{"record":{"id":"467bbb6e9734fcc1","repo":"can1357/oh-my-pi","slug":"security-output-directory-must-be-outside-the-scan","errorCode":null,"errorMessage":"Security output directory must be outside the scanned repository","messagePattern":"Security output directory must be outside the scanned repository","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/coding-agent/src/security/preflight.ts","lineNumber":251,"sourceCode":"\t\tconst canonical = await fs.realpath(path.resolve(baseDirectory, input));\n\t\tconst stats = await fs.stat(canonical);\n\t\tif (!stats.isFile()) throw new Error(`Security knowledge base is not a file: ${input}`);\n\t\tconst digest = await hashFile(canonical);\n\t\tresults.push({ path: canonical, sha256: digest.sha256, size: digest.size });\n\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}","sourceCodeStart":233,"sourceCodeEnd":269,"githubUrl":"https://github.com/can1357/oh-my-pi/blob/969062200754ea02cfac922e5ebb8c608c079e15/packages/coding-agent/src/security/preflight.ts#L233-L269","documentation":"normalizeOutput validates that the configured security-scan output directory lives outside the repository being scanned. The requested path is resolved and canonicalized against its parent, then pathIsWithin() checks containment against repositoryRoot; an output dir inside the repo would let scan results pollute (or be re-scanned from) the tree, so it is rejected.","triggerScenarios":"Calling output()/normalizeOutput with an outputRoot that resolves inside repositoryRoot — e.g. outputRoot=\"./security-report\" in a repo at /repo, or any path like /repo/../repo/out.","commonSituations":"Developer sets the report dir next to the code inside the project ('./reports', 'dist/security'); relative paths accidentally resolve into the repo because cwd is the repo root; symlink or '..' tricks that fold back inside the repo.","solutions":["Move the output directory outside the repository, e.g. /tmp/security-report or ~/security-reports/<project>.","Add the path to the config's outputRoot option instead of relying on a repo-relative default.","Verify resolution: run `realpath -m <your-output-path>` and confirm the result is not under the repo root."],"exampleFix":"// before\nconst plan = await output({ outputRoot: \"./reports\", repositoryRoot });\n// after\nconst plan = await output({ outputRoot: \"/tmp/security-reports/myproj\", repositoryRoot });","handlingStrategy":"validation","validationCode":"import * as path from \"node:path\";\nimport * as fs from \"node:fs/promises\";\nconst requested = path.resolve(outputRoot);\nconst real = path.join(await fs.realpath(path.dirname(requested)), path.basename(requested));\nconst rel = path.relative(repositoryRoot, real);\nif (rel === \"\" || (!rel.startsWith(\"..\") && !path.isAbsolute(rel))) {\n  throw new Error(\"Choose an outputRoot outside the repository\");\n}","typeGuard":null,"tryCatchPattern":"try {\n  await output(options);\n} catch (err) {\n  if ((err as Error).message.includes(\"must be outside the scanned repository\")) {\n    // prompt user for an external directory, e.g. /tmp or ~\n  }\n  throw err;\n}","preventionTips":["Default outputRoot to an absolute path outside any repo (/tmp, ~/security-reports).","Never use repo-relative defaults for scan output.","Check containment with path.relative before configuring."],"tags":["filesystem","path","security","validation"],"backgroundTag":"path-outside-allowed-root","analyzedSha":"969062200754ea02cfac922e5ebb8c608c079e15","analyzedAt":"2026-08-31T10:29:35.737Z","schemaVersion":2},"datasetVersion":"2026-08-31T14:17:45.589Z"}