JuliusBrussee/caveman · error

caveman agent: dev escapes project root

Error message

caveman agent: dev ${kind} escapes project root

What it means

During dev-module loading, copyProjectFile stages a project file into a temporary staging directory and refuses any file whose path resolves outside the project root. The library throws this to prevent dev tooling from reading or copying files outside the project. It fires before any filesystem copy happens, as a path-traversal guard on the relative path computed from root.

Solutions

  1. Move or symlink the referenced file inside the project root so relative(root, source) stays within root.
  2. Fix the dev-loader config (include globs/file sources) to reference only paths under the project root.
  3. If the file genuinely lives outside root, copy it into the project or use a proper workspace/package link instead of a raw path.
  4. Check that root passed to the loader is the actual intended project root (e.g. package dir, not repo root or vice versa).

Example fix

// before
await includeFiles(agent, ["/tmp/shared/extra.ts"]);
// after
cp("/tmp/shared/extra.ts", "./vendor/extra.ts");
await includeFiles(agent, ["./vendor/extra.ts"]);
Defensive patterns

Strategy: validation

Validate before calling

import { relative, isAbsolute, resolve } from "node:path";
function pathInsideRoot(root: string, source: string): boolean {
  const abs = isAbsolute(source) ? source : resolve(root, source);
  const rel = relative(root, abs);
  return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
}
// call pathInsideRoot(root, file) for each file before loadDevModule/includeFiles

Type guard

function isInsideRoot(root: string, source: string): boolean {
  const rel = relative(root, source);
  return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
}

Try / catch

try {
  await loadDevModule(entry);
} catch (err) {
  if (err instanceof Error && err.message.includes("escapes project root")) {
    // surface the offending path from config and fail fast with a clear message
    throw new Error(`dev-loader config references a path outside the project root: ${err.message}`);
  }
  throw err;
}

Prevention

When it happens

Trigger: Calling loadDevModule, includeFiles, includeOptionalFiles, includeSourceGraph, or includeAgentFiles with a source path that is not inside the loader's project root, e.g. an absolute path in another directory or a '../'-relative path that climbs above root.

Common situations: Dev config pointing include globs at a shared monorepo directory outside the package root; a symlinked node_modules or workspace layout where source files are resolved from a sibling package; passing an absolute path from a temp dir into includeFiles.

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/c505457db6544590. Report an issue: GitHub.

Appendix: source

Thrown at packages/agent/src/dev-loader.ts:146

async function copyOptional(source: string, destination: string): Promise<void> {
  try {
    await readFile(source);
    await mkdir(dirname(destination), { recursive: true });
    await copyFile(source, destination);
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
  }
}

async function copyProjectFile(
  root: string,
  staging: string,
  source: string,
  kind: "source graph" | "file source",
): Promise<void> {
  const name = relative(root, source);
  if (escapesRoot(name)) {
    throw new Error(`caveman agent: dev ${kind} escapes project root`);
  }
  const canonicalSource = await realpath(source);
  if (escapesRoot(relative(root, canonicalSource))) {
    throw new Error(`caveman agent: dev ${kind} symlink escapes project root`);
  }
  const destination = resolve(staging, name);
  await mkdir(dirname(destination), { recursive: true });
  await copyFile(canonicalSource, destination);
}

function remapDevError(error: unknown, staging: string, root: string): unknown {
  if (!(error instanceof Error)) return error;
  const mapped = new Error(error.message, { cause: error.cause });
  mapped.name = error.name;
  mapped.stack = (error.stack ?? `${error.name}: ${error.message}`).replaceAll(staging, root);
  return mapped;
}

View on GitHub (pinned to 3ee70a1026)