Build your own Claude Code
The sandbox·18 min

Confining bash

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

bash is the tool that doesn't take a path. It takes a command — and a command can write anywhere the process can reach: echo pwned > ../../etc/hosts, cat /tmp/secret, cd / && rm -rf. The path guard you just built protects view/create/edit because they hand you a path. Bash hands you an arbitrary shell string. So confinement has to come from somewhere else.

It comes from two pins working together. First, cwd is pinned to the sandbox root, so every relative path inside the command is already resolved against $TASK_DIR. Second, the same path guard still vets every file path the agent gives any tool — so the escape vectors a confined bash can still reach (an absolute path, a ../ climb) are rejected the moment they're checked.

cwd pinned + the guard = bash can't leave the tree
spawnSync("bash", ["-c", command], { cwd: taskDir, timeout, maxBuffer })
                                          └── every relative path resolves here

app.txt           →  $TASK_DIR/app.txt          inside  ✓
../../etc/hosts   →  climbs out of $TASK_DIR     OUTSIDE ✗   guard rejects
/tmp/x            →  absolute, outside the root  OUTSIDE ✗   guard rejects

What to build

Your task

Confine bash to the sandbox

Two pieces, both already scaffolded — make them hold:

  1. Pin bash's cwd. In src/tools.ts, the bash(command) tool must spawn with cwd set to the task directory (spawnSync("bash", ["-c", command], { cwd: this.taskDir, ... })). A pinned cwd means a bare echo x > out.txt lands inside the tree, never at the host's working directory.
  2. Confirm the guard rejects the escapes bash would attempt. The grader doesn't run a shell write — it runs the same path strings a confined bash could form through resolveInSandbox (the function from step 01). In-tree paths resolve; an out-of-tree ../../etc/hosts and an absolute /tmp/x are rejected. If your step-01 guard is correct, this is already green; if you stubbed it, finish it here.

The point of the step: bash gains its freedom and its leash from the same two mechanisms every other tool uses.

src/tools.ts
bash(command: string, timeoutMs = 30000): ToolOutput & { exit_code: number } {
  const r = spawnSync("bash", ["-c", command], {
    cwd: this.taskDir, // pin cwd to the sandbox root
    timeout: timeoutMs,
    encoding: "utf8",
    maxBuffer: 1024 * 1024,
  })
  // ...capture stdout+stderr, map status -> exit_code, handle ETIMEDOUT
}
cwd is necessary, not sufficient

Pinning cwd stops a relative write from escaping, but a confined process can still type /etc/hosts or ../../etc/hosts literally. That's why the path guard stays in the loop: any file path the agent surfaces to a tool is canonicalized and bounds-checked. cwd handles the implicit case; the guard handles the explicit one. You need both — neither alone confines bash.

How it's graded

program.ts guard runs the same shape as step 01: each path in case.json → resolveInSandbox → { path, ok }. Here the cases are the writes a confined bash would attempt — app.txt resolves (ok: true), ../../etc/hosts and /tmp/x are rejected (ok: false). Match expected.json and you've proven an out-of-tree write is blocked before any host file is touched.