Capstone Lesson 26: Sandbox Runner with Denylist and Path Jail
The verification gate decides whether a tool call should run. The sandbox decides what happens when it does. This lesson ships a subprocess runner that refuses dangerous executables and dangerous argv shapes, jails every file path to a project root, truncates oversized output, and kills runaway processes on a wall-clock timeout. Together they are the two layers between the model and the operating system.
Learning Objectives
- Build a `Sandbox` class wrapping `subprocess.run` with timeout, capture, and truncation.
- Refuse a command by name against a denylist and by structure against an argv inspector.
- Refuse any path argument that resolves outside a declared project root.
- Refuse shell metacharacters when shell mode is off.
- Return a structured `SandboxResult` that downstream observability and the eval harness can ingest.
The Problem
A sandbox, in this lesson, is a subprocess runner that checks every command against a set of refusal rules before anything spawns, and says no to a precise list of patterns. A coding agent that can shell out can install backdoors, exfiltrate keys, brick a developer laptop, and rack up a cloud bill in a single turn. The cheapest defense is to withhold the shell entirely. The next cheapest is a sandbox like this one.
Three classes of failure recur in agent traces.
The first is dangerous executables. A model under pressure to fix a path issue will reach for sudo, chmod -R 777, rm -rf, mkfs, dd. None of these belong in an agent run, and the denylist catches them by name and by alias.
The second is argv tricks. Tell a model it has no shell and it will pipe the attack through an interpreter instead: python3 -c "import os; os.system('rm -rf /')", bash -c '...', node -e '...', perl -e '...'. An interpreter run with a -c-like flag is a shell call with extra steps, and the sandbox has to treat it as one.
The third is path escape. The model is told to read ./src/main.py and reads ../../etc/passwd instead. The sandbox jails every path argument by resolving it through os.path.realpath and asserting the prefix.
This is not a security boundary in the operating-system sense. A determined attacker with code execution can still break out. What you get is a development-time guardrail: the common failure modes become loud, and the agent can't do damage out of plain ineptitude.
The Concept
The sandbox refuses along four axes: name, argv, path, and structure. Each axis is a pure function of the call, so no subprocess exists yet when a check runs. The subprocess spawns only after every axis has passed.
SandboxResult uses the conventional exit codes: 0 for success, non-zero for failure. Three cases get special handling. Denied is the sentinel -100, timed_out is -101, and truncated keeps the real exit code and sets a flag. Downstream lessons read this structured result instead of parsing stderr.
Architecture
The denylist is a frozenset of executable basenames, so aliases like /bin/rm and /usr/bin/rm resolve to the same entry. The argv inspector knows the interpreter shape: if argv[0] is an interpreter and any later argument is exactly -c, -e, --eval, --exec, -cu, or -ce, the call is denied. Shell metacharacters (;, |, &, >, <, backticks, $(, ${, and a bare )) trigger refusal whenever the call did not explicitly ask for a shell.
The path jail is the subtle piece. The sandbox takes a project_root at construction. Any argument that looks like a path (it contains / or \, or is exactly . or ..; the detector looks only at the shape of the string and never checks whether a file exists) is normalized through os.path.realpath and checked against the realpath of the project root. A target that resolves outside the root is refused. Checking the realpath instead of the literal string also blocks symlink escapes, where a link inside the project root points somewhere outside it.
What you will build
You will write main.py plus a tests dir.
SandboxResultdataclass: exit_code, stdout, stderr, truncated, timed_out, denied, reason, duration_ms.SandboxConfigdataclass: project_root, max_output_bytes, timeout_seconds, denylist, interpreter_block.Sandboxclass:run(argv, *, shell=False, cwd=None)returns aSandboxResult.- Internal refusal helpers:
_check_executable_denylist,_check_argv_interpreter,_check_shell_metachars,_check_path_jail. - Output truncation with a clear
truncatedflag and a marker line in the captured stream. - A demo at the bottom: a sequence of legitimate and adversarial calls, each printed with its result.
Execution goes through subprocess.run with shell=False by default and capture_output=True. The wall-clock limit is the timeout argument. When the timeout fires, subprocess.run kills the direct child (not any processes it spawned) and raises TimeoutExpired, and the sandbox synthesizes a SandboxResult from it.
Why this is not a real sandbox
Kernel-level isolation means namespaces, cgroups, seccomp, gVisor, or Firecracker, and this lesson uses none of them. Anything the subprocess can do, the sandbox can do. The protection is structural: the agent is denied the most common dangerous invocations, and the refusal goes loudly into observability instead of the command running silently.
For production agents you layer more on top. Run inside an unprivileged Docker container or a microVM, drop capabilities, mount the project root read-only and a scratch dir read-write, set ulimit on memory and CPU, and scrub the environment down to a known-safe whitelist. Lesson 29 does some of this. Operating-system isolation is out of scope here.
Running it
cd phases/19-capstone-projects/26-sandbox-runner-denylist
python3 code/main.py
python3 -m pytest code/tests/ -vThe demo creates a temp directory, drops a clean file into it, and runs a battery of calls. Legal calls succeed. Denied calls return a SandboxResult with denied=True and a reason. Timeouts return timed_out=True, and oversized output sets truncated=True. The demo prints a JSON table of outcomes and exits zero.
How this composes with the rest of Track A
Lesson 25 produced the gate chain, and Lesson 26 is the executor that runs after a gate ALLOW. Lesson 27's eval harness compares the sandbox results against the expected exit code per task. Lesson 28 emits a gen_ai.tool.execution span around each Sandbox.run invocation. Lesson 29's end-to-end demo wires a real coding agent through both layers.
I'd build the path jail first and spend the most testing time on it, because it is the subtle piece: it has to check realpath instead of the literal string. A symlink inside the project root that points outside is the case I'd write a test for before the happy path.
My bet is that the reason field is worth designing carefully. reason is the only thing that tells whoever reads the trace why a call was denied, so a refusal that goes loudly into observability is only as useful as that string.
I'd also keep in mind that this is not a real sandbox. It is a guardrail, so I'd plan for the container or microVM layer described above rather than treating the denylist as the finish line.
Code
- main.py (python, 509 lines)
- tests/test_main.py (python, 196 lines)