TAI BUI
Back to curriculum
CapstonePython (stdlib)

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.

TIME: ~90 minutesPREREQUISITES: Phase 19 · 25 (verification gates and observation budget), Phase 14 · 33 (instructions as constraints), Phase 14 · 38 (verification gates)

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.

Warm-up0/4
1. An agent's prompt says "never delete files". Why is a code-level check before each tool call stronger than that instruction?Warm-up
2. A finished program reports exit code 0. What does that conventionally mean?Warm-up
3. What is a symbolic link on a Unix-style filesystem?Warm-up
4. Where does a verification gate belong relative to the action it guards?Warm-up
Check: The Problem0/3
1. In this lesson, what is a sandbox?Define
2. A model told it has no shell calls python3 -c with a payload. Why does the sandbox check argv shape instead of only the executable name?Explain
3. A teammate wants to delete the denylist, arguing that the metacharacter check and the path jail already cover the dangerous cases. What does ["sudo", "apt", "update"] show?Analyze

The Concept

Mermaid diagram

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.

Check: The Concept0/6
1. What is a SandboxResult?Define
2. What is the reason field of a SandboxResult for?Define
3. Why is it useful that each refusal check is a pure function of the call, run before any subprocess exists?Explain
4. Why does SandboxResult give denied and timed_out their own sentinel exit codes, -100 and -101?Explain
5. A SandboxResult comes back with denied=True, timed_out=False, exit_code=-100. What is the canonical interpretation?Apply
6. A dashboard shows hundreds of results with denied=True and exit code -100, all with an empty reason. What can the team not do?Analyze

Architecture

Mermaid diagram

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.

Check: Architecture0/13
1. What does the sandbox's denylist contain?Define
2. What does the argv inspector look for?Define
3. What is the path jail?Define
4. How does the path jail decide that an argument looks like a path?Define
5. Which set of characters triggers a metacharacter refusal when shell mode is off?Define
6. The sandbox is configured with project_root=/work. A path argument arrives as ./src/main.py. How is the jail check performed?Explain
7. The denylist blocks executables by basename. If a model invokes /usr/bin/rm instead of rm, what happens?Explain
8. Why does the sandbox default to shell=False and refuse shell metacharacters?Explain
9. An agent calls ["node", "--eval", "require('child_process')..."] with shell off. What comes back?Apply
10. An agent calls ["echo", "${IFS}"] with shell off. What happens?Apply
11. A tester puts a symlink inside the project root that points at /etc. A jail that compares the literal string prefix lets cat link/passwd through. Which change closes the hole?Analyze
12. An agent must be able to run the project's test script but not inline code. Which policy fits the sandbox's checks?Design
13. You are starting a sandbox like this one and can thoroughly test only one part before the rest. Which does the lesson's author choose, and why?Design

What you will build

You will write main.py plus a tests dir.

  1. SandboxResult dataclass: exit_code, stdout, stderr, truncated, timed_out, denied, reason, duration_ms.
  2. SandboxConfig dataclass: project_root, max_output_bytes, timeout_seconds, denylist, interpreter_block.
  3. Sandbox class: run(argv, *, shell=False, cwd=None) returns a SandboxResult.
  4. Internal refusal helpers: _check_executable_denylist, _check_argv_interpreter, _check_shell_metachars, _check_path_jail.
  5. Output truncation with a clear truncated flag and a marker line in the captured stream.
  6. 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.

Check: What you will build0/6
1. What does truncated=True on a SandboxResult mean?Define
2. Which set of fields belongs to SandboxConfig rather than SandboxResult?Define
3. When the timeout fires, subprocess.run raises TimeoutExpired. What does the sandbox do with it?Explain
4. The call ["python3", "script.py"] reaches the argv inspector. What does it return?Apply
5. A caller passes ["echo", "a;b"] with shell=True. What does the metacharacter check do?Apply
6. A call runs a script that forks a helper, then the parent hangs past timeout_seconds. What does the sandbox guarantee?Analyze

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.

Check: Why this is not a real sandbox0/4
1. What is kernel-level isolation, which this lesson does not use?Define
2. Why does the lesson call the sandbox a development-time guardrail and not a security boundary?Explain
3. An agent runs ["python3", "helper.py"] and the helper script itself runs rm -rf on the project. The sandbox allowed the call. What is the best diagnosis?Analyze
4. You must give an agent read access to the repo and a place for build output, while limiting memory and CPU. Which design fits the lesson?Design

Running it

bash
cd phases/19-capstone-projects/26-sandbox-runner-denylist
python3 code/main.py
python3 -m pytest code/tests/ -v

The 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.

Check: Running it0/2
1. With the demo's project root, a call runs ["rm", "-rf", "."]. Which check refuses it, and what does the reason say?Apply
2. max_output_bytes is 512 and a command prints 4,096 bytes and exits with code 0. What does the result hold?Apply

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.

Check: How this composes with the rest of Track A0/1
1. How does the sandbox relate to the gate chain from Lesson 25?Explain
Author's Note

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