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

  1. 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.
  2. 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)"`.
  3. Point the remote backend's HOME/hermes home to a real directory instead of a symlinked one.
  4. 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

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


AI-assisted analysis of NousResearch/hermes-agent@c896c09c42 (2026-08-14). Data as JSON: /api/errors/cb5d35915c827395. Report an issue: GitHub.