{"record":{"id":"54147a9aea74d8eb","repo":"can1357/oh-my-pi","slug":"security-output-directory-does-not-have-a-canonica","errorCode":null,"errorMessage":"Security output directory does not have a canonical identity","messagePattern":"Security output directory does not have a canonical identity","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/coding-agent/src/security/preflight.ts","lineNumber":259,"sourceCode":"\nasync function normalizeOutput(\n\trepositoryRoot: string,\n\toutputRoot: string,\n\tarchiveExisting: boolean,\n): Promise<SecurityOutputPlan> {\n\tconst requested = path.resolve(outputRoot);\n\tconst parent = await fs.realpath(path.dirname(requested));\n\tconst canonicalCandidate = path.join(parent, path.basename(requested));\n\tif (pathIsWithin(canonicalCandidate, repositoryRoot)) {\n\t\tthrow new Error(\"Security output directory must be outside the scanned repository\");\n\t}\n\tlet existingState: SecurityOutputPlan[\"existingState\"] = \"absent\";\n\ttry {\n\t\tconst stats = await fs.lstat(canonicalCandidate);\n\t\tif (stats.isSymbolicLink()) throw new Error(\"Security output directory must not be a symbolic link\");\n\t\tif (!stats.isDirectory()) throw new Error(\"Security output path exists and is not a directory\");\n\t\tconst real = await fs.realpath(canonicalCandidate);\n\t\tif (real !== canonicalCandidate) throw new Error(\"Security output directory does not have a canonical identity\");\n\t\tconst entries = await fs.readdir(canonicalCandidate);\n\t\texistingState = entries.length === 0 ? \"empty\" : \"archivable\";\n\t\tif (entries.length > 0 && !archiveExisting) {\n\t\t\tthrow new Error(\"Security output directory is not empty; enable archiveExisting or choose another directory\");\n\t\t}\n\t} catch (error) {\n\t\tif (!(error instanceof Error && \"code\" in error && error.code === \"ENOENT\")) throw error;\n\t\tawait fs.mkdir(canonicalCandidate, { recursive: false, mode: 0o700 });\n\t\texistingState = \"empty\";\n\t}\n\tif (process.platform !== \"win32\") await fs.chmod(canonicalCandidate, 0o700);\n\treturn { root: canonicalCandidate, archiveExisting, existingState };\n}\n\nexport interface PreparedSecurityOutput {\n\troot: string;\n\tarchivedTo?: string;\n}","sourceCodeStart":241,"sourceCodeEnd":277,"githubUrl":"https://github.com/can1357/oh-my-pi/blob/969062200754ea02cfac922e5ebb8c608c079e15/packages/coding-agent/src/security/preflight.ts#L241-L277","documentation":"normalizeOutput requires the output directory's canonical identity to match the resolved path: after confirming it is a real directory, realpath(canonicalCandidate) must equal canonicalCandidate exactly. A mismatch means hidden symlinks in the chain (or a link resolving to elsewhere) would make writes land somewhere other than the configured location, so the path is rejected.","triggerScenarios":"Calling output()/normalizeOutput where some ancestor component of outputRoot is a symlink so realpath differs from path.join(realpath(dirname), basename) — e.g. outputRoot=\"/var/link/sec-out\" where /var/link -> /mnt/data.","commonSituations":"macOS /tmp -> /private/tmp; home directories symlinked (e.g. /home/user -> /usr/home/user); CI runners exposing symlinked workspace roots; network mounts referenced via link aliases.","solutions":["Resolve the path first and use the real path in config: `realpath <output-path>`, then configure that value.","Replace the symlinked ancestor with a bind/real directory if you need the literal path.","On macOS, use /private/tmp instead of /tmp if you need literal equality."],"exampleFix":"// before\nconst plan = await output({ outputRoot: \"/tmp/sec-report\", repositoryRoot });\n// after (macOS, /tmp -> /private/tmp)\nconst plan = await output({ outputRoot: \"/private/tmp/sec-report\", repositoryRoot });","handlingStrategy":"validation","validationCode":"import * as path from \"node:path\";\nimport * as fs from \"node:fs/promises\";\nconst requested = path.resolve(outputRoot);\nconst canonical = path.join(await fs.realpath(path.dirname(requested)), path.basename(requested));\nconst real = await fs.realpath(canonical);\nif (real !== canonical) {\n  console.warn(`Using canonical path ${real} instead of ${outputRoot}`);\n}","typeGuard":null,"tryCatchPattern":"try {\n  await output(options);\n} catch (err) {\n  if ((err as Error).message.includes(\"does not have a canonical identity\")) {\n    options.outputRoot = await fs.realpath(path.resolve(options.outputRoot));\n    return output(options); // retry with the resolved path\n  }\n  throw err;\n}","preventionTips":["Store realpath-resolved absolute paths in configuration files.","On macOS prefer /private/tmp over /tmp in persisted config.","Avoid symlinked ancestors (mount points, /var/aliases) for scan output paths."],"tags":["filesystem","symlink","path","security"],"backgroundTag":"symlink-not-allowed","analyzedSha":"969062200754ea02cfac922e5ebb8c608c079e15","analyzedAt":"2026-08-31T10:29:35.737Z","schemaVersion":2},"datasetVersion":"2026-08-31T14:17:45.589Z"}