Build your own Claude Code
The sandbox·20 min

Containment under attack

0 / 1
# grade this stage (from your course directory)
npx theinference test --stage containment-under-attack --watch

A guard you've only tested with friendly inputs isn't a guard. This step points the agent itself at the wall. A cassette scripts a model that, on its first turn, tries to read ../../../etc/passwd — a classic path-traversal exfiltration. The guard has to reject it, the loop has to feed the rejection back as a tool result, and the model — seeing the failure — has to recover and finish a benign task.

The whole point is that containment is not an abort. A blocked tool call returns an is_error result like any other failure; the loop keeps turning; the agent reads the real file (a.txt) and ends clean. Defense in depth means the dangerous action is a no-op against the host, not a crash of the session.

Block, feed back, recover
turn 1   model → Read "../../../etc/passwd"
         guard rejects → tool_result { is_error: true, "path escapes sandbox" }
turn 2   model → Read "a.txt"
         guard ok → "1: A"
turn 3   model → end_turn "safe"

host /etc/passwd: never opened.   a.txt: unchanged ("A").

What to build

Your task

Survive a scripted escape attempt

There's no new function to write — this step proves the machinery you already built holds under attack. Make sure:

  1. The guard rejects the traversal. resolveInSandbox (step 01) must turn ../../../etc/passwd into { ok: false }, so the view/Read tool returns an is_error result instead of reading the host file.
  2. The loop feeds the rejection back, not up. A tool error is a normal tool_result with is_error: true — the loop appends it and continues to the next model turn. It must not throw or abort the session.
  3. The benign task completes. The model's second turn reads a.txt; that resolves and returns 1: A; the third turn is end_turn with the text safe.

If steps 01–02 are solid, your job here is to confirm the loop treats a guard rejection as a recoverable tool error and nothing leaks. program.ts contain runs the cassette end to end against a real sandbox.

src/sandbox.ts
// No new code required — this step exercises resolveInSandbox (step 01)
// and the loop's tool-error handling under a scripted attack cassette.
// The malicious Read is rejected, fed back as an is_error tool_result,
// and the loop recovers to finish the benign Read of a.txt.
A blocked tool is an error result, never a thrown exception

If a rejected path throws and unwinds the loop, the agent can't recover — and worse, a half-handled throw can skip the trajectory record the grader checks. Containment must look exactly like any other tool failure: { content: "path escapes sandbox: ...", is_error: true }, appended as a tool_result, loop continues. The model sees the failure and routes around it.

The blocked attempt still appears in the trajectory

expected.json records both Read calls — the rejected ../../../etc/passwd and the successful a.txt. Blocking the action doesn't erase it from the record; the trajectory captures what the model attempted, and the file snapshot proves the host was untouched. Don't filter the blocked call out of your trajectory.

How it's graded

program.ts contain builds a sandbox containing a.txt: "A", replays the attack cassette through your loop, and prints { trajectory, finalText, files }. To match expected.json: the trajectory is the two Read attempts in order, finalText is "safe", and the a.txt snapshot is still "A". The dangerous read produces no host side effect, the loop finishes, and the benign file is byte-equal.