NousResearch/hermes-agent · error · Error

Unsafe SSH control dir: ${controlDir} is a symlink.

Error message

Unsafe SSH control dir: ${controlDir} is a symlink.

What it means

Thrown during SSH control-master setup (POSIX only) when lstat() on the control-socket directory — path.dirname(controlPath) — reports it is a symbolic link. A symlinked control dir could point at a world-writable or attacker-controlled location where another process could pre-create or hijack the multiplexed control socket, so the connection refuses to proceed. The check runs after a best-effort mkdirSync(recursive, 0700) whose error is swallowed.

Source

Thrown at apps/desktop/electron/ssh-connection.ts:588

      this._opened = true
      this._logLine('connection verified (no-mux; per-operation ssh)')

      return
    }

    const controlDir = path.dirname(this.controlPath)

    try {
      fs.mkdirSync(controlDir, { recursive: true, mode: 0o700 })
    } catch {
      void 0
    }

    if (process.platform !== 'win32') {
      const st = fs.lstatSync(controlDir)

      if (st.isSymbolicLink()) {
        throw new Error(`Unsafe SSH control dir: ${controlDir} is a symlink.`)
      }

      if (!st.isDirectory()) {
        throw new Error(`Unsafe SSH control dir: ${controlDir} is not a directory.`)
      }

      if (st.uid !== process.getuid!()) {
        throw new Error(`Unsafe SSH control dir: ${controlDir} is owned by uid ${st.uid}, not ${process.getuid!()}.`)
      }

      if ((st.mode & 0o777) !== 0o700) {
        fs.chmodSync(controlDir, 0o700)
      }
    }

    const args = buildMasterArgs(this, this._connectTimeoutMs)
    this._logLine(`opening control master to ${target(this.user, this.host)}:${this.port}`)
    let result

View on GitHub (pinned to c896c09c42)

Solutions

  1. Replace the symlink with a real directory (mkdir the actual target path) so lstat sees a directory.
  2. Configure the control path base to a non-symlinked location the app owns (e.g. under the app's userData dir).
  3. Do not bypass the check — it is a socket-hijacking guard; fix the filesystem layout instead.

Example fix

# before (shell)
~/.cache/hermes/ssh-control -> /mnt/shared/ssh-control   # symlink

# after
rm ~/.cache/hermes/ssh-control
mkdir -m 700 ~/.cache/hermes/ssh-control
Defensive patterns

Strategy: validation

Validate before calling

import fs from 'node:fs'
import path from 'node:path'

// Before opening the control master, ensure the control dir is a real, app-owned directory
function isRealDir(dir: string): boolean {
  try {
    return fs.lstatSync(dir).isDirectory() // lstat: does not follow symlinks
  } catch {
    return false
  }
}

if (!isRealDir(path.dirname(conn.controlPath))) {
  // relocate controlPath to an app-owned real directory before connecting
}

Try / catch

try {
  await conn.open()
} catch (e) {
  if (e instanceof Error && /is a symlink/.test(e.message)) {
    // point controlPath at an app-owned real directory, then retry once
    setControlDir(path.join(app.getPath('userData'), 'ssh-control'))
    await conn.open()
  } else throw e
}

Prevention

When it happens

Trigger: lstatSync(controlDir).isSymbolicLink() — e.g. the runtime/cache dir holding controlPath is symlinked to another volume, a user symlinked their cache dir, or an attacker pre-planted a symlink to hijack the mux socket.

Common situations: Users who symlinked XDG cache/runtime directories (or ~/.hermes state dirs) to another disk; tmp cleaners or system setups replacing dirs with symlinks; shared-machine tampering.

Related errors


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