{"record":{"id":"034b9ef83aab4d25","repo":"JuliusBrussee/caveman","slug":"caveman-agent-file-source-escapes-project-root","errorCode":null,"errorMessage":"caveman agent: file source escapes project root","messagePattern":"caveman agent: file source escapes project root","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"packages/agent/src/context-ir.ts","lineNumber":325,"sourceCode":"  lowered.ir.segments.push(appended);\n  return appended;\n}\n\nexport function contextBill(ir: ContextIR): Record<string, number> {\n  const bill: Record<string, number> = {};\n  for (const segment of ir.segments) {\n    bill[segment.kind] = (bill[segment.kind] ?? 0) + segment.tokenCount;\n  }\n  return bill;\n}\n\nasync function sourceBytes(source: string | FileSource, rootDir: string): Promise<Uint8Array> {\n  if (typeof source === \"string\") return new TextEncoder().encode(source);\n  const fullPath = resolve(rootDir, source.path);\n  const relativePath = relative(rootDir, fullPath);\n  if (relativePath === \"..\" || relativePath.startsWith(\"../\") ||\n      relativePath.startsWith(\"..\\\\\") || isAbsolute(relativePath)) {\n    throw new Error(\"caveman agent: file source escapes project root\");\n  }\n  return new Uint8Array(await readFile(fullPath));\n}\n\nfunction encodeCanonical(value: unknown): Uint8Array {\n  return new TextEncoder().encode(stableStringify(value));\n}\n\nexport function stableStringify(value: unknown): string {\n  if (value === null || typeof value !== \"object\") {\n    const encoded = JSON.stringify(value);\n    if (encoded === undefined) throw new Error(\"caveman agent: value is not canonically serializable\");\n    return encoded;\n  }\n  if (Array.isArray(value)) return `[${value.map(stableStringify).join(\",\")}]`;\n  const object = value as Record<string, unknown>;\n  return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(object[key])}`).join(\",\")}}`;\n}","sourceCodeStart":307,"sourceCodeEnd":343,"githubUrl":"https://github.com/JuliusBrussee/caveman/blob/3ee70a102609e550bd2e68004bf5990a9341c851/packages/agent/src/context-ir.ts#L307-L343","documentation":"Thrown by sourceBytes in packages/agent/src/context-ir.ts when a FileSource resolves to a path outside the configured project rootDir. The library refuses to read any file whose relative path from root escapes upward (e.g. '../secrets'), preventing path traversal during context lowering. This is a deliberate security boundary, not a bug.","triggerScenarios":"Calling lowerContext with a FileSource whose path contains '..' segments or an absolute path that resolves outside rootDir; e.g. { path: '../../etc/passwd' } or { path: '/etc/passwd' } while rootDir is the project directory.","commonSituations":"Symlinks resolving outside the root, user-supplied include paths not sanitized, config files referencing shared assets in a parent directory, or joining paths from untrusted plugin input.","solutions":["Rewrite the FileSource path so it stays inside rootDir (copy the file into the project or use a path relative to the root).","Resolve the offending path manually and confirm it is inside rootDir before passing it in.","If the asset legitimately lives outside the root, pass a rootDir that is the common ancestor directory of all sources.","Check for symlinks pointing outside the project and remove or replace them."],"exampleFix":"// before\nawait lowerContext({ sources: [{ path: \"../shared/prompt.md\" }], rootDir: projectRoot });\n// after\nawait lowerContext({ sources: [{ path: \"shared/prompt.md\" }], rootDir: projectRoot }); // file copied into projectRoot/shared/","handlingStrategy":"validation","validationCode":"import { resolve, relative, isAbsolute } from \"node:path\";\nfunction isInsideRoot(rootDir: string, path: string): boolean {\n  const rel = relative(rootDir, resolve(rootDir, path));\n  return !(rel === \"..\" || rel.startsWith(\"../\") || rel.startsWith(\"..\\\\\") || isAbsolute(rel));\n}\nif (!isInsideRoot(rootDir, source.path)) throw new Error(`source path escapes root: ${source.path}`);","typeGuard":null,"tryCatchPattern":"try {\n  await lowerContext({ sources, rootDir });\n} catch (e) {\n  if (e instanceof Error && e.message.includes(\"escapes project root\")) {\n    console.error(`Invalid source path: rewrite it relative to ${rootDir}`, e.message);\n  } else throw e;\n}","preventionTips":["Always express FileSource paths relative to rootDir; never concatenate user input into them.","Run the relative()/startsWith('..') check on every dynamically built path before calling the API.","Watch for symlinks — resolve real paths if sources may link outside the project.","Copy external assets into the project tree instead of referencing parent directories."],"tags":["path-traversal","filesystem","security"],"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"}