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

  1. Replace the symlink with a real copy inside the project root.
  2. Ensure the symlink target is inside the project root, or symlink the whole directory tree and let its canonical path stay under root.
  3. Canonicalize root itself (realpath) before configuring the loader so comparisons are consistent (e.g. /var vs /private/var).
  4. 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

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


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)