cat posts/2026-09-21-sandboxing-agents.md
Sandboxing agents
An engineer on my team asked last week whether the box our unattended agent runs go into is “a real sandbox”, and I started to say yes and then realized I’d have to ask what she meant. People use the word for at least four different things when they talk about coding agents, and they stop different things. One of them is a check inside the agent’s own process. One is the operating system fencing in the commands it runs. One is a container. And one is a separate kernel. You can have any combination, and the one most people think they have is usually not the one doing the work.
So this is the version I gave her, with a picture.
What we’re defending against
It’s worth being clear about the threat first, because it isn’t the movie version. Nobody I know is worried that the model is plotting. The realistic problems are two. The agent is confused and does something destructive with good intentions, like deleting a directory it thinks is a build artifact. Or the agent reads something an attacker wrote, a README in a dependency, a GitHub issue, a web page it fetched, a support ticket, and follows the instructions in it. That second one is prompt injection, and it turns the agent into what security people call a confused deputy: it has your permissions, and someone else is steering.
Those two cause different kinds of damage. The first one breaks things. The second one, if the attacker is any good, takes things: it reads a token or some source code and sends it somewhere. Most sandboxing conversations are about the first kind, because it’s the one you can picture. The second is the one that matters more, and it’s the one most of the layers below do the least about.
Layer one: permission rules
This is what you get out of the box with Claude Code and most of its competitors. Before the agent runs a tool, whether that’s editing a file, running a shell command or fetching a URL, the harness checks the call against your rules and either allows it, refuses it or asks you. There are modes on top: ask for everything, accept file edits automatically but ask about commands, plan without changing anything, or skip the prompts altogether, which is the flag with “dangerously” in its name that I used for the .NET upgrade last year.
Rules are good for keeping a well-meaning agent on the path. They’re weak as a security boundary, for a reason anyone who’s written a command blocklist will recognize. The rule sees the command as a string. You allow npm test, and npm test runs whatever the test script in package.json says this week, which the agent can edit. You deny curl, and python3 -c with urllib is right there, or git push to a remote it added a minute ago. You can’t enumerate your way to safety on a general-purpose computer. And prompts have their own failure, which I wrote about after my first week: after the fortieth “y”, you aren’t reading anymore.
Layer two: an OS sandbox around the commands
Since last October, Claude Code has had a sandbox mode that wraps every shell command it runs, and anything those commands start, in the operating system’s own sandbox: Seatbelt on macOS, which is the same mechanism Apple uses for App Store apps, and bubblewrap on Linux, which builds a restricted view of the filesystem with namespaces. Writes are allowed in the working directory and blocked elsewhere. Network traffic has to go through a proxy that runs outside the sandbox and only lets through domains you’ve allowed.
The difference from layer one is who enforces it. The rules were the agent’s harness deciding whether to run something. This is the kernel deciding what the process is allowed to do once it’s running, and it doesn’t care what the command is called. python3 -c with urllib hits the same wall as curl, because they both end up asking the kernel for a socket, and the sandbox only lets them reach the proxy.
Two caveats. Reading is a lot looser than writing by default, so the directories where your SSH keys and cloud credentials live need explicit deny entries, and you should check that they work rather than assume it. And if the sandbox can’t start, because a dependency is missing or the platform isn’t supported, the default in the version I’m on is to warn and run the command without it. There’s a setting to make that a hard failure, and I’d turn it on.
Layer three: a container, and seccomp
This is what our unattended runs use. I wrote about what a container is back when Docker was new, and the short description still holds: namespaces give a process its own view of the filesystem, the process table and the network, and cgroups limit how much CPU and memory it can use. To the process inside, it looks like its own machine. To the kernel, it’s a normal process with an odd view of the world.
The part people skip is seccomp. Every time a process wants the kernel to do something, open a file, make a socket, start a process, it makes a system call, and there are a few hundred of them. Seccomp lets you attach a small filter program, a BPF program, which the kernel runs on every system call the process makes. It looks at the call’s number and arguments and returns allow, fail with an error, or kill the process. Docker applies a default profile that blocks around 44 of the 300-plus calls, the ones a normal application has no business making, like mount, reboot, kexec_load and keyctl.
That matters because the main weakness of containers is that they all share the host’s kernel. A kernel bug reachable from inside the container is a bug on the host. Seccomp doesn’t fix that, but it shrinks how much of the kernel is reachable, and several real container escapes went through system calls that the default profile already blocks.
Inside our container, the agent runs as a non-root user with a read-only root filesystem and a writable working directory. That’s the setup from the .NET upgrade, mostly unchanged: a fresh clone, no credentials of any kind except one, and network access limited to the package registries and our container registry. The one credential is new since last summer. It’s a token that can push branches with a fixed prefix to the repo it was cloned from and open a pull request, and nothing else. A person merges.
Layer four: a separate kernel
If you’re running code from strangers, a container’s shared kernel isn’t enough, and the answer is to give each workload its own kernel. There are two main ways. gVisor puts a kernel written in Go, running in user space, between the application and the real kernel, and handles most system calls itself, so the host kernel sees a much smaller set. Firecracker runs each workload in a tiny virtual machine with its own Linux kernel and a minimal emulated device model, and it’s what AWS Lambda runs on. Its paper reported boot times around 125 milliseconds, which is why it’s practical to start one per job.
Most of the hosted agent sandboxes you can rent now are built on one of these, and if I were offering agents to the public I’d use one. For our own agents working on our own code, I think a container with seccomp is the right level, but I’m not dogmatic about it, and if we ever let an agent run code that came in from outside, a customer’s file or a pull request from a stranger, that’s the job I’d move to a microVM.
The red bar
Look at the bottom of the picture. Every one of those layers is about limiting what a process can do on the machine. None of them does anything once the process holds a credential that’s allowed to do the harmful thing, or can reach a domain that will accept what it sends.
An agent with a production database URL in its environment can drop the database from inside a Firecracker VM. An agent with a GitHub token that can push to any repo can push your source to a repo the attacker controls, through a proxy that’s happily allowing github.com, because you allowed github.com so it could push. Allowed domains are the hole in every network allowlist, and the more general-purpose the domain, the bigger the hole. A package registry that accepts uploads, a paste site, a model provider’s API, any of those will take data out if the agent has something to authenticate with.
That’s why the token in our container can only push branches with one prefix to one repo, and why nothing in there can publish a package. It’s also why the agents on our laptops are the part I’m least comfortable with. A laptop has everything on it: SSH keys, a logged-in cloud CLI, a browser profile with sessions in it. The OS sandbox helps a lot there, if the deny entries are right. But the honest description of an agent on a developer’s laptop in default mode is that it’s protected by the developer reading prompts, and I know how well that works because I’m one of the developers.
If you only do one thing, take the credentials out of where the agent runs, and give it narrow ones for the one job. Then pick whichever of the boxes fits how much you trust what it’ll read.