{"record":{"id":"a84429ee34a6d17a","repo":"NousResearch/hermes-agent","slug":"unsafe-ssh-control-dir-controldir-is-owned-by","errorCode":null,"errorMessage":"Unsafe SSH control dir: ${controlDir} is owned by uid ${st.uid}, not ${process.getuid!()}.","messagePattern":"Unsafe SSH control dir: (.+?) is owned by uid (.+?), not (.+?)\\.","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"apps/desktop/electron/ssh-connection.ts","lineNumber":596,"sourceCode":"    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\n\n    try {\n      result = await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn })\n    } catch (error) {\n      throw this._fail(error, SSH_ERROR.UNREACHABLE)\n    }\n\n    if (result.code !== 0) {","sourceCodeStart":578,"sourceCodeEnd":614,"githubUrl":"https://github.com/NousResearch/hermes-agent/blob/c896c09c42910c584c4c7d2325b58c14713ea42c/apps/desktop/electron/ssh-connection.ts#L578-L614","documentation":"Thrown during SSH control-master setup (POSIX only) when the control directory's owner uid does not match the current process uid (st.uid !== process.getuid()). A control dir owned by another user could let them control or read the multiplexed socket, so ownership is enforced before the master connection opens.","triggerScenarios":"The dir was created by root (app once run with sudo), by a different local user, or by a system service — lstatSync reports a uid different from the current process uid.","commonSituations":"App was once launched with sudo, leaving root-owned dirs in the user's cache/runtime path; shared machines where another user pre-created the dir; containers where uid mapping differs between runs.","solutions":["Fix ownership: sudo chown -R $(id -u):$(id -g) <controlDir>, or simply delete it so the app recreates it as the current user.","Avoid running the desktop app as root/sudo.","In containers, ensure consistent uids across restarts or point the control path at a per-uid directory."],"exampleFix":"# before (shell)\nsudo chown -R $(id -u):$(id -g) ~/.cache/hermes/ssh-control\n# or simply\nrm -rf ~/.cache/hermes/ssh-control   # app recreates it as current user\n\n# after: control master connects normally","handlingStrategy":"validation","validationCode":"import fs from 'node:fs'\n\nfunction isControlDirOwnedByMe(dir: string): boolean {\n  try {\n    const st = fs.lstatSync(dir)\n    return st.isDirectory() && st.uid === process.getuid!()\n  } catch {\n    return false\n  }\n}\n\nif (!isControlDirOwnedByMe(controlDir)) {\n  // refuse and instruct the user (or relocate the dir) before opening the master\n}","typeGuard":null,"tryCatchPattern":"try {\n  await conn.open()\n} catch (e) {\n  if (e instanceof Error && /owned by uid/.test(e.message)) {\n    showFixHint(`Run: sudo chown -R $(id -u):$(id -g) ${path.dirname(conn.controlPath)}`)\n    return\n  }\n  throw e\n}","preventionTips":["Never run the desktop app under sudo — it leaves root-owned dirs in user paths.","Place the control dir in a per-user runtime path (userData / XDG_RUNTIME_DIR) the app owns.","On containerized setups, keep a stable uid across container restarts."],"tags":["ssh","security","permissions","filesystem"],"backgroundTag":null,"analyzedSha":"c896c09c42910c584c4c7d2325b58c14713ea42c","analyzedAt":"2026-08-14T17:18:01.089Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}