{"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":null,"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/3ee70a102609e550bd2e68004bf5990a9341c851/packages/agent/src/dev-loader.ts#L132-L168","documentation":"copyProjectFile resolves the file with realpath and rejects it when the canonical (symlink-resolved) path lands outside the project root. This is the symlink-aware second half of the traversal guard: a file may look inside root but point elsewhere on disk. It prevents dev builds from following symlinks out of the project.","triggerScenarios":"loadDevModule/includeFiles/includeOptionalFiles/includeSourceGraph/includeAgentFiles staging a file that is a symlink (or sits under a symlinked directory) whose real target is outside the project root, e.g. root/../other/file.ts after realpath.","commonSituations":"Symlinked monorepo workspaces (pnpm/yarn) where package dirs are symlinked into node_modules; a developer symlinking a shared utils folder into the project; macOS temp dir /var -> /private/var symlink chains making canonical paths differ from root's own canonical path.","solutions":["Replace the symlink with a real copy inside the project root.","Ensure the symlink target is inside the project root, or symlink the whole directory tree and let its canonical path stay under root.","Canonicalize root itself (realpath) before configuring the loader so comparisons are consistent (e.g. /var vs /private/var).","Use the package manager's workspace linking instead of ad-hoc symlinks to outside directories."],"exampleFix":"// before\nln -s ../../shared/helpers ./helpers\n// after\ncp -r ../../shared/helpers ./helpers","handlingStrategy":"validation","validationCode":"import { realpath } from \"node:fs/promises\";\nimport { relative, isAbsolute } from \"node:path\";\nasync function canonicalInsideRoot(root: string, source: string): Promise<boolean> {\n  const canon = await realpath(source);\n  const rel = relative(root, canon);\n  return rel !== \"\" && !rel.startsWith(\"..\") && !isAbsolute(rel);\n}\n// await canonicalInsideRoot(root, file) before including; also realpath root itself","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 includeFiles(agent, files);\n} catch (err) {\n  if (err instanceof Error && err.message.includes(\"symlink escapes project root\")) {\n    console.error(\"Replace the symlink with an in-root copy:\", err.message);\n    return;\n  }\n  throw err;\n}","preventionTips":["Avoid symlinking directories from outside the project into dev-include paths.","On macOS, be aware /var -> /private/var; canonicalize root before configuring the loader.","Prefer workspace linking (pnpm workspaces, npm workspaces) over ad-hoc symlinks.","Run realpath -f on configured paths in CI to catch escaping symlinks early."],"tags":["security","symlink","path-traversal","dev-loader"],"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"}