{"record":{"id":"ebcb55d1b402e2ef","repo":"can1357/oh-my-pi","slug":"security-output-directory-must-not-be-a-symbolic-l","errorCode":null,"errorMessage":"Security output directory must not be a symbolic link","messagePattern":"Security output directory must not be a symbolic link","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/coding-agent/src/security/preflight.ts","lineNumber":256,"sourceCode":"\t}\n\treturn results.sort((left, right) => left.path.localeCompare(right.path));\n}\n\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 {","sourceCodeStart":238,"sourceCodeEnd":274,"githubUrl":"https://github.com/can1357/oh-my-pi/blob/969062200754ea02cfac922e5ebb8c608c079e15/packages/coding-agent/src/security/preflight.ts#L238-L274","documentation":"During normalizeOutput, an lstat on the canonical output candidate reveals an existing entry that is a symbolic link; this is rejected. Symlinks are disallowed so the canonical identity check (realpath must equal the resolved path) can guarantee scan output cannot be redirected through a link to an unexpected location.","triggerScenarios":"Calling output()/normalizeOutput where outputRoot already exists on disk and lstat reports it as a symlink — e.g. ~/sec-out -> /mnt/data/sec-out.","commonSituations":"dotfile managers (stow, chezmoi) symlink config dirs; users symlink a shared output folder into their home; previous tooling replaced the dir with a link; copying a config between machines where a path became a symlink.","solutions":["Remove the symlink (`rm <path>`) and create a real directory at that path (`mkdir <path>`).","Choose a different outputRoot that is a real directory.","If the data lives elsewhere, configure outputRoot to the actual target location (the real directory), not the link."],"exampleFix":"// shell\n# before: ~/sec-out -> /mnt/data/sec-out (symlink)\nrm ~/sec-out\nmkdir ~/sec-out  # real directory\n// after: ~/sec-out is a real directory","handlingStrategy":"validation","validationCode":"import * as fs from \"node:fs/promises\";\ntry {\n  const st = await fs.lstat(outputPath);\n  if (st.isSymbolicLink()) throw new Error(`${outputPath} is a symlink; replace it with a real directory`);\n} catch (e) {\n  if (!(e as NodeJS.ErrnoException).code || (e as NodeJS.ErrnoException).code !== \"ENOENT\") throw e;\n}","typeGuard":"function isRealDirectoryError(err: unknown): err is Error & { message: string } {\n  return err instanceof Error && err.message.includes(\"must not be a symbolic link\");\n}","tryCatchPattern":"try {\n  await output(options);\n} catch (err) {\n  if ((err as Error).message.includes(\"must not be a symbolic link\")) {\n    await fs.unlink(outputPath);\n    await fs.mkdir(outputPath);\n    return output(options); // retry once with a real directory\n  }\n  throw err;\n}","preventionTips":["Avoid symlinked directories for scan output; use real paths.","If using dotfile managers (stow/chezmoi), exclude output directories from symlink management.","lstat the configured path before running to detect links early."],"tags":["filesystem","symlink","security"],"backgroundTag":"symlink-not-allowed","analyzedSha":"969062200754ea02cfac922e5ebb8c608c079e15","analyzedAt":"2026-08-31T10:29:35.737Z","schemaVersion":2},"datasetVersion":"2026-08-31T14:17:45.589Z"}