JuliusBrussee/caveman · critical · Error

caveman-code: path escapes the workspace: ${candidate}

Error message

caveman-code: path escapes the workspace: ${candidate}

What it means

Every tool path is resolved against the canonical (realpath'd) workspace and rejected if it lands outside it. This is a directory-traversal/symlink-escape guard: a lexical prefix check would pass a workspace symlink pointing at /etc, so both sides are canonicalized before the relative-path containment test. It mirrors the same check used when staging the sandbox source graph.

Source

Thrown at packages/agent/src/code.ts:386

      );
    },
  });

  return [readFileTool, grepTool, bashTool, editTool];
}

/**
 * Resolve a caller path against the canonical workspace and refuse anything
 * that lands outside it.
 *
 * A lexical prefix check is not containment: a symlink inside the workspace
 * pointing anywhere on the filesystem passes it. Both sides are canonicalized
 * first, matching how `stageSandboxSourceGraph` decides the same question.
 */
async function containedPath(canonicalWorkspace: string, candidate: string): Promise<string> {
  const full = await canonicalizePath(resolve(canonicalWorkspace, candidate));
  if (escapesRoot(relative(canonicalWorkspace, full))) {
    throw new Error(`caveman-code: path escapes the workspace: ${candidate}`);
  }
  return full;
}

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

/**
 * `realpath` for a path whose leaf may not exist yet (a file `edit_file` is
 * about to create): canonicalize the deepest existing ancestor and re-attach
 * the missing tail, so every symlink on the existing part is still resolved.
 */
async function canonicalizePath(target: string): Promise<string> {
  const missing: string[] = [];
  let current = target;
  for (;;) {
    try {

View on GitHub (pinned to 27d5a3981a)

Solutions

  1. Keep all read/write/glob/grep targets inside the workspace; reference files by paths relative to the workspace root
  2. If a symlink inside the workspace points outside, either remove the symlink or copy the target file into the workspace
  3. If the external file is legitimately needed, run the agent with a workspace root that contains it (choose a higher-level root directory)
  4. Never pass absolute paths unless they resolve inside the canonical workspace root

Example fix

# before: symlink escape
ln -s /etc/passwd workspace/passwd-link
await read_file('passwd-link')  // throws

# after: keep content inside the workspace
cp /etc/passwd workspace/reference-passwd.txt
await read_file('reference-passwd.txt')
Defensive patterns

Strategy: type-guard

Validate before calling

import { resolve, relative, isAbsolute } from "node:path";
import { realpath } from "node:fs/promises";

async function assertInsideWorkspace(root: string, p: string): Promise<string> {
  const canonRoot = await realpath(root);
  const full = await realpath(resolve(canonRoot, p)).catch(() => resolve(canonRoot, p));
  const rel = relative(canonRoot, full);
  if (rel === ".." || rel.startsWith("../") || rel.startsWith("..\\") || isAbsolute(rel)) {
    throw new Error(`refusing outside-workspace path: ${p}`);
  }
  return full;
}

Type guard

function isWorkspaceRelative(candidate: string): boolean {
  const norm = candidate.replace(/\\/g, "/");
  return !norm.startsWith("/") &&
    !norm.split("/").includes("..") &&
    /^[^/]/.test(norm) &&
    !/^[A-Za-z]:/.test(norm);
}

Try / catch

try {
  await readTool.execute({ path }, signal);
} catch (err) {
  if (err instanceof Error && err.message.includes("escapes the workspace")) {
    // security boundary: log and reject the request; never retry with a mutated path
    auditLog.warn("path escape attempt", { path });
    throw err;
  }
  throw err;
}

Prevention

When it happens

Trigger: Passing '../secrets.env' or an absolute path outside the workspace; passing a path that traverses a symlink INSIDE the workspace whose target lives elsewhere on disk; a workspace whose canonical root differs from the apparent one (macOS /tmp -> /private/tmp).

Common situations: Agents attempting to read ~/.ssh or /etc/hosts via relative traversal; repos containing node_modules symlinks or other linked directories pointing outside the tree; monorepo tooling that assumes a parent directory is accessible.

Related errors


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