JuliusBrussee/caveman · error

cave_tool_sandbox_entry_escapes_root

Error message

cave_tool_sandbox_entry_escapes_root

What it means

Thrown by stageSandboxSourceGraph when the canonicalized (realpath) entry file does not live under the canonicalized sandbox root. Both paths go through realpath first, so symlink indirection cannot hide the escape.

Source

Thrown at packages/agent/src/runtime.ts:2270

    if (activeExecution) await activeExecution.catch(() => undefined);
    if (activeAbort) options.signal?.removeEventListener("abort", activeAbort);
    releaseConversation();
    // Idempotent backstop for the abort/return() paths that never reached a
    // terminal yield; the success and error paths already released above.
    await releaseRunResources();
  }
}

async function stageSandboxSourceGraph(
  root: string,
  entryPath: string,
  frameworkDistRoot: string,
): Promise<SandboxSourceSnapshot> {
  const canonicalRoot = await realpath(root);
  const canonicalEntry = await realpath(entryPath);
  const entryName = relative(canonicalRoot, canonicalEntry);
  if (escapesRoot(entryName)) {
    throw new Error("cave_tool_sandbox_entry_escapes_root");
  }
  const canonicalFrameworkDist = await realpath(frameworkDistRoot);
  const graph = [...await expandSourceGraph(
    canonicalRoot,
    [canonicalEntry],
    false,
    [canonicalFrameworkDist],
  )].sort();
  const staging = await realpath(await mkdtemp(resolve(tmpdir(), "caveman-agent-source-")));
  try {
    const stagedFiles: string[] = [];
    await copyOptionalSandboxFile(
      resolve(canonicalRoot, "package.json"),
      resolve(staging, "package.json"),
      stagedFiles,
    );
    for (const source of graph) {
      if (!escapesRoot(relative(canonicalFrameworkDist, source))) {

View on GitHub (pinned to 27d5a3981a)

Solutions

  1. Pass an entryPath that genuinely lives inside root after symlink resolution — resolve both with realpath yourself before calling
  2. Repoint the symlink at a file inside the sandbox root, or copy the entry in
  3. If the entry legitimately lives elsewhere, stage the graph from a root that actually contains it

Example fix

// before
stageSandboxSourceGraph(root, resolve(root, "linked-entry.ts"), dist); // symlink out

// after
const realRoot = await realpath(root);
const entry = await realpath(resolve(root, "src/main.ts"));
stageSandboxSourceGraph(realRoot, entry, dist);
Defensive patterns

Strategy: validation

Validate before calling

import { realpath, relative } from "node:fs/promises";
async function entryWithinRoot(root: string, entry: string): Promise<boolean> {
  const r = await realpath(root);
  const rel = relative(r, await realpath(entry));
  return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
}

Type guard

function isEntryEscapesRoot(e: unknown): e is Error {
  return e instanceof Error && e.message === "cave_tool_sandbox_entry_escapes_root";
}

Try / catch

try {
  await stageSandboxSourceGraph(root, entry, dist);
} catch (e) {
  if (isEntryEscapesRoot(e)) { /* resolve real paths and pick an in-root entry */ }
  else throw e;
}

Prevention

When it happens

Trigger: entryPath, after resolving symlinks, resolves outside root — e.g. entry is a symlink pointing elsewhere, or root itself contains a symlinked parent making the entry's real location external.

Common situations: Passing a symlinked entry (e.g. from a node_modules link or /tmp symlink); mixing realpathed and non-realpathed path inputs; monorepo where the entry is linked from another package.

Related errors


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