Skip to content
← All writing

Sandboxing code an agent wrote for you

4 min read
.md
Cover illustration for Sandboxing code an agent wrote for you

Code an LLM generates is untrusted input and needs process-level isolation, not a try/catch. Give each session its own container, run the coding loop inside it rather than in your request handler, and design for the operational problems: per-tenant quotas, mid-run cancellation, and streaming partial output.

My agent platform has a skill where you describe something you want built and the agent writes the code and runs it. Clean this spreadsheet. Scrape this list. It works well.

It also means a stranger can type a sentence and my servers will execute whatever program comes out of it.

People overcomplicate the security question here. Nobody is worried the model has gone rogue. The point is simpler: a user’s prompt is input I do not control, the model turns that input into a program, and I run it. It does not matter what the model intended.

So the code needs a real boundary around it. What I did not expect is that the boundary turned out to be the easy part.

Pick the boundary, then stop thinking about it

The options are well documented and the decision is not close for most teams.

BoundaryIsolationStart timeWhen it fits
Same process, vm or execNone worth the nameInstantNever, for untrusted code
Container, shared kernelNamespaces and cgroupsSub-secondTrusted-ish workloads
gVisorSyscalls intercepted in user spaceSub-secondUntrusted code, most cases
Firecracker microVMHardware-enforcedAround a secondUntrusted code, hostile tenants

Do not try to do this inside your language. Node’s vm module tells you in its own documentation that it is not a security boundary, and the history of JavaScript sandbox escapes is basically one long list of people finding a way to reach back out to the host.

I use Cloudflare’s Sandbox, which runs on Containers, one per session. That choice was mostly about not operating a second orchestration system, and the isolation properties come from the platform rather than from anything I wrote. Which is the point. Whichever row you pick, pick it once.

The design decision that mattered more

I originally ran the coding loop in the Worker: the model proposed an edit, the Worker applied it, ran a command, fed the output back. It kept hitting the model’s output limit and truncating tool-call JSON mid-string, which fails in a way that is tedious to diagnose because the error surfaces as malformed arguments rather than as a length problem.

The version that works inverts it. The Worker does not run a coding loop. It boots a container, runs a coding agent inside it, and streams that process’s output back. Two models, two jobs:

  • A cheap fast model in the orchestrator, talking to the user and deciding to delegate.
  • A strong model driving the coding agent inside the container, iterating until the task is done.

The orchestrator never sees the file edits. It sees a task id, progress lines, and a result. Its context stays small, and the coding loop is bounded by the container rather than by a single response.

What breaks in production

Isolation is table stakes. These are the problems that generate tickets.

Cancellation is the one to design for on day one. A user says stop while the container is mid npm install, and you need a control path that does not depend on the running process cooperating. That means killing the container and treating the partial workspace as garbage. Retrofitting this into a loop that assumes it runs to completion is a rewrite, not a patch.

Quotas come next. One tenant should not consume the pool. Mine are enforced before the container boots, against the same token wallet the rest of the platform uses, because container time and model tokens both cost money and a tenant does not distinguish between them.

Then there is getting results out. The container has a filesystem the user cannot see, so I mirror workspace changes to object storage as they happen. A run cancelled at 80% still leaves the files it wrote. Streaming progress as newline-delimited JSON into a key the frontend polls was the least clever option available and has never caused a problem.

Last, put the timeout on the task rather than the request. A coding task can legitimately take minutes; the HTTP request that started it cannot. Anything long-running goes through a queued object with its own alarm, and the request returns a task id immediately.

Should the container reach the network?

Mostly no, and the exception is annoying.

Code that installs a dependency needs a package registry. Code that fetches a URL because the user asked it to needs the open internet, which is the same SSRF exposure a webhook sender has, except the attacker gets to write arbitrary code rather than supply a URL.

I allow package registries and block private address ranges. It is a compromise rather than a solution, and I would tighten it to a proxy with an allow-list if the workload justified the work. Anyone claiming a network-enabled sandbox is fully contained has not thought about DNS.

Sources

Written by Elson Tan, Head of Technology and co-founder at Nedex Group, working on AI harness and agent infrastructure.

AboutRSS
  • 6 min read

    The job was never the code

    Most of my code is now written by an agent, and my output went up rather than down. That is not a story about typing speed. It is about what the job always was underneath the typing.

  • 4 min read

    One Durable Object per agent

    Why I gave every AI agent its own Durable Object with a SQLite task ledger, and what that bought in state, concurrency and debuggability.

  • 4 min read

    Signing webhooks, and the SSRF hole nobody patches

    Most webhook guides stop at HMAC signatures. The bigger risk is on your side: a customer-supplied delivery URL turns your sender into a proxy for your own private network. Here is the full path.

Get in touch

Tell me who you are and what you are working on.

Your details are used only to reply to this message.