{"record":{"id":"3eaf04317a18802a","repo":"NousResearch/hermes-agent","slug":"unsafe-ssh-control-dir-controldir-is-a-symlink","errorCode":null,"errorMessage":"Unsafe SSH control dir: ${controlDir} is a symlink.","messagePattern":"Unsafe SSH control dir: (.+?) is a symlink\\.","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"apps/desktop/electron/ssh-connection.ts","lineNumber":588,"sourceCode":"      this._opened = true\n      this._logLine('connection verified (no-mux; per-operation ssh)')\n\n      return\n    }\n\n    const controlDir = path.dirname(this.controlPath)\n\n    try {\n      fs.mkdirSync(controlDir, { recursive: true, mode: 0o700 })\n    } catch {\n      void 0\n    }\n\n    if (process.platform !== 'win32') {\n      const st = fs.lstatSync(controlDir)\n\n      if (st.isSymbolicLink()) {\n        throw new Error(`Unsafe SSH control dir: ${controlDir} is a symlink.`)\n      }\n\n      if (!st.isDirectory()) {\n        throw new Error(`Unsafe SSH control dir: ${controlDir} is not a directory.`)\n      }\n\n      if (st.uid !== process.getuid!()) {\n        throw new Error(`Unsafe SSH control dir: ${controlDir} is owned by uid ${st.uid}, not ${process.getuid!()}.`)\n      }\n\n      if ((st.mode & 0o777) !== 0o700) {\n        fs.chmodSync(controlDir, 0o700)\n      }\n    }\n\n    const args = buildMasterArgs(this, this._connectTimeoutMs)\n    this._logLine(`opening control master to ${target(this.user, this.host)}:${this.port}`)\n    let result","sourceCodeStart":570,"sourceCodeEnd":606,"githubUrl":"https://github.com/NousResearch/hermes-agent/blob/c896c09c42910c584c4c7d2325b58c14713ea42c/apps/desktop/electron/ssh-connection.ts#L570-L606","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"# before (shell)\n~/.cache/hermes/ssh-control -> /mnt/shared/ssh-control   # symlink\n\n# after\nrm ~/.cache/hermes/ssh-control\nmkdir -m 700 ~/.cache/hermes/ssh-control","handlingStrategy":"validation","validationCode":"import fs from 'node:fs'\nimport path from 'node:path'\n\n// Before opening the control master, ensure the control dir is a real, app-owned directory\nfunction isRealDir(dir: string): boolean {\n  try {\n    return fs.lstatSync(dir).isDirectory() // lstat: does not follow symlinks\n  } catch {\n    return false\n  }\n}\n\nif (!isRealDir(path.dirname(conn.controlPath))) {\n  // relocate controlPath to an app-owned real directory before connecting\n}","typeGuard":null,"tryCatchPattern":"try {\n  await conn.open()\n} catch (e) {\n  if (e instanceof Error && /is a symlink/.test(e.message)) {\n    // point controlPath at an app-owned real directory, then retry once\n    setControlDir(path.join(app.getPath('userData'), 'ssh-control'))\n    await conn.open()\n  } else throw e\n}","preventionTips":["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."],"tags":["ssh","security","filesystem","symlink"],"backgroundTag":null,"analyzedSha":"c896c09c42910c584c4c7d2325b58c14713ea42c","analyzedAt":"2026-08-14T17:18:01.089Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}