{"record":{"id":"438996adc48b8ffc","repo":"JuliusBrussee/caveman","slug":"caveman-code-path-escapes-the-workspace-candid","errorCode":null,"errorMessage":"caveman-code: path escapes the workspace: ${candidate}","messagePattern":"caveman-code: path escapes the workspace: (.+?)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"critical","filePath":"packages/agent/src/code.ts","lineNumber":386,"sourceCode":"      );\n    },\n  });\n\n  return [readFileTool, grepTool, bashTool, editTool];\n}\n\n/**\n * Resolve a caller path against the canonical workspace and refuse anything\n * that lands outside it.\n *\n * A lexical prefix check is not containment: a symlink inside the workspace\n * pointing anywhere on the filesystem passes it. Both sides are canonicalized\n * first, matching how `stageSandboxSourceGraph` decides the same question.\n */\nasync function containedPath(canonicalWorkspace: string, candidate: string): Promise<string> {\n  const full = await canonicalizePath(resolve(canonicalWorkspace, candidate));\n  if (escapesRoot(relative(canonicalWorkspace, full))) {\n    throw new Error(`caveman-code: path escapes the workspace: ${candidate}`);\n  }\n  return full;\n}\n\nfunction escapesRoot(path: string): boolean {\n  return path === \"..\" || path.startsWith(\"../\") || path.startsWith(\"..\\\\\") || isAbsolute(path);\n}\n\n/**\n * `realpath` for a path whose leaf may not exist yet (a file `edit_file` is\n * about to create): canonicalize the deepest existing ancestor and re-attach\n * the missing tail, so every symlink on the existing part is still resolved.\n */\nasync function canonicalizePath(target: string): Promise<string> {\n  const missing: string[] = [];\n  let current = target;\n  for (;;) {\n    try {","sourceCodeStart":368,"sourceCodeEnd":404,"githubUrl":"https://github.com/JuliusBrussee/caveman/blob/27d5a3981a347890211bb1bf2439e5c821a63bc9/packages/agent/src/code.ts#L368-L404","documentation":"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.","triggerScenarios":"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).","commonSituations":"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.","solutions":["Keep all read/write/glob/grep targets inside the workspace; reference files by paths relative to the workspace root","If a symlink inside the workspace points outside, either remove the symlink or copy the target file into the workspace","If the external file is legitimately needed, run the agent with a workspace root that contains it (choose a higher-level root directory)","Never pass absolute paths unless they resolve inside the canonical workspace root"],"exampleFix":"# before: symlink escape\nln -s /etc/passwd workspace/passwd-link\nawait read_file('passwd-link')  // throws\n\n# after: keep content inside the workspace\ncp /etc/passwd workspace/reference-passwd.txt\nawait read_file('reference-passwd.txt')","handlingStrategy":"type-guard","validationCode":"import { resolve, relative, isAbsolute } from \"node:path\";\nimport { realpath } from \"node:fs/promises\";\n\nasync function assertInsideWorkspace(root: string, p: string): Promise<string> {\n  const canonRoot = await realpath(root);\n  const full = await realpath(resolve(canonRoot, p)).catch(() => resolve(canonRoot, p));\n  const rel = relative(canonRoot, full);\n  if (rel === \"..\" || rel.startsWith(\"../\") || rel.startsWith(\"..\\\\\") || isAbsolute(rel)) {\n    throw new Error(`refusing outside-workspace path: ${p}`);\n  }\n  return full;\n}","typeGuard":"function isWorkspaceRelative(candidate: string): boolean {\n  const norm = candidate.replace(/\\\\/g, \"/\");\n  return !norm.startsWith(\"/\") &&\n    !norm.split(\"/\").includes(\"..\") &&\n    /^[^/]/.test(norm) &&\n    !/^[A-Za-z]:/.test(norm);\n}","tryCatchPattern":"try {\n  await readTool.execute({ path }, signal);\n} catch (err) {\n  if (err instanceof Error && err.message.includes(\"escapes the workspace\")) {\n    // security boundary: log and reject the request; never retry with a mutated path\n    auditLog.warn(\"path escape attempt\", { path });\n    throw err;\n  }\n  throw err;\n}","preventionTips":["Resolve and canonicalize (realpath) every caller-supplied path before handing it to tools","Audit the workspace for symlinks pointing outside the tree before starting a session","Treat occurrences of this error as attempted escapes worth logging, not as transient failures"],"tags":["security","path-traversal","symlink","sandbox","containment"],"backgroundTag":null,"analyzedSha":"27d5a3981a347890211bb1bf2439e5c821a63bc9","analyzedAt":"2026-08-15T09:26:11.751Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}