JuliusBrussee/caveman · error · Error

caveman agent: dev ${kind} symlink escapes project root

Error message

caveman agent: dev ${kind} symlink escapes project root

What it means

Thrown by copyProjectFile in the dev loader (packages/agent/src/dev-loader.ts:150): the lexical path is inside the root, but realpath() of the file resolves through a symlink to a location outside the project root. Dev staging is realpath-based, so symlinks that escape the root are rejected even when the link itself sits inside the project.

Source

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

    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;
}

function escapesRoot(path: string): boolean {
  return path === ".." || path.startsWith("../") || path.startsWith("..\\") || isAbsolute(path);
}

View on GitHub (pinned to 27d5a3981a)

Solutions

  1. Replace the symlink with a real copy of the file inside the project root.
  2. Re-point the symlink at a target inside the project root.
  3. If the target is a legitimate workspace dependency, widen the project/dev root so the realpath target is contained, or vendor the file.

Example fix

# before
prompts/system.md -> /home/user/shared-prompts/system.md

# after
cp /home/user/shared-prompts/system.md prompts/system.md # real file inside root
Defensive patterns

Strategy: validation

Validate before calling

import { relative, realpath } from "node:path/promises";
import { isAbsolute } from "node:path";
async function symlinkEscapesRoot(root: string, source: string): Promise<boolean> {
  const rel = relative(root, await realpath(source));
  return rel === ".." || rel.startsWith("..") || isAbsolute(rel);
}
// preflight: for (const p of paths) if (await symlinkEscapesRoot(root, p)) ...

Prevention

When it happens

Trigger: A FileSource or source-graph file inside the project root that is a symlink whose target is outside the root (e.g., node_modules-style symlinking to a pnpm store, or a manually linked prompt file pointing to /home or another repo).

Common situations: pnpm-style symlinked dependencies inside the workspace pointing at a global store outside the root; developers symlinking shared prompt/config files from another checkout; moving the repo directory while a symlink still points at the old absolute location.

Related errors


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