The path guard
# grade this stage (from your course directory)
npx theinference test --stage the-guard --watchPermissions 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.
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
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:
- Canonicalize the root —
realpathSync(taskDir)so symlinks in the root itself are already resolved. - Resolve the path — absolute paths as-is (
resolve(p)), relative paths against the root (resolve(root, p)).resolvecollapses..and.for you. - Resolve symlinks on the part that exists — walk up to the deepest existing ancestor,
realpathSyncthat (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'trealpaththe whole path. - 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.
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)
}
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.
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.
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.