JuliusBrussee/caveman · error
caveman agent: dev symlink escapes project root
Error message
caveman agent: dev ${kind} symlink escapes project root What it means
copyProjectFile resolves the file with realpath and rejects it when the canonical (symlink-resolved) path lands outside the project root. This is the symlink-aware second half of the traversal guard: a file may look inside root but point elsewhere on disk. It prevents dev builds from following symlinks out of the project.
Solutions
- Replace the symlink with a real copy inside the project root.
- Ensure the symlink target is inside the project root, or symlink the whole directory tree and let its canonical path stay under root.
- Canonicalize root itself (realpath) before configuring the loader so comparisons are consistent (e.g. /var vs /private/var).
- Use the package manager's workspace linking instead of ad-hoc symlinks to outside directories.
Example fix
// before ln -s ../../shared/helpers ./helpers // after cp -r ../../shared/helpers ./helpers
Defensive patterns
Strategy: validation
Validate before calling
import { realpath } from "node:fs/promises";
import { relative, isAbsolute } from "node:path";
async function canonicalInsideRoot(root: string, source: string): Promise<boolean> {
const canon = await realpath(source);
const rel = relative(root, canon);
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
}
// await canonicalInsideRoot(root, file) before including; also realpath root itself Type guard
function isInsideRoot(root: string, source: string): boolean {
const rel = relative(root, source);
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
} Try / catch
try {
await includeFiles(agent, files);
} catch (err) {
if (err instanceof Error && err.message.includes("symlink escapes project root")) {
console.error("Replace the symlink with an in-root copy:", err.message);
return;
}
throw err;
} Prevention
- Avoid symlinking directories from outside the project into dev-include paths.
- On macOS, be aware /var -> /private/var; canonicalize root before configuring the loader.
- Prefer workspace linking (pnpm workspaces, npm workspaces) over ad-hoc symlinks.
- Run realpath -f on configured paths in CI to catch escaping symlinks early.
When it happens
Trigger: loadDevModule/includeFiles/includeOptionalFiles/includeSourceGraph/includeAgentFiles staging a file that is a symlink (or sits under a symlinked directory) whose real target is outside the project root, e.g. root/../other/file.ts after realpath.
Common situations: Symlinked monorepo workspaces (pnpm/yarn) where package dirs are symlinked into node_modules; a developer symlinking a shared utils folder into the project; macOS temp dir /var -> /private/var symlink chains making canonical paths differ from root's own canonical path.
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
- caveman agent: dev escapes project root
- cave_live_eval_sandbox_profile_escapes_root
- cave_sandbox_source_read_grant_escapes_staging
- cave_tool_sandbox_entry_escapes_root
- cave_tool_sandbox_source_escapes_root
AI-assisted analysis of JuliusBrussee/caveman@3ee70a1026 (2026-09-20).
Data as JSON: /api/errors/7d9faffed951bf0d.
Report an issue: GitHub.
Appendix: 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 3ee70a1026)