JuliusBrussee/caveman · critical · Error

caveman agent: file source escapes project root

Error message

caveman agent: file source escapes project root

What it means

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.

Source

Thrown at packages/agent/src/context-ir.ts:325

  lowered.ir.segments.push(appended);
  return appended;
}

export function contextBill(ir: ContextIR): Record<string, number> {
  const bill: Record<string, number> = {};
  for (const segment of ir.segments) {
    bill[segment.kind] = (bill[segment.kind] ?? 0) + segment.tokenCount;
  }
  return bill;
}

async function sourceBytes(source: string | FileSource, rootDir: string): Promise<Uint8Array> {
  if (typeof source === "string") return new TextEncoder().encode(source);
  const fullPath = resolve(rootDir, source.path);
  const relativePath = relative(rootDir, fullPath);
  if (relativePath === ".." || relativePath.startsWith("../") ||
      relativePath.startsWith("..\\") || isAbsolute(relativePath)) {
    throw new Error("caveman agent: file source escapes project root");
  }
  return new Uint8Array(await readFile(fullPath));
}

function encodeCanonical(value: unknown): Uint8Array {
  return new TextEncoder().encode(stableStringify(value));
}

export function stableStringify(value: unknown): string {
  if (value === null || typeof value !== "object") {
    const encoded = JSON.stringify(value);
    if (encoded === undefined) throw new Error("caveman agent: value is not canonically serializable");
    return encoded;
  }
  if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
  const object = value as Record<string, unknown>;
  return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(object[key])}`).join(",")}}`;
}

View on GitHub (pinned to 27d5a3981a)

Solutions

  1. Use file paths relative to and inside the project root you passed in
  2. Move or copy the needed file into the project tree, or pass a rootDir that legitimately contains it
  3. Remove/avoid symlinks that cross the project boundary
  4. For string content, pass a plain string source instead of FileSource — strings bypass the path check

Example fix

// before
addSegment({ source: { path: "../../shared/config.json" }, rootDir: projectRoot });

// after
addSegment({ source: { path: "shared/config.json" }, rootDir: projectRoot }); // shared/ copied into project
Defensive patterns

Strategy: type-guard

Validate before calling

import { resolve, relative, isAbsolute } from "node:path";
function insideRoot(rootDir: string, p: string): boolean {
  const rel = relative(rootDir, resolve(rootDir, p));
  return rel !== ".." && !rel.startsWith("../") && !rel.startsWith("..\\") && !isAbsolute(rel);
}

Type guard

function isSafeRelativePath(p: string): boolean {
  const norm = p.replace(/\\/g, "/");
  return !norm.startsWith("/") && !/^[A-Za-z]:/.test(norm) && !norm.split("/").includes("..");
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of JuliusBrussee/caveman@27d5a3981a (2026-08-15). Data as JSON: /api/errors/034b9ef83aab4d25. Report an issue: GitHub.