{"record":{"id":"7d9faffed951bf0d","repo":"JuliusBrussee/caveman","slug":"caveman-agent-dev-kind-symlink-escapes-project","errorCode":null,"errorMessage":"caveman agent: dev ${kind} symlink escapes project root","messagePattern":"caveman agent: dev (.+?) symlink escapes project root","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/agent/src/dev-loader.ts","lineNumber":150,"sourceCode":"    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\nfunction escapesRoot(path: string): boolean {\n  return path === \"..\" || path.startsWith(\"../\") || path.startsWith(\"..\\\\\") || isAbsolute(path);\n}\n","sourceCodeStart":132,"sourceCodeEnd":168,"githubUrl":"https://github.com/JuliusBrussee/caveman/blob/27d5a3981a347890211bb1bf2439e5c821a63bc9/packages/agent/src/dev-loader.ts#L132-L168","documentation":"Thrown by copyProjectFile in the dev loader (packages/agent/src/dev-loader.ts:150): the lexical path is inside the root, but realpath() of the file resolves through a symlink to a location outside the project root. Dev staging is realpath-based, so symlinks that escape the root are rejected even when the link itself sits inside the project.","triggerScenarios":"A FileSource or source-graph file inside the project root that is a symlink whose target is outside the root (e.g., node_modules-style symlinking to a pnpm store, or a manually linked prompt file pointing to /home or another repo).","commonSituations":"pnpm-style symlinked dependencies inside the workspace pointing at a global store outside the root; developers symlinking shared prompt/config files from another checkout; moving the repo directory while a symlink still points at the old absolute location.","solutions":["Replace the symlink with a real copy of the file inside the project root.","Re-point the symlink at a target inside the project root.","If the target is a legitimate workspace dependency, widen the project/dev root so the realpath target is contained, or vendor the file."],"exampleFix":"# before\nprompts/system.md -> /home/user/shared-prompts/system.md\n\n# after\ncp /home/user/shared-prompts/system.md prompts/system.md # real file inside root","handlingStrategy":"validation","validationCode":"import { relative, realpath } from \"node:path/promises\";\nimport { isAbsolute } from \"node:path\";\nasync function symlinkEscapesRoot(root: string, source: string): Promise<boolean> {\n  const rel = relative(root, await realpath(source));\n  return rel === \"..\" || rel.startsWith(\"..\") || isAbsolute(rel);\n}\n// preflight: for (const p of paths) if (await symlinkEscapesRoot(root, p)) ...","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Avoid symlinking prompt/config files from outside the repo; copy them instead.","If using pnpm workspaces, make sure the dev root contains the store-resolved realpaths or widen the root.","Add a CI check that realpath()s all FileSource paths stay under the root."],"tags":["dev","filesystem","symlink","path-traversal","staging"],"backgroundTag":null,"analyzedSha":"27d5a3981a347890211bb1bf2439e5c821a63bc9","analyzedAt":"2026-08-15T09:26:11.751Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}