Module 21 — Secure Tool Use & Sandboxing¶
Time: 5–7 days · Depends on: 02 Security, 11 Single agents, 20 Reliability · Pairs with: 08 MCP · Next: Harness engineering
Fictional teaching scenario.
An editor agent is told “clean up this module.” It has a god-tool bash. The model proposes git checkout --orphan tmp && git add -A && git commit. No path sandbox. No approval. The user’s uncommitted novel in a sibling folder is gone from the index. Postmortem: the prompt said “be careful.” The process had the user’s full UID, env, and credentials. Policy lived in English. English is not a sandbox.
Case question: Which privilege, approval, isolation, and output check prevents the model proposal from reaching files outside its authority?
Learning objectives¶
- Ship tools as least-privilege manifests, not
run(cmd: str) - Put approval gates on writes; split internal reasons from user messages
- Validate outputs (size, secrets, schema) before they re-enter context
- Execute untrusted work in isolation: process bounds, temp worktrees, or containers
- Keep the sentence true: the model proposes; the runtime disposes
Module 02 taught injection and allowlists. Module 11 executed tools in-process. This module is the security course handshake: capability tokens, human gates, and an execution environment that cannot reach the rest of the laptop.
Intuition lock
Sticky picture: Each tool is a badge with listed doors (read / write / exec / network). Approval is a physical key for write doors. A worktree is a photocopy of the repo in a locked room — the original stays on the shelf until a merge gate says otherwise. Output validation is the x-ray of what comes back.
Kill this idea: “We’ll sandbox later; for now the agent has shell so it can be useful.” → Replace with: Narrow tools, granted privileges, approval on writes, isolated cwd/env/timeout, validate the bytes that re-enter the prompt.
Mental model¶
flowchart LR
M[Model proposal] --> Man[ToolManifest]
Man --> Priv{Privileges granted?}
Priv -->|no| Deny[PrivilegeError]
Priv -->|write + no approval| Gate[ApprovalGate]
Gate -->|denied| HumanMsg[Generic refusal]
Gate -->|approved| Exec[ProcessSandbox / Worktree]
Exec --> Val[validate_output]
Val --> Ctx[Observation in context]
Invariant: the model never receives a shell. It receives names from a catalog. Your code maps names to fixed functions with schemas.
Core tutorial¶
1. Manifests are capability tokens¶
from src.sandbox import Privilege, ToolManifest, ToolRegistry, PrivilegeError
reg = ToolRegistry()
reg.register(
ToolManifest(
name="read_file",
privileges=frozenset({Privilege.READ}),
arg_schema={"path": str},
max_output_chars=4000,
),
lambda path: open(path, encoding="utf-8").read(),
)
reg.register(
ToolManifest(
name="apply_patch",
privileges=frozenset({Privilege.WRITE}),
arg_schema={"diff": str},
requires_approval=True,
),
apply_patch_impl,
)
reg.invoke("read_file", {"path": "README.md"}, granted={Privilege.READ})
# PrivilegeError — write not granted, and approval required:
# reg.invoke("apply_patch", {"diff": "..."}, granted={Privilege.READ})
| Privilege | Typical tools |
|---|---|
READ |
read_file, list_dir, search |
WRITE |
apply_patch, ticket.close |
EXEC |
run_tests (fixed argv, not a string) |
NETWORK |
fetch_url with host allowlist |
If a tool needs more than one privilege, that is a smell: split it.
2. Approval gates (HITL)¶
from src.sandbox import ApprovalGate, propose_then_dispose
gate = ApprovalGate()
out = propose_then_dispose(
reg,
"apply_patch",
{"diff": "--- a/x\n+++ b/x\n"},
granted={Privilege.WRITE},
gate=gate,
approver=lambda req: show_diff_and_ask(req), # UI / CLI
)
Rules:
- Default deny if the human walks away (timeout → denied)
- Internal log:
apply_patch denied: unapproved production path - User-facing: generic, no gadget for attackers (
I can't apply that change yet) - MCP write tools use the same gate (Module 08). No protocol shortcut.
Explainer
Propose / dispose is the whole security model. The LLM can emit {"type":"tool","name":"apply_patch",...} all day. Nothing hits disk until propose_then_dispose checks the manifest, privileges, schema, and human. If you find a code path that calls impl(**args) from the JSON decoder, you have a confused deputy.
sequenceDiagram
participant Model
participant Runtime as Runtime (propose_then_dispose)
participant Human
participant Exec as impl()
rect rgb(200, 230, 200)
Note over Model,Exec: Safe path — the runtime disposes
Model->>Runtime: {"type":"tool","name":"apply_patch",args}
Runtime->>Runtime: check manifest + privileges + schema
Runtime->>Human: show_diff_and_ask(req)
Human-->>Runtime: approve / deny
Runtime->>Exec: impl(**args) (only if approved)
Exec-->>Runtime: result
end
rect rgb(230, 200, 200)
Note over Model,Exec: Confused deputy — skip the runtime
Model->>Exec: impl(**args) (called straight from the JSON decoder)
Note right of Exec: no privilege check, no schema, no human
end
The bottom half is the bug to grep for: any handler that unpacks model JSON straight into a privileged function is a confused deputy, because it lets the model's proposal act with the runtime's authority.
3. Output validation¶
Tool results are untrusted — they can contain secrets, injection, or 2MB of noise.
from src.sandbox import validate_output
safe = validate_output(raw, max_chars=4000)
# raises if 'BEGIN RSA PRIVATE KEY' / 'AWS_SECRET' appear
Production extras: JSON schema on structured tools, URL allowlists on fetch_url, strip ANSI / null bytes, never log raw.
4. Process isolation (the laptop-friendly sandbox)¶
Start by separating process hygiene from security isolation:
from src.sandbox import ProcessSandbox
box = ProcessSandbox(root=repo_subdir, timeout_s=8)
proc = box.run(["pytest", "tests/test_foo.py", "-q"]) # argv list, no shell
ProcessSandbox:
cwdfixed to a root- env scrubbed to
PATH+ explicit extras (no inheritedAWS_*by default) timeoutshell=False
Still not a security boundary against a malicious binary with the same UID. It is a boundary against “the model concatenated a string into bash.” The gap: any process running as your UID can read your files, use your credentials, and ptrace your other processes — a fixed cwd and scrubbed env do not stop that, they only stop accidental reach. Closing it needs a UID the untrusted work does not share with you: a dedicated non-root runner user, or a container (drop capabilities, read-only root, no network) whose namespace the host process cannot cross.
Can the process open the file next door?¶
cwd tells a process where it starts looking. It does not tell the operating system which files the process may open. Predict the result, then test it with fictional files. Do not use real credentials.
from pathlib import Path
import sys
from src.sandbox import ProcessSandbox
root = Path("/tmp/isolation-demo/work")
outside = Path("/tmp/isolation-demo/host-only.txt")
root.mkdir(parents=True, exist_ok=True)
outside.write_text("fictional canary")
result = ProcessSandbox(root).run(
[sys.executable, "-c", "import sys; print(open(sys.argv[1]).read())", str(outside)]
)
assert result.stdout.strip() == "fictional canary" # cwd did not confine the read
The read succeeds under the same user permissions. A clean environment, fixed argv, and timeout still help, but they answer different questions. None creates a filesystem allowlist. WorktreeExecutor.write_file separately confines its own Python write method; arbitrary code inside the copied tree does not inherit that restriction. A passing in-directory read is only a positive control; it cannot prove forbidden access fails.
5. Worktrees: copy, mutate, merge-gate¶
from src.sandbox import WorktreeExecutor
with WorktreeExecutor(source=Path(".")) as wt:
wt.write_file("src/foo.py", new_src)
# run tests inside wt.path
snap = wt.snapshot_files()
# original tree untouched
Coding agents should never edit the user’s working copy as their first write. Isolated copy → tests → merge gate → human approve → apply. This is the same shape as CI: PR branch, not main.
Think about it
Question: Product wants one tool bash(command: str) “like a senior engineer.” You have a container. Is that enough?
Reveal a strong answer
A container with a network and a mounted Docker socket is a **root-equivalent** confused deputy. Even a tight container still lets the model probe until it finds `curl | sh`. Split into `read_file` / `list_dir` / `run_tests` / `git_diff` with argv arrays, path prefixes, and approval on writes. Isolation **reduces blast radius**; it does not replace least privilege. Keep network off unless a named `fetch_url` tool needs it.6. A boundary the operating system can enforce¶
A container is the next probe, and it needs a running Docker daemon. Allow 30–45 minutes.
The runner deliberately constructs the container command as an argument list. These are the lines that turn the intended boundary into runtime policy:
command = [
"docker", "run", "--rm",
"--network=none",
"--read-only",
"--user=65534:65534",
"--cap-drop=ALL",
"--security-opt=no-new-privileges",
"--pids-limit=32",
"--memory=128m",
"--cpus=0.5",
"--tmpfs=/tmp:rw,noexec,nosuid,size=16m,mode=1777",
"--mount", f"type=bind,src={mounted},dst=/work,readonly",
image,
"python", "/work/probe.py",
]
The full runner fixes the image, mount source, environment, and program itself. Do not accept those values from model output and call the result an isolation boundary.
The runner creates a host-only canary file and mounts only a separate fixture directory. It executes a fixed probe with:
- a non-root UID, all capabilities dropped, and no privilege escalation;
- a read-only root and fixture mount, with a small writable scratch directory;
- no container network, no Docker socket, and no host credentials passed through;
- process, memory, CPU, and wall-clock bounds.
The probe needs both positive and negative controls:
| Attempt | Required observation |
|---|---|
| Read the mounted fixture | Succeeds — the runner actually works |
| Write scratch data | Succeeds — intended work remains possible |
| Read the host-only canary | Fails |
| Change the mounted fixture or root filesystem | Fails |
| Connect to a public network address | Fails |
| Read the host's canary environment variable | Absent |
All checks must pass. A nonzero exit or missing Docker daemon is a failed or unavailable experiment, never evidence that isolation worked. The network connection failure alone is weak evidence if the host is offline; interpret it together with Docker's explicit --network=none configuration. Save the engine and platform version and the image digest when recording results; --image python@sha256:... pins the image.
A container is a concrete boundary, not a proof against every kernel or runtime escape. Changing mounts, enabling network, or exposing a privileged socket changes what you have tested. Re-run the probes after such a change.
The corrected sentence is: “This process may read these mounted files and write this scratch area; these probes verify that configuration.”
Artifact: the JSON probe output, Docker and image versions, and the sibling-file read that explains why cwd was insufficient. In CI, the Linux isolation job runs this same probe. Locally, pytest tests/test_sandbox.py -q also demonstrates the weaker process wrapper's actual boundary.
The Docker run reference documents the runtime flags used by the probe. Inspect the actual mounts and privileges whenever you change them.
Mapping to the security course¶
| Security idea | Agent encoding |
|---|---|
| Confused deputy | Model-proposed args into a privileged runtime |
| Least privilege | ToolManifest.privileges ∩ granted set |
| Dual control | requires_approval + ApprovalGate |
| Untrusted input | Tool observations wrapped as data (Module 02 / 08) |
| Blast radius | Worktree / container / non-root UID |
Failure modes¶
| Symptom | Cause | Fix |
|---|---|---|
| Agent “needed bash” | God-tool | Split capabilities |
| Writes apply in the UI tree | No worktree | Copy-on-write + merge gate |
| Secrets in scratchpad | Unvalidated tool dump | validate_output + redact |
| Approval fatigue | Every read gated | Gate writes and high-risk MCP only |
Escape via ../ |
Path not resolved vs root | relative_to after resolve() |
Case checkpoint
Opening failure: A model proposal reached a broad shell running with the user's full filesystem authority.
What this lab demonstrates: Privilege and approval tests, secret/size output validation, a temp-directory process, and copy-only mutation prove the runtime disposes of proposals within limits.
What it does not prove: A sandbox reduces reach; it does not make an approved patch correct or eliminate platform-specific escape risks.
Lab¶
- Register
echo(read) andapply_patch(write, approval). Unit-test deny without grant and deny without human. - Feed
validate_outputa fake key; assert raise. Feed a 10k string; assert truncate. ProcessSandboxon a temp dir: runsys.executable -cthat reads a file in the dir; confirm.WorktreeExecutor: mutate a copy; assert the source file is unchanged.- Stretch: wrap
run_testsasEXECwith a fixed argv, timeout 30s. - Where Docker is available, run
python -m examples.isolation.runand keep the probe output. A missing daemon is an unavailable experiment, not a pass.
Quizzes¶
Quiz · +25 XP
Who is allowed to execute a write tool?
Quiz · +25 XP
Why should a coding agent write into a worktree first?
Open source materials¶
| Resource | Use it for |
|---|---|
src/sandbox.py + tests/test_sandbox.py |
Manifests, gates, process + worktree |
| Module 02 | Injection / confused deputy |
| MCP spec | Host policy on third-party tools |
| Containers: drop capabilities, read-only root | Stronger isolation when you outgrow subprocess |
Checkpoint¶
- No god-tool
run(cmd)in your design - Writes require approval; reads do not spam the human
- Tool output is size-capped and scanned
- At least one isolated executor (process or worktree) has a test
- The worktree write API rejects escaping paths; subprocess
cwdalone is not confinement - Container extension: forbidden filesystem access and network egress fail the isolation probes
Mark complete when a write cannot land without a manifest, a grant, and (if required) a human.
Exercise¶
- Catalog: EX-21 — Sandbox
- Prove: Writes deny without grant+human; a worktree edit leaves the source file untouched.
- Test:
pytest tests/test_sandbox.py -v
Return to the case: Least-privilege manifests, approval gates, isolated execution, and output validation keep English policy from being the only defense. Sandboxing limits reach; it does not make proposed changes desirable.
Next: Harness engineering