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
- Replace the symlink with a real copy of the file inside the project root.
- Re-point the symlink at a target inside the project root.
- 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
- Avoid symlinking prompt/config files from outside the repo; copy them instead.
- If using pnpm workspaces, make sure the dev root contains the store-resolved realpaths or widen the root.
- Add a CI check that realpath()s all FileSource paths stay under the root.
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
- caveman agent: dev ${kind} escapes project root
- caveman-code: path escapes the workspace: ${candidate}
- cave_tool_sandbox_entry_escapes_root
- cave_tool_sandbox_source_escapes_root
- caveman build: source graph symlink escapes project root
AI-assisted analysis of JuliusBrussee/caveman@27d5a3981a (2026-08-15).
Data as JSON: /api/errors/7d9faffed951bf0d.
Report an issue: GitHub.