{"record":{"id":"3a1165a061e6b5a2","repo":"can1357/oh-my-pi","slug":"ssh-control-directory-dir-issymlink-is-a-s","errorCode":null,"errorMessage":"SSH control directory ${dir} ${isSymlink ? \"is a symlink\" : \"is not a directory\"}","messagePattern":"SSH control directory (.+?) (.+?)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"packages/coding-agent/src/ssh/connection-manager.ts","lineNumber":201,"sourceCode":" * Exported as a test seam.\n */\nexport function assertOwnerPrivateDir(dir: string): void {\n\tconst uid = process.getuid?.();\n\tlet fd: number;\n\ttry {\n\t\tfd = fs.openSync(dir, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_DIRECTORY);\n\t} catch (err) {\n\t\tconst code = (err as NodeJS.ErrnoException).code;\n\t\t// O_NOFOLLOW rejects a symlinked final component; kernels report it as\n\t\t// either ELOOP or (with O_DIRECTORY) ENOTDIR. Either way the entry is\n\t\t// already refused — we only lstat here to label the failure precisely, so\n\t\t// a swap after this point cannot weaken the (already-final) rejection.\n\t\tif (code === \"ELOOP\" || code === \"ENOTDIR\") {\n\t\t\tlet isSymlink = false;\n\t\t\ttry {\n\t\t\t\tisSymlink = fs.lstatSync(dir).isSymbolicLink();\n\t\t\t} catch {}\n\t\t\tthrow new Error(`SSH control directory ${dir} ${isSymlink ? \"is a symlink\" : \"is not a directory\"}`);\n\t\t}\n\t\tthrow err;\n\t}\n\ttry {\n\t\tlet st = fs.fstatSync(fd);\n\t\t// Normalize perms on the pinned inode only when it is ours; never fchmod a\n\t\t// directory another user owns.\n\t\tif ((uid === undefined || st.uid === uid) && (st.mode & 0o777) !== 0o700) {\n\t\t\ttry {\n\t\t\t\tfs.fchmodSync(fd, 0o700);\n\t\t\t\tst = fs.fstatSync(fd);\n\t\t\t} catch (err) {\n\t\t\t\tlogger.debug(\"SSH control dir chmod failed\", { path: dir, error: String(err) });\n\t\t\t}\n\t\t}\n\t\tconst reason = controlDirGuardError(\n\t\t\t{ isSymlink: false, isDir: st.isDirectory(), uid: st.uid, mode: st.mode },\n\t\t\tuid,","sourceCodeStart":183,"sourceCodeEnd":219,"githubUrl":"https://github.com/can1357/oh-my-pi/blob/969062200754ea02cfac922e5ebb8c608c079e15/packages/coding-agent/src/ssh/connection-manager.ts#L183-L219","documentation":"assertOwnerPrivateDir pins and validates the SSH ControlMaster directory. If the open fails with ELOOP (symlink loop) or ENOTDIR, it lstats the path to determine whether it is a symlink and throws a descriptive error — the control directory path is a symlink or not a directory, which could enable symlink attacks or break socket placement. The path is rejected regardless of race outcome after this point.","triggerScenarios":"SSH control master dir configured (e.g. via env or options) pointing at: a symlink, a regular file, or a symlink loop; path created by another tool as a link; corrupted tmp/userdata layout.","commonSituations":"Users pointing the control dir at ~/control when that's a symlink into Dropbox/iCloud, tampered or overly-permissive shared tmp directories, leftover symlink from a previous setup script.","solutions":["Remove the symlink or replace it with a real directory: rm the path and mkdir -p it","Point the control directory configuration at a genuine, private directory (e.g. ~/.ssh/controlmaster)","Check `ls -ld <dir>` to see whether it is a symlink and where it points","Ensure no startup script recreates the symlink"],"exampleFix":"# before\n$ ls -ld ~/.ssh/ctl\nlrwxr-xr-x ~/.ssh/ctl -> /tmp/ctl\n# after\n$ rm ~/.ssh/ctl && mkdir -m 700 ~/.ssh/ctl","handlingStrategy":"validation","validationCode":"import * as fs from \"node:fs\";\nconst st = fs.lstatSync(dir);\nif (st.isSymbolicLink() || !st.isDirectory()) {\n  fs.rmSync(dir, { recursive: true, force: true });\n  fs.mkdirSync(dir, { mode: 0o700 });\n}","typeGuard":"function isRealDir(p: string): boolean { try { const s = fs.lstatSync(p); return s.isDirectory() && !s.isSymbolicLink(); } catch { return false; } }","tryCatchPattern":"try { await ensureSshControlDir(dir); } catch (e) { if (e instanceof Error && /is a symlink|is not a directory/.test(e.message)) { fs.rmSync(dir, { force: true, recursive: true }); fs.mkdirSync(dir, { mode: 0o700 }); await ensureSshControlDir(dir); } else throw e; }","preventionTips":["Point the control dir at a real private directory (0700), not a symlinked cloud-synced path","Check `ls -ld` before configuring a custom control directory","Avoid setup scripts that create symlinks at the control path","Keep the directory out of shared /tmp locations"],"tags":["ssh","security","symlink","filesystem"],"backgroundTag":"symlink-path-rejected","analyzedSha":"969062200754ea02cfac922e5ebb8c608c079e15","analyzedAt":"2026-08-31T10:29:35.737Z","schemaVersion":2},"datasetVersion":"2026-08-31T14:17:45.589Z"}