{"record":{"id":"c505457db6544590","repo":"JuliusBrussee/caveman","slug":"caveman-agent-dev-kind-escapes-project-root","errorCode":null,"errorMessage":"caveman agent: dev ${kind} escapes project root","messagePattern":"caveman agent: dev (.+?) escapes project root","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"packages/agent/src/dev-loader.ts","lineNumber":146,"sourceCode":"async function copyOptional(source: string, destination: string): Promise<void> {\n  try {\n    await readFile(source);\n    await mkdir(dirname(destination), { recursive: true });\n    await copyFile(source, destination);\n  } catch (error) {\n    if ((error as NodeJS.ErrnoException).code !== \"ENOENT\") throw error;\n  }\n}\n\nasync function copyProjectFile(\n  root: string,\n  staging: string,\n  source: string,\n  kind: \"source graph\" | \"file source\",\n): Promise<void> {\n  const name = relative(root, source);\n  if (escapesRoot(name)) {\n    throw new Error(`caveman agent: dev ${kind} escapes project root`);\n  }\n  const canonicalSource = await realpath(source);\n  if (escapesRoot(relative(root, canonicalSource))) {\n    throw new Error(`caveman agent: dev ${kind} symlink escapes project root`);\n  }\n  const destination = resolve(staging, name);\n  await mkdir(dirname(destination), { recursive: true });\n  await copyFile(canonicalSource, destination);\n}\n\nfunction remapDevError(error: unknown, staging: string, root: string): unknown {\n  if (!(error instanceof Error)) return error;\n  const mapped = new Error(error.message, { cause: error.cause });\n  mapped.name = error.name;\n  mapped.stack = (error.stack ?? `${error.name}: ${error.message}`).replaceAll(staging, root);\n  return mapped;\n}\n","sourceCodeStart":128,"sourceCodeEnd":164,"githubUrl":"https://github.com/JuliusBrussee/caveman/blob/3ee70a102609e550bd2e68004bf5990a9341c851/packages/agent/src/dev-loader.ts#L128-L164","documentation":"During dev-module loading, copyProjectFile stages a project file into a temporary staging directory and refuses any file whose path resolves outside the project root. The library throws this to prevent dev tooling from reading or copying files outside the project. It fires before any filesystem copy happens, as a path-traversal guard on the relative path computed from root.","triggerScenarios":"Calling loadDevModule, includeFiles, includeOptionalFiles, includeSourceGraph, or includeAgentFiles with a source path that is not inside the loader's project root, e.g. an absolute path in another directory or a '../'-relative path that climbs above root.","commonSituations":"Dev config pointing include globs at a shared monorepo directory outside the package root; a symlinked node_modules or workspace layout where source files are resolved from a sibling package; passing an absolute path from a temp dir into includeFiles.","solutions":["Move or symlink the referenced file inside the project root so relative(root, source) stays within root.","Fix the dev-loader config (include globs/file sources) to reference only paths under the project root.","If the file genuinely lives outside root, copy it into the project or use a proper workspace/package link instead of a raw path.","Check that root passed to the loader is the actual intended project root (e.g. package dir, not repo root or vice versa)."],"exampleFix":"// before\nawait includeFiles(agent, [\"/tmp/shared/extra.ts\"]);\n// after\ncp(\"/tmp/shared/extra.ts\", \"./vendor/extra.ts\");\nawait includeFiles(agent, [\"./vendor/extra.ts\"]);","handlingStrategy":"validation","validationCode":"import { relative, isAbsolute, resolve } from \"node:path\";\nfunction pathInsideRoot(root: string, source: string): boolean {\n  const abs = isAbsolute(source) ? source : resolve(root, source);\n  const rel = relative(root, abs);\n  return rel !== \"\" && !rel.startsWith(\"..\") && !isAbsolute(rel);\n}\n// call pathInsideRoot(root, file) for each file before loadDevModule/includeFiles","typeGuard":"function isInsideRoot(root: string, source: string): boolean {\n  const rel = relative(root, source);\n  return rel !== \"\" && !rel.startsWith(\"..\") && !isAbsolute(rel);\n}","tryCatchPattern":"try {\n  await loadDevModule(entry);\n} catch (err) {\n  if (err instanceof Error && err.message.includes(\"escapes project root\")) {\n    // surface the offending path from config and fail fast with a clear message\n    throw new Error(`dev-loader config references a path outside the project root: ${err.message}`);\n  }\n  throw err;\n}","preventionTips":["Keep all dev-include sources under the package root and use relative paths in config.","Audit include globs when moving code into or out of a monorepo.","Realpath the loader root at startup so comparisons are consistent.","Add a preflight check that iterates configured include files and asserts isInsideRoot for each."],"tags":["security","path-traversal","dev-loader","filesystem"],"backgroundTag":"path-traversal-blocked","analyzedSha":"3ee70a102609e550bd2e68004bf5990a9341c851","analyzedAt":"2026-09-20T15:53:39.229Z","contentChangedAt":"2026-09-20T15:53:39.229Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}