Confining bash
# grade this stage (from your course directory)
npx theinference test --stage bash-confinement --watchbash 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.
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
Confine bash to the sandbox
Two pieces, both already scaffolded — make them hold:
- Pin
bash's cwd. Insrc/tools.ts, thebash(command)tool must spawn withcwdset to the task directory (spawnSync("bash", ["-c", command], { cwd: this.taskDir, ... })). A pinned cwd means a bareecho x > out.txtlands inside the tree, never at the host's working directory. - 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/hostsand an absolute/tmp/xare 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.
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
}
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.
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.