Build your own Claude Code
The sandbox·25 min

The path guard

0 / 1
# grade this stage (from your course directory)
npx theinference test --stage the-guard --watch

Permissions decide whether a tool runs. The sandbox decides where it can reach. Even a tool you've allowed — a view, a create, a bash — must stay inside the task directory: the agent works in $TASK_DIR and nothing it does should touch a byte outside that tree.

The whole sandbox reduces to one function. Every file path the agent hands you — relative, absolute, dotted with ../, or pointed at a symlink — goes through resolveInSandbox first. It either returns a canonical path inside the root, or it rejects. Get this one guard right and every tool above it inherits containment for free.

Four ways a path tries to leave the tree
taskDir = /tmp/cc-task-abc

ok.txt              →  /tmp/cc-task-abc/ok.txt          inside  ✓
sub/../ok.txt       →  /tmp/cc-task-abc/ok.txt          inside  ✓  (normalizes back in)
../escape           →  /tmp/escape                      OUTSIDE ✗
/etc/passwd         →  /etc/passwd                      OUTSIDE ✗
link → /etc         →  /etc/...                         OUTSIDE ✗  (symlink target escapes)

What to build

Your task

Implement resolveInSandbox

Implement resolveInSandbox(taskDir, p) in src/sandbox.ts. Canonicalize p against the sandbox root and return { ok: true, resolved } for any path that lands inside the tree, or { ok: false, error } for any escape.

The steps that survive every attack:

  1. Canonicalize the root — realpathSync(taskDir) so symlinks in the root itself are already resolved.
  2. Resolve the path — absolute paths as-is (resolve(p)), relative paths against the root (resolve(root, p)). resolve collapses .. and . for you.
  3. Resolve symlinks on the part that exists — walk up to the deepest existing ancestor, realpathSync that (so a symlink can't point the tail outside), then re-append the not-yet-created tail. A target file may not exist yet (create), so you can't realpath the whole path.
  4. Reject escapes — compute relative(root, real); if it's "" it is the root (ok); if it starts with .. or is absolute, it escaped — reject.

The other file tools already call this; you're filling in the one function they route through.

src/sandbox.ts
export interface GuardResult {
  ok: boolean
  resolved?: string
  error?: string
}

export function resolveInSandbox(taskDir: string, p: string): GuardResult {
  // canonicalize p against the sandbox root; reject any escape
  // (../, absolute paths outside the root, symlink targets outside)
}
Don't `realpath` the whole path — it may not exist yet

A create targets a file that doesn't exist, so realpathSync(fullPath) throws ENOENT. Resolve symlinks only on the existing prefix: walk up with dirname until existsSync is true, realpathSync that ancestor, then join the remaining tail back on. Realpath the whole thing and you'll fail every brand-new file the agent writes.

A string prefix check is not enough

Tempting shortcut: real.startsWith(root). It passes ../escape if root is /tmp/cc-task and real is /tmp/cc-task-evil — the sibling directory shares the prefix. Use relative(root, real) and inspect the result: a leading .. or an absolute result means outside. That's why the reference computes rel instead of comparing strings.

How it's graded

program.ts guard builds a sandbox from case.json's files, then maps each path in paths through resolveInSandbox to { path, ok }. Your JSON must match expected.json exactly: ok.txt, sub/deep.txt, and sub/../ok.txt resolve (ok: true); ../escape and /etc/passwd are rejected (ok: false). The error strings aren't compared — only the boolean — but every escape must be a hard false.