Chapter 1 of 7 · 18 min read
The blast radius of a sandbox
On this page
A code execution tool is a tool call whose argument is a program and whose result is whatever running that program printed: the model writes Python, a process somewhere outside the model runs it, and standard output comes back into the conversation as the tool result. It exists because a language model produces text and nothing else, so a question that needs arithmetic over ninety thousand rows, or a chart, or a file converted from one format into another, has to be answered by something that computes rather than by something predicting what the answer would look like. You meet it as the data-analysis mode in ChatGPT, as the code execution tool on the Claude API, and inside every coding agent that runs a shell command on somebody's laptop. Two neighbouring capabilities work the same way with a different instrument, and they are the rest of this topic's subject: computer use hands the model a screenshot of a desktop and accepts mouse and keyboard actions in return — click at these coordinates, type this string, take another screenshot — so that it can operate any application a person can operate, and browser use is that loop narrowed to a browser, with the page going in as pixels or as text and navigation, clicks and typing coming back. All three exist because the alternative is an engineer shipping a new endpoint for every variation of every request, and the generality is what the capability is bought for.
Those three are one family holding three instruments, and it is worth seeing what they share before the differences start to matter. Code execution hands the model a language and a process to run it in. Computer use hands it a screen and a pair of hands. Browser use hands it a screen with one application on it. In every case the model produces a description of an action and something outside the model performs that action against a real system, which is why the same question is the right one to ask of all three — what does a single wrong action reach — and why the answer comes out differently for each of them. The question is answerable before any of them is switched on, because it is decided by what the tool was placed next to rather than by what the model does.
Blast radius is the phrase for what a reviewer is trying to establish about the first of them: everything a single run can read, change or reach when it does the wrong thing, whether a hostile instruction steered it there or it simply misread the task. It is decided by what surrounds the tool rather than by the model driving it, which is why it can be written down before anything runs, and why the name of an isolation technology is not an answer to it. This chapter is about producing that written answer — a containment statement — and about the probe that keeps it true after the infrastructure changes underneath it.
The three questions that produce an answer
Topic 7.6, on sandbox design, covers how strong an isolation boundary can be made — user namespaces, seccomp profiles, a gVisor or Kata runtime, a microVM per execution — and those choices matter. They are answers to a different question from the one a reviewer is actually asking. Isolation strength governs how hard it is to get out of the box. Blast radius governs what is reachable from inside the box while entirely obeying it. Most incidents involving code-execution tools are the second kind, because the second kind requires no exploit, no unusual behaviour, and no privilege escalation. It requires the model to use the capability it was given on data it was told to trust.
The independence of those two questions is worth deriving rather than asserting, because it is the whole reason a stronger runtime does not move the answer to the second one. A sandbox boundary is a filter. It decides which requests the process may make of the kernel underneath it, which parts of the machine it can name, and which parts it cannot see at all. A mount, a credential and a network route are not requests of that kind. They are objects placed inside the boundary before the process starts, by whatever built the sandbox. A file copied in is already on the filesystem the process is permitted to see, so reading it needs no operation the filter would refuse. A route attached to the sandbox's network namespace is already in the set of addresses the process is permitted to use, so a connection over it is not an escape from anything. Tighten the filter as far as it will go and every one of those objects is still inside it, because they were put on the permitted side. That is the step: the boundary governs what the process may do, the three questions govern what it was given, and no amount of the first constrains the second.
So when someone answers a safety question with the name of a technology, the reply that moves the conversation forward is three questions, each of which has a checkable answer:
What is mounted into it. Not the intended mount, the full list, including
the ones the base image or the orchestrator adds. In Kubernetes that includes
the projected service account token at
/var/run/secrets/kubernetes.io/serviceaccount/, which is present unless
automountServiceAccountToken: false was set, and which is a credential for
the API server with whatever the pod's role grants. It also includes anything a
developer added during debugging and never removed, which is how source trees,
.git directories with remote credentials in the config, and home directories
end up inside execution sandboxes.
Which credentials are visible from inside. Environment variables are the
obvious ones and the least interesting, because everyone thinks of them. The
ones that get missed are ambient: the instance metadata service handing out IAM
(identity and access management) role credentials to anything that can make an
HTTP request to a link-local address, a mounted cloud SDK config directory, an
SSH agent socket forwarded for convenience, a ~/.netrc, a Docker config with
registry pull secrets, a Kerberos ticket cache. A credential does not have to
be passed to the tool to be available to it.
What it can reach on the network. Egress to the public internet decides whether anything the execution learns can leave. Reachability into the internal network decides how much there is to learn — an execution environment on a flat internal network can talk to the metrics stack, the internal package registry, the staging database that shares a password with production, and the admin interfaces that were never exposed publicly because they had no authentication worth the name. DNS is part of this: a sandbox with no TCP egress but working recursive DNS resolution can still exfiltrate — carry data out of the boundary — a few hundred bytes per query to a domain the attacker controls.
That last clause is the one people wave through, and its mechanism is short enough to check line by line. A resolver that cannot answer a name from its own cache has exactly one way to find the answer, which is to ask the servers responsible for that name. A name under a domain the attacker registered is, by the design of the naming system, delegated to a machine the attacker runs. So the query arrives there. Notice what has already happened at that point: the attacker has not answered anything, and does not need to. The question is the delivery, and the question is a string the sandbox chose. What limits the channel is format rather than policy — a name is built from labels, each label is length-bounded and the whole name is length-bounded too, which is why one query carries a few hundred bytes rather than an unbounded amount. Slow, and slow is a different property from blocked.
Here is what those three questions look like answered badly, in a real deployment file for an internal data assistant that could write Python and run it:
services:
code-exec:
image: python:3.12-slim
working_dir: /workspace
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /srv/analytics/notebooks:/workspace
- /home/svc-analytics/.aws:/root/.aws:ro
env_file: .env # SNOWFLAKE_PASSWORD, SLACK_BOT_TOKEN, PD_ROUTING_KEY
network_mode: host
command: ["python", "-u", "/opt/runner/serve.py"]Every word a review would say about that file can be true at once. The
interpreter does run in a container, the container does have its own filesystem
and process namespace, and nothing about it is misconfigured in the sense of
being broken. It also has the host's Docker socket mounted, which is
root on the host for anyone who can issue an API call to it; a read-only copy
of a service account's AWS credentials; three long-lived tokens in the
environment; an analytics team's notebook directory, mounted read-write; and
the host's network stack, which means every internal service the host can
reach, including the cloud metadata endpoint at 169.254.169.254. A model that
writes open("/root/.aws/credentials").read() and prints the result is not
escaping anything. It is reading a file that was handed to it.
| Path from a hostile execution | Requires an escape? | Addressed by a stronger runtime (gVisor, microVM)? |
|---|---|---|
| Read mounted credentials and use them | No | No — the file was mounted in |
| Fetch IAM role credentials from the metadata endpoint | No | No, unless egress to link-local is blocked |
| Reach an internal admin service on the flat network | No | No — network policy is a separate control |
| Exfiltrate results over HTTPS or DNS | No | No — egress policy is a separate control |
| Modify files the team relies on, in a read-write mount | No | No |
| Control the host through a mounted Docker socket | No | No — this is a documented API |
| Break out via a kernel vulnerability | Yes | Yes — this is what it is for |
The table is uncomfortable in a specific way: the isolation technology, which is the thing a review usually spends its attention on, addresses one row, and it is the row that requires the most work from an attacker.
The two right-hand columns close on each other, which is what makes the table worth more than a list of worries. Read the middle column downward: every path except the last answers no, meaning it needs no escape. Now read the right-hand column: every path except the last answers no again, meaning a stronger runtime does not address it. The two columns are the same partition written twice. The set of paths a stronger runtime answers is exactly the set of paths that require an escape, and that set has one member. There is no row where the two columns disagree, and the absence of such a row is the finding rather than an accident of how the table was drawn — a row that needed no escape but was still fixed by a better runtime would have to be a path that goes through the kernel without going around it, and there is no such path. What the identity licenses is a narrower claim than "containers are not enough". It says that choosing between a plain container, gVisor and a microVM decides one row of the enumeration, and that the other rows are decided in the volume list, the environment block and the network policy, which are three files nobody read.
Answering the three questions also changes what escape means. If nothing is mounted, no credential is reachable, and there is no egress, a kernel escape gets an attacker a machine with nothing on it, and the isolation technology is the last line rather than the only one. If the Docker socket is mounted, the isolation technology is decorative, because there is a supported API that grants root on the host and it does not involve a bug.
The belief this chapter is arguing with is not carelessness, and it is worth stating in the form that makes it testable. "We run it in a container" is offered as a defence, and the test of any defence is whether its answer changes when the thing it is meant to bound changes. Hold that sentence against the compose file above, where a Docker socket, a credentials directory, three long-lived tokens and the host's entire network stack are reachable without leaving the container. Then hold the same sentence against the containment statement in the next section, where nothing is mounted, no secret is present, and one internal address is permitted. The sentence is true in both cases, word for word, while the exposure between them runs from root on the host to effectively nothing. A defence whose answer is constant while the quantity it must bound varies across its whole range carries no information about that quantity — which is why the reply to it is three questions rather than a counter-claim. The questions have answers that move.
Write the statement, then test it
The useful artefact is a paragraph that any engineer on the team could have written in an hour, and that no team writes unless someone asks for it. It states what contains this tool, in terms of the three questions, with values rather than adjectives.
# containment.yaml — reviewed 2026-03-04, owner: platform-security
tool: code_exec (data assistant)
runtime: gVisor, one sandbox per execution, destroyed after
lifetime: 120s wall clock, 2 vCPU, 2 GiB, no swap
filesystem:
writable: /tmp only (tmpfs, 512 MiB, discarded)
mounted_in: none
base_image: python:3.12-slim + pandas, pyarrow, numpy (pinned)
credentials_visible_from_inside:
env: none (the runner passes no secrets into the sandbox)
metadata_endpoint: blocked (169.254.0.0/16 dropped at the sandbox netns)
k8s_service_account: automountServiceAccountToken false
files: none
network:
egress_internet: denied (default DROP, no proxy)
egress_internal: denied except 10.40.2.11:9000 (results object store, write-only bucket)
dns: resolver returns NXDOMAIN for everything except that host
data_reaching_it:
query results are copied in as a Parquet file on /tmp before start,
scoped to the caller's row-level permissions, max 200 MiBTwo lines of that statement answer none of the three questions and belong in it
anyway. The lifetime and the resource bounds — 120 seconds of wall clock, 2
vCPU, 2 GiB, no swap — decide nothing about reach, because a sandbox with
nothing mounted and no egress is equally harmless whether it runs for a second
or for a week. What they bound is consumption: a while True loop that
allocates, a hostile instruction that asks for a proof-of-work computation, a
model that keeps retrying a failing script. That is the denial-of-wallet
question rather than the blast-radius one, and it is worth writing on the same
page because the artefact is what a reviewer reads, and a reviewer who has to
consult two documents consults one.
Offered as a picture rather than as a claim about the technology: the isolation runtime is the thickness of the walls, and the three questions are an inventory of what was carried into the room before the door was shut. Thickening the walls does nothing to the inventory, and the inventory is what a thief in the room walks out with. The picture breaks in one place worth naming, because that is where the misreading forms. A room's contents change only when somebody carries something in, deliberately, through the door. A sandbox's reachable set can grow because a shared network module three repositories away was widened by an engineer who has never heard of this tool, and nobody carried anything anywhere. That difference is the reason the statement above has to be re-checked by a machine rather than remembered by the team that wrote it.
That statement is a description rather than a control, and the distinction matters, because descriptions rot. The control is a test that asserts it, run on every deploy of the sandbox image and every change to the network policy. A probe that runs inside the sandbox and reports what it can see takes an afternoon to write and catches the case where a well-meaning change to a shared Terraform module reopens egress for everything in the namespace:
# probe.py — executed through the tool itself, in CI, asserting containment.yaml
import os, socket, pathlib, urllib.request
findings = []
for path in ("/root/.aws", "/var/run/docker.sock", "/workspace",
"/var/run/secrets/kubernetes.io/serviceaccount/token"):
if pathlib.Path(path).exists():
findings.append(f"MOUNT {path} is present")
for key in os.environ:
if any(m in key.upper() for m in ("TOKEN", "SECRET", "PASSWORD", "KEY")):
findings.append(f"ENV {key} is visible")
try:
urllib.request.urlopen(
"http://169.254.169.254/latest/meta-data/iam/security-credentials/",
timeout=2,
)
findings.append("METADATA endpoint reachable")
except Exception:
pass
for host, port in (("example.com", 443), ("10.40.0.1", 5432), ("8.8.8.8", 53)):
s = socket.socket(); s.settimeout(2)
if s.connect_ex((host, port)) == 0:
findings.append(f"EGRESS {host}:{port} open")
print("\n".join(findings) or "OK: matches containment.yaml")The first run of that probe against the compose file above produces six findings in under three seconds, none of which require an exploit and all of which had been signed off. Run it as a scheduled job as well as in CI, because the drift that matters usually arrives through infrastructure changes rather than through changes to the tool.
One choice inside that script is a design decision rather than a requirement, and separating the two is what keeps a probe useful across a migration. The requirement is that each of the three questions is asked from inside: what is mounted, what is visible, what is reachable. The decision is which specific paths, keys and addresses stand in for them. The list of mount paths includes the projected Kubernetes service account token even though the deployment being tested is a compose file that can never produce one, because the probe is written against the containment statement rather than against the current infrastructure — the statement makes a claim about that token, so something has to check it. The consequence is that the same probe keeps working when the tool moves to Kubernetes, and it fails loudly on the day the mount appears rather than being silently inapplicable. A probe written against the deployment instead would have passed on that day and told nobody.
The detail that makes the probe worth more than an inspection of the configuration is in its first line: it is submitted through the tool, as a program the model could have written, rather than run against the sandbox from outside. What a reviewer wants to know is what an execution sees, and that is not always what the deployment file says — a mount can be added by a base image, an egress rule can be widened by a module three repositories away, and a metadata endpoint can be reachable through a route nobody declared. Asserting containment from the inside is the only version of the question that has the same answer as the incident would.
When the sandbox is not yours
Provider-hosted execution tools — the code execution tool on the Claude API, and equivalents elsewhere — run the interpreter in the vendor's infrastructure, which means the mount list, the credential surface and the network policy are theirs rather than yours. You cannot run the probe inside it, and the containment statement has to be written from the documented behaviour plus what you control at the edges. Three things stay under your control and are the ones to write down: what data you send into the session, since the sandbox can only leak what it was given; whether files persist between sessions and for how long, because a container that lives for the length of a conversation and a file store that lives for a month are different exposures; and whether the sandbox has network access at all, which for the hosted tools is generally either absent or restricted to package installation, and which decides whether the lethal trifecta — private data, untrusted content and an exfiltration channel in one agent — closes. The statement for a hosted tool is shorter than the one for a self-hosted sandbox and it is still a statement, and writing it surfaces the question of what the assistant is uploading into the session — which, in one finance assistant's case, was a full customer table because that was easier than filtering it first.
A different failure appears when the sandbox is yours and multi-tenant. One
sandbox per execution, destroyed afterwards, is a strong property; a pool of
long-lived warm containers reused across tenants for latency reasons is a much
weaker one, because a file written to /tmp by tenant A is readable by tenant
B until something cleans it, and a background process started by one execution
keeps running into the next. Warm pools exist because cold-starting a sandbox
costs 800 ms to 3 seconds depending on the runtime and that is visible to
users, so the pressure to reuse is real and continuous. If you reuse, the
containment statement has to say so, and the probe has to check for residue
from previous executions rather than only for mounts and egress.
A statement is not a threat model, and containment is not correctness
A containment statement says what an execution reaches; it says nothing about who would want to reach it, how a hostile instruction gets into the model's context in the first place, or how likely any of it is. It is deliberately cheaper than a threat model, which is why it gets written.
It is also not an argument that containers are useless. A container is a good
answer to the questions it answers: a runaway while True loop that allocates
memory does not take down the host, a package installed during one execution
does not persist into the next, and the whole environment can be rebuilt from
an image rather than repaired. Those are real properties and worth having. They
are simply not answers to "what does a compromised execution reach", and
substituting one for the other is the failure this chapter describes.
Finally, none of it addresses correctness. A perfectly contained sandbox will still let an assistant compute the wrong number from the right data and present it with confidence, and that failure has caused more damage in more organisations than sandbox escapes have. It needs a different class of control entirely, and later chapters in this topic build it.
What contains a code-execution tool is not the runtime it names but the answers to three questions that anybody can check: the full mount list, the credentials reachable from inside including the ambient ones nobody passed in, and what the sandbox can talk to over the network and over DNS. Written down with values in them, those answers are short enough to review in a meeting and specific enough to test, and the test is what stops them from becoming untrue three deploys later.
That gives you a way to describe one capability. It does not tell you how to rank two capabilities against each other, and that is the harder judgement, because the intuitive ranking — arbitrary code is more dangerous than clicking around a web page — turns out to be backwards in the case teams actually face.
Computer use, browser use and code execution tools — read the current version at https://daviddominguez.dev/learn/en/computer-use-browser-use-and-code-execution-tools/blast-radius-of-a-sandbox