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 resultView on GitHub (pinned to c896c09c42)
Solutions
- Replace the symlink with a real directory (mkdir the actual target path) so lstat sees a directory.
- Configure the control path base to a non-symlinked location the app owns (e.g. under the app's userData dir).
- 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
- Keep the SSH control dir inside the app's userData directory, which the app creates itself.
- Never replace the check with realpath-following — it exists to stop control-socket hijacking.
- On managed fleets, ensure user profile dirs are real directories, not symlinked to shared storage.
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
- Unsafe SSH control dir: ${controlDir} is not a directory.
- Unsafe SSH control dir: ${controlDir} is owned by uid ${st.u
- Refusing to write iron-proxy log {log_path}: {exc}. Remove
- unsafe token directory
- Unsafe SSH target: host must not start with a dash ("${host}
AI-assisted analysis of NousResearch/hermes-agent@c896c09c42 (2026-08-14).
Data as JSON: /api/errors/3eaf04317a18802a.
Report an issue: GitHub.