{"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":"Error","httpStatus":null,"severity":"critical","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/27d5a3981a347890211bb1bf2439e5c821a63bc9/packages/agent/src/context-ir.ts#L307-L343","documentation":"When building IR segments from a FileSource, the path is resolved against the project root and rejected if the resolved path escapes it (leading ../, backslash variant, or an absolute result). Like the workspace containment check in code.ts, this prevents segment construction from reading files outside the declared project.","triggerScenarios":"Passing a FileSource with path '../../../etc/hosts', an absolute path like '/etc/hosts', or an in-root symlink whose real target is outside the project directory.","commonSituations":"Config generators pointing at files in parent directories; monorepo setups where the project root was misconfigured to a subpackage; symlinked assets; templates with user-supplied paths.","solutions":["Use file paths relative to and inside the project root you passed in","Move or copy the needed file into the project tree, or pass a rootDir that legitimately contains it","Remove/avoid symlinks that cross the project boundary","For string content, pass a plain string source instead of FileSource — strings bypass the path check"],"exampleFix":"// before\naddSegment({ source: { path: \"../../shared/config.json\" }, rootDir: projectRoot });\n\n// after\naddSegment({ source: { path: \"shared/config.json\" }, rootDir: projectRoot }); // shared/ copied into project","handlingStrategy":"type-guard","validationCode":"import { resolve, relative, isAbsolute } from \"node:path\";\nfunction insideRoot(rootDir: string, p: string): boolean {\n  const rel = relative(rootDir, resolve(rootDir, p));\n  return rel !== \"..\" && !rel.startsWith(\"../\") && !rel.startsWith(\"..\\\\\") && !isAbsolute(rel);\n}","typeGuard":"function isSafeRelativePath(p: string): boolean {\n  const norm = p.replace(/\\\\/g, \"/\");\n  return !norm.startsWith(\"/\") && !/^[A-Za-z]:/.test(norm) && !norm.split(\"/\").includes(\"..\");\n}","tryCatchPattern":null,"preventionTips":["Validate file-source paths against the project root before adding segments","Prefer string sources for content that originates outside the project","Configure rootDir to the true project root, not an arbitrary subdirectory"],"tags":["security","path-traversal","file-source","containment"],"backgroundTag":null,"analyzedSha":"27d5a3981a347890211bb1bf2439e5c821a63bc9","analyzedAt":"2026-08-15T09:26:11.751Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}