JuliusBrussee/caveman · error

caveman agent: file source escapes project root

Error message

caveman agent: file source escapes project root

What it means

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.

Solutions

  1. Rewrite the FileSource path so it stays inside rootDir (copy the file into the project or use a path relative to the root).
  2. Resolve the offending path manually and confirm it is inside rootDir before passing it in.
  3. If the asset legitimately lives outside the root, pass a rootDir that is the common ancestor directory of all sources.
  4. Check for symlinks pointing outside the project and remove or replace them.

Example fix

// before
await lowerContext({ sources: [{ path: "../shared/prompt.md" }], rootDir: projectRoot });
// after
await lowerContext({ sources: [{ path: "shared/prompt.md" }], rootDir: projectRoot }); // file copied into projectRoot/shared/
Defensive patterns

Strategy: validation

Validate before calling

import { resolve, relative, isAbsolute } from "node:path";
function isInsideRoot(rootDir: string, path: string): boolean {
  const rel = relative(rootDir, resolve(rootDir, path));
  return !(rel === ".." || rel.startsWith("../") || rel.startsWith("..\\") || isAbsolute(rel));
}
if (!isInsideRoot(rootDir, source.path)) throw new Error(`source path escapes root: ${source.path}`);

Try / catch

try {
  await lowerContext({ sources, rootDir });
} catch (e) {
  if (e instanceof Error && e.message.includes("escapes project root")) {
    console.error(`Invalid source path: rewrite it relative to ${rootDir}`, e.message);
  } else throw e;
}

Prevention

When it happens

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

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

Understand the failure class

Background: Path traversal blocked: "path escapes the workspace" and "outside site root" errors when a path will not stay inside its allowed directory — this error's family across 26 libraries.

Related errors


AI-assisted analysis of JuliusBrussee/caveman@3ee70a1026 (2026-09-20). Data as JSON: /api/errors/034b9ef83aab4d25. Report an issue: GitHub.

Appendix: 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 3ee70a1026)