NousResearch/hermes-agent · critical · SystemExit
unsafe token directory
Error message
unsafe token directory
What it means
SystemExit('unsafe token directory') from the embedded Python token-upload script in apps/desktop/electron/remote-lifecycle.ts:536. The script opens the token file's parent directory with O_NOFOLLOW|O_DIRECTORY (best-effort via getattr) and then fstat's the fd; if the fd is not a real directory (symlink traversal when O_NOFOLLOW/O_DIRECTORY are unavailable, or a race replaced the entry), it aborts before writing the auth token. This is a hard security guard on where the remote-backend session token may land.
Source
Thrown at apps/desktop/electron/remote-lifecycle.ts:536
throw err
}
const spawnNonce = crypto.randomBytes(8).toString('hex')
const tokenDir = ownershipDirectory(ownershipId)
const tokenFilePath = `${tokenDir}/${spawnNonce}.token`
const logPath = spawnLogPath(ownershipId, spawnNonce)
const tokenUploadPy =
'import os,sys,stat\n' +
`p=os.path.expanduser(${shq(tokenFilePath)})\n` +
'd=os.path.dirname(p)\n' +
'n=os.path.basename(p)\n' +
'os.makedirs(d,mode=0o700,exist_ok=True)\n' +
'df=os.O_RDONLY|getattr(os,"O_DIRECTORY",0)|getattr(os,"O_NOFOLLOW",0)\n' +
'dd=os.open(d,df)\n' +
'try:\n' +
' s=os.fstat(dd)\n' +
' if not stat.S_ISDIR(s.st_mode):raise SystemExit("unsafe token directory")\n' +
' if hasattr(os,"getuid") and s.st_uid!=os.getuid():raise SystemExit("token directory owner mismatch")\n' +
' if (s.st_mode&0o777)!=0o700:os.fchmod(dd,0o700)\n' +
' fl=os.O_WRONLY|os.O_CREAT|os.O_EXCL|getattr(os,"O_NOFOLLOW",0)\n' +
' now=__import__("time").time()\n' +
' for stale in os.listdir(dd):\n' +
' if stale.endswith(".token") and len(stale)==22:\n' +
' try:\n' +
' ss=os.stat(stale,dir_fd=dd,follow_symlinks=False)\n' +
' if stat.S_ISREG(ss.st_mode) and now-ss.st_mtime>3600:os.unlink(stale,dir_fd=dd)\n' +
' except OSError:pass\n' +
' fd=os.open(n,fl,0o600,dir_fd=dd)\n' +
' try:os.write(fd,sys.stdin.buffer.read())\n' +
' except BaseException:\n' +
' try:os.unlink(n,dir_fd=dd)\n' +
' except OSError:pass\n' +
' raise\n' +
' finally:os.close(fd)\n' +
'finally:os.close(dd)'View on GitHub (pinned to c896c09c42)
Solutions
- Make the token directory a real directory owned by the current user with no symlinks on the path: replace the symlink with a bind mount or move the real directory.
- Verify manually: `ls -ld <dir>` must show a directory (not 'l') and your uid as owner; `python -c "import os,stat;s=os.stat('<dir>');print(stat.S_ISDIR(s.st_mode), s.st_uid)"`.
- Point the remote backend's HOME/hermes home to a real directory instead of a symlinked one.
- Never bypass the check by pre-creating the token file yourself — the guard exists to keep the session token out of attacker-writable locations.
Example fix
# shell: before — dir is a symlink, upload aborts $ ls -ld ~/.hermes/remote drwx------ ... -> /mnt/other/tokens # 'lrwxrwxrwx' actually # after — replace symlink with a real dir owned by you $ rm ~/.hermes/remote && mkdir -m 700 ~/.hermes/remote
Defensive patterns
Strategy: validation
Validate before calling
import os, stat
def token_dir_is_safe(path: str) -> bool:
p = os.path.realpath(path)
s = os.stat(p, follow_symlinks=False)
return (
stat.S_ISDIR(s.st_mode)
and (not hasattr(os, "getuid") or s.st_uid == os.getuid())
and (s.st_mode & 0o777) == 0o700
) Prevention
- Never place the token directory behind a symlink; use real directories.
- Set the directory to mode 0700 owned by your uid before starting the remote backend.
- Do not pre-create or hand-edit token files — the uploader enforces O_EXCL creation.
When it happens
Trigger: The computed token directory path (or a component of it) is a symlink and the platform lacks O_NOFOLLOW/O_DIRECTORY enforcement; the path exists but is a file, not a directory; a hostile or misconfigured HOME/XDG layout redirects the token dir.
Common situations: Users with symlinked home/config dirs (dotfiles managers, macOS data-volume symlinks); containers or NFS mounts where O_NOFOLLOW is unsupported; a tampered or manually created file occupying the directory path; platform where getattr(os, 'O_DIRECTORY', 0) returned 0 so the open() checks degrade.
Related errors
- Unsafe SSH control dir: ${controlDir} is a symlink.
- Refusing to write iron-proxy log {log_path}: {exc}. Remove
- Secure token storage returned no encrypted payload; refusing
- Unsafe SSH control dir: ${controlDir} is not a directory.
- Unsafe SSH control dir: ${controlDir} is owned by uid ${st.u
AI-assisted analysis of NousResearch/hermes-agent@c896c09c42 (2026-08-14).
Data as JSON: /api/errors/cb5d35915c827395.
Report an issue: GitHub.