Anatomy of an Agent Sandbox Escape: What Actually Stops rm -rf /
"The agent hallucinated and ran rm -rf /" is the scenario every security review of an agent sandbox eventually asks about. The honest answer isn't one mechanism — it's four independent ones on the sandbox container, and the interesting part is what happens if you imagine each one failing in turn. The less comfortable part is where the real boundary lives, which isn't where most reviews look first.
Start with the command that should never leave the pod
Walk through it concretely. An agent, given bash_tool access, decides — through a prompt-injection, a hallucination, or a genuinely reasonable-looking plan that goes wrong — to run rm -rf /. What actually happens next is a sequence of independent boundaries, each of which would contain the damage even if the ones before it didn't exist. None of them is a policy engine inspecting the string rm -rf / and deciding to say no — that kind of command-name allowlist exists in boxxkite (command_whitelist.py), but SECURITY.mdis explicit that it's a guardrail against unexpected commands, not a sandbox-escape boundary: once you allow a general-purpose interpreter through it, arbitrary code runs anyway. The boundaries that matter are structural, and they don't care what the command is called.
Four layers, checked in order
These aren't abstractions bolted on after the fact — they are the sandbox container's securityContext in deploy/pod-template.yaml, and they are also enforced by a parity test (test_pod_template_parity.py) so the reference template can't silently drift from what the manager actually applies to live pods. Here is the block, more or less verbatim:
# sandbox container — the one that runs agent code
securityContext:
runAsUser: 1001
runAsGroup: 1001
runAsNonRoot: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
seccompProfile:
type: RuntimeDefaultLayer one: there's no root to escalate to
The sandbox process runs as runAsUser: 1001 with runAsNonRoot: true, so a destructive command has to operate within whatever permissions that non-root user actually has. On a correctly built image that's "not much outside its own workspace." The reason this is layer one and not the whole story is that non-root UID alone is a surprisingly weak claim: a process can be UID 1001 and still wield near-root power if it holds the wrong Linux capabilities, and it can still climb back to root if allowPrivilegeEscalation is left at its permissive default via a setuid binary. Both of those escape hatches are closed by the next layer, which is exactly why non-root is necessary but never sufficient on its own.
Layer two: capabilities are gone, not just UID
Dropping root is necessary but not sufficient — Linux capabilities can grant root-equivalent power to a process that technically has a non-root UID. boxxkite's sandbox container sets capabilities.drop: [ALL], so there's no leftover CAP_DAC_OVERRIDE that would let a clever command bypass ordinary file-permission checks, and no CAP_SYS_ADMIN to remount anything. Paired with allowPrivilegeEscalation: false, the kernel refuses the classic no_new_privs escape where a setuid helper hands the process more than it started with.
There is a fifth control riding alongside these that's worth naming honestly, because it also illustrates why you verify the pod and not the template: seccompProfile: RuntimeDefault filters the syscall surface the container can reach at all. As of the v0.2.1 changelog, boxxkite sets this profile on both the sandbox and sidecar containers at runtime— previously it was only advertised in the reference template, and a parity test now guards against that gap reopening. A control that lives only in a YAML file nobody applies is not a layer; it's a comment.
Layer three: even a privileged write attempt hits a read-only mount
The sandbox container's root filesystem is mounted readOnlyRootFilesystem: true. Even if the first two layers were somehow bypassed, a write to the image tree — /usr, /etc, /bin — is refused at the kernel/mount level with EROFS, an independent check that doesn't depend on the process's UID or capability set being correct. This is the layer whose limits are most often overstated, so it's worth being precise: read-only root does not mean rm -rf /has "nowhere writable to even attempt." The pod deliberately mounts several writable volumes so agent code can, you know, do work.
What rm -rf / actually deletes
This is the honest version of layer three. The recursive delete walks the tree, gets EROFS on everything backed by the read-only image, and succeeds on the writable mounts — which are all emptyDirvolumes scoped to this one pod. So the command does destroy something: this session's own scratch space. It does not, and structurally cannot, reach the host node's filesystem or another tenant's data, because those were never mounted into this container to begin with.
| Mount | Sandbox access | What rm -rf / does | Lives as long as |
|---|---|---|---|
/ | read-only (image) | refused — EROFS | the image; never writable |
/workspace | read-write emptyDir | contents deleted | this pod |
/tmp | read-write emptyDir | contents deleted | this pod |
/mnt/user-data/outputs | read-write emptyDir | contents deleted | this pod |
/mnt/user-data/uploads | read-only | refused | this pod |
/mnt/skills | read-only | refused | this pod |
Framing it this way actually strengthensthe containment argument rather than weakening it: the worst a destructive command can accomplish, having defeated nothing, is to erase the disposable directory it was already working in. That's not a partial escape — it's the sandbox working exactly as intended, with the damage confined to state the platform was going to throw away.
Layer four: the blast radius is one disposable pod
Every session gets its own Kubernetes pod, torn down at the end of the session or on demand — SandboxManager.destroy_session() and _recycle_pod_via_k8s() are the paths that do it, and both call _kill_all_processes()first so nothing survives into the next tenant that claims the pod. In the genuinely worst case — every layer above fails in some way nobody anticipated — the damage is bounded to a pod that was always going to be destroyed. It never had a route to the host node, another tenant's pod, or your control plane, and one specific reason it couldn't is worth calling out: the pod spec sets automountServiceAccountToken: false, so there is no Kubernetes API credential sitting in /var/run/secrets for a compromised process to pick up and use to talk to the cluster.
The same disposability logic covers the network. When SANDBOX_EXEC_NETWORK_ISOLATION_ENABLED is true (the default), each /exec runs inside a fresh, empty network namespace created with unshare -n before nsenter— the executed process has no network interfaces at all, which is stronger than "restricted by policy." The cluster NetworkPolicy is the backstop for everything else in the pod, and SECURITY.md is refreshingly blunt that its enforcement is CNI-dependent: verify your cluster actually blocks the cloud metadata endpoint (169.254.169.254) rather than assuming the manifest does something.
The layer this post is not about: the sidecar
Here is where an honest anatomy has to point somewhere uncomfortable. Every sandbox pod runs two containers, and everything above describes the one that runs agent code. The other one — the sidecar that exposes the tool API and syncs storage — runs as runAsUser: 0and holds several near-root capabilities for the pod's entire lifetime, because the nsenter-based isolation design requires them. If you are hunting for the boundary that matters most, it is not the four layers on the sandbox container; it is the sidecar's request surface, which is why boxxkite treats its per-pod auth token, pinned TLS, and the NetworkPolicy as independent, non-optional layers on top of the capability grant.
Two containers, very different privilege
This is not a caveat that undercuts the thesis; it's the reason the thesis is scoped the way it is. rm -rf /executed as the agent runs against the hardened container. The sidecar is a separate boundary with its own separate defenses, disclosed openly in the pod template's own comments and in SECURITY.md's in-scope list, precisely so a reviewer doesn't spend all their attention on the container that's already been locked down four ways.
How to actually check the layers are on
A layer you haven't verified on a running pod is a layer you're taking on faith. The template is the design intent; the live pod is the fact. Three quick checks turn the claims above into observations — and note the last one exists because automountServiceAccountToken: false is the sort of thing that silently regresses if someone edits the manifest:
# 1. Read back the sandbox container's securityContext from the live pod,
# not the template — this is the actual enforced config.
kubectl get pod "$POD" -o jsonpath \
'{.spec.containers[?(@.name=="sandbox")].securityContext}' | jq .
# 2. Prove the root filesystem is really read-only (expect a write failure):
kubectl exec "$POD" -c sandbox -- sh -c 'echo x > /breakout' \
&& echo "WRITABLE — investigate" \
|| echo "refused (read-only, as expected)"
# 3. Prove no Kubernetes API token was mounted (expect: no such directory):
kubectl exec "$POD" -c sandbox -- ls /var/run/secrets/kubernetes.io 2>&1Why "which layer stopped it" is the wrong question
A security review that asks "which one of these actually stops the attack" is asking the wrong question — the design goal is that it doesn't matter which one does, because each is independently sufficient on its own, not a chain that only works if every link holds. That's the actual test worth running: pick one layer, imagine it disabled, and check whether the sandbox is still safe. If the answer is no for every layer you try, that's a real defense-in-depth design. If disabling any single one breaks the whole model, it wasn't actually four layers — it was one layer wearing three costumes. Run that thought experiment against the sidecar too, and you'll find the honest gap the design already names for you: there the layers are fewer, so the auth token and network policy are load-bearing rather than redundant.