The whole design, in writing
Learn AI system design by building a secure code execution sandbox for untrusted, LLM-generated (or user-submitted) code — the runtime behind AI agents that write and run code, online judges and notebooks — step by step. An interactive guide covering why running code directly is catastrophic, isolation with containers/microVMs, resource limits, the execution pipeline, ephemeral sandboxes, warm pools for latency, and the unhappy paths (escapes, egress control, sessions).
Every step of the build above, written out: the problem each piece solves, the option that was taken and the ones that were not, the numbers, and how it fails in production.
The big idea
Why is running code hard?
An AI agent writes code to solve a task and needs to run it. An online judge accepts a user's submission. A notebook executes a cell. In every case you're about to execute code you did not write and cannot trust — it could try to read secrets, attack internal systems, exfiltrate data, or hog the whole machine. And you have to do it fast, at scale, for many concurrent runs.
A code execution sandbox runs untrusted code safely: strong isolation (so code can't touch anything it shouldn't), strict resource limits (so it can't exhaust the machine), ephemeral environments (a fresh sandbox per run, destroyed after), and a warm pool so it's still fast. Security and speed are the whole problem — the "run code" part is easy; doing it without getting owned is not.
What the new pieces do
- Callerclient
- An AI agent that wrote code to run (or a user submitting code to a judge/notebook). The code is untrusted by default.
Step 1 · Never run it directly
Untrusted code = full compromise
The naive approach: just exec the code in your service process (or on the host). Why is this catastrophic when the code is untrusted?
Run untrusted (LLM-generated) code directly in your service. What's the danger?
Untrusted code executed in your process inherits its permissions and environment: it can read env vars/secrets, access the filesystem and other users' data, consume all CPU/memory, and make network calls to internal services. It is, by definition, arbitrary code execution on your infrastructure — a total compromise. It must run inside strong isolation with no ambient access.
Syntax errors are harmless. The real danger is that untrusted code running with your privileges can do anything your service can — read secrets, exfiltrate data, attack internal systems, or take down the host.
Slowness is a resource concern (handled by limits), but the primary danger is security: arbitrary untrusted code with your process's access is a full compromise, not just a performance issue.
Executing untrusted code in your process means it runs with your privileges and environment — it can read secrets and env vars, touch the filesystem and other users' data, consume all CPU/memory, and call internal services. That's arbitrary code execution on your own infrastructure: a complete compromise. The code must run somewhere with no ambient access to anything that matters — isolated from the host, the network, and every other run.
Why this piece earns its place
The trust boundary is set by who can influence the code, not by who wrote it. A program an agent emits was authored, in practice, by whatever text reached its context — a page it browsed, a ticket it was handed — so the employee who started the run is not its author, and no internal caller has earned a fast path around the sandbox. Ship one and it becomes the path everything routes through the first time a deadline argues for it. That also settles a deployment question that looks like taste. Execution belongs behind a service boundary, never a library the API imports, because a library runs inside whatever ambient authority the calling process was already handed: the database pool, the cloud role, the mounted config, the network position internal services trust by source address. Nobody adding the next feature to that service is thinking about your sandbox, and every one of them widens what it was meant to be sealed off from. And price the failure while it is still hypothetical: untrusted code that ran with a service's identity means every credential that identity could reach is now assumed public, so recovery is a rotation across all of them, not a rebuild of one host.
Step 2 · A service around execution
Submit, run isolated, capture
Wrap execution behind a service. What's the basic contract, regardless of the isolation tech?
An execution API takes code + language + inputs, runs it inside an isolated sandbox under strict limits, and returns only the captured result — stdout/stderr, return value, exit status, and metrics. Nothing else crosses the boundary: the sandbox has no access in (no secrets, no network) and only structured output goes out. This clean submit→isolate→capture contract is the frame; the next steps make the isolation and limits real.
Why this piece earns its place
Two fields decide whether a machine can actually drive this API. The first is output size. Captured output is the one thing that legitimately crosses the boundary, which makes it the one channel the resource limits do not cover — a program printing in a loop turns your result field into the exhaustion vector you closed everywhere else. Cap the bytes, truncate at the cap, and say in the response that you truncated, because silent truncation reads downstream as a program that stopped early. The second is the difference between the code failed and we failed to run the code. A non-zero exit, a compile error, a timeout: those are results, and the caller should act on them. No sandbox available, a host that died mid-run, a pool that came up empty: those are ours, and only those should be retried. Collapse them into one error field and an agent in a loop will retry a deterministic failure until its budget is gone. One thing the contract has to refuse outright is an input that is a reference. Accept a URL to fetch and the service dials it with its own network position and its own credentials, handing the caller exactly the reach this boundary exists to remove. Inputs arrive as bytes.
- incode + inputs
- isolatedrun under limits
- outonly captured result
What the new pieces do
- Exec APIgateway
- Accepts code + language + inputs, schedules a run in an isolated sandbox, and returns captured output/result.
Step 3 · Contain what it can touch
Isolation: containers & microVMs
The code must run somewhere it can't harm anything. What actually provides that isolation, and how strong does it need to be?
What isolates untrusted code from the host and other runs?
You can't review arbitrary (or generated) code for every run, and cleverly malicious code can hide intent. Isolation must be enforced by the runtime, not by inspecting the program.
A different user is a start but nowhere near enough — plain process isolation shares the kernel and can be escaped, and it doesn't block network or resource abuse by itself. You need strong sandboxing: containers hardened with syscall filtering, or microVMs.
Untrusted code runs inside a container hardened with syscall filtering/user-namespacing, or a lightweight microVM that gives near-VM isolation with fast startup. Deny network by default, drop privileges, restrict the filesystem, and layer defenses (defense in depth) so a single weakness doesn't breach the host.
Run the code inside a hardened sandbox: a container with restricted syscalls (seccomp/gVisor-style filtering, user namespaces) or a lightweight microVM (Firecracker-style) that gives near-VM isolation with fast boot. Apply least privilege — no network by default, a minimal read-only-ish filesystem, dropped capabilities — and layer these (defense in depth) so no single weakness breaches the host. Isolation is enforced by the runtime, never by trusting the code.
Why this piece earns its place
Start with the endpoint everyone forgets. The cloud instance metadata service answers ordinary HTTP from inside the guest and hands the host's credentials to whatever asks, so it has to be blocked at the sandbox's network rather than merely left unused — a link-local address is not a secret, and the code has read the same documentation you did. Then two consequences of the choice itself. Hardware virtualization needs a host that offers it, so picking microVMs constrains which instance types you can buy and which regions you can run in, and that lands in capacity planning long before it reaches a security review. And the guest image becomes a versioned artifact you own, which makes it part of every answer you return. An interpreter or library bump changes what a program does, and rolling a new image across a pool is gradual by construction — the old sandboxes have to drain first — so two identical submissions minutes apart can land on different images and disagree. Stamp the image version on the result. It costs nothing now, and it is the only thing that will ever explain a judge's scores moving on a day nobody deployed anything.
- container + seccomphardened, shared kernel
- microVMnear-VM, fast boot
- no net · least privdefense in depth
What the new pieces do
- Sandboxdata
- The isolated runtime (container/microVM) where untrusted code actually executes — restricted syscalls, no network by default.
Step 4 · Contain how much it consumes
Resource limits
Isolation stops code from reaching things — but code can still hang forever, allocate all memory, spawn endless processes (a fork bomb), or fill the disk, taking down the worker and co-located runs. How do you bound consumption?
Enforce strict resource limits per run: a CPU quota, a memory cap, a hard wall-clock timeout, a process/thread limit (to stop fork bombs), and disk quotas — via cgroups/ulimits or the microVM's allocation. Anything exceeding its limits is hard-killed. This turns "runs forever / eats the machine" into "fails cleanly at its quota", protecting the worker and every other tenant. Isolation contains what; limits contain how much — you need both.
Why this piece earns its place
Sort these knobs by where they belong, because the answer differs for each. Wall-clock time belongs in the request — a judge's test case and an agent's exploratory loop want different deadlines — but clamped by a server-side maximum, since the caller is untrusted too. Memory is not the caller's to choose: it decides how many sandboxes fit on a host, and therefore your cost per run. Then learn what each limit actually does when it is hit, because they do not behave alike. A CPU quota throttles; it never kills, so a compute-bound run does not fail at that limit, it simply takes longer — and the thing that finally ends it is the wall clock. That couples the two: the same program finishes comfortably on a quiet host and times out on a busy one, which makes the deadline the least reproducible promise in the system and the source of most it-worked-yesterday reports. A garbage-collected runtime has the opposite shape. Sized to exactly the memory cap it does not slow down at the boundary, it dies there, so usable heap is always below the quota and the runtime needs its own smaller ceiling set inside the sandbox. The quiet payoff: only once every run has a ceiling does the fleet have a denominator, which is what turns Step 7's pool sizing into arithmetic instead of guesswork.
- cpu · mem · timeper-run quotas
- pid limitstop fork bombs
- exceed → killfail cleanly
What the new pieces do
- Resource Limitsservice
- Enforces per-run quotas (CPU, memory, wall-time, processes, disk) and hard-kills anything that exceeds them.
Step 5 · The execution pipeline
Queue → sandbox → capture
Put it together into a flow that handles many concurrent runs and returns results. What's the path a submission takes?
Submissions flow through a queue to an orchestrator that assigns each to a fresh isolated sandbox (with limits wired up), runs the code, captures stdout/stderr/return/exit/metrics, returns the result, and tears the sandbox down. The queue absorbs bursts and lets you scale workers; the orchestrator manages sandbox lifecycle and enforces limits; only the structured result leaves. Long or interactive runs stream output; batch judge runs return when done.
Why this piece earns its place
A queue hands you one guarantee you did not have before: delivery is at-least-once. The visibility timeout has to exceed the longest wall-clock limit you allow, or the queue gives the same job to a second worker while the first is still running it. You pay twice, and anything with a side effect outside the sandbox — a package pulled through your mirror, an allowlisted API call — happens twice. So submit carries a caller-supplied run id, and the orchestrator refuses the duplicate rather than re-executing it, handing back the first attempt's answer. The second change is ownership of a live sandbox. Tear it down in a finally on the request path — that is what returns capacity fast enough for a pool to mean anything — but a finally only runs while the process holding it is alive, and an orchestrator that dies mid-run leaves a sandbox nobody owns: still allocated, still counted against the host, no request ever coming back for it. So in-path teardown needs a backstop: a lease on every sandbox, and a reaper walking the runtime's own inventory to kill whatever has no live owner or an expired lease. Without one the fleet looks healthy right up to the day it has no free capacity and no running work to explain where it went.
- queueabsorb bursts
- orchestrateassign + limit + capture
- result onlycrosses the boundary
What the new pieces do
- Job Queuebus
- Buffers execution requests so bursts drain through the sandbox fleet at a sustainable rate.
- Orchestratorservice
- Assigns each run to a fresh isolated sandbox from the pool, wires up limits, and reclaims the sandbox after.
- Resultdata
- Captured stdout/stderr, return value, exit status and metrics — the only thing that leaves the sandbox.
Step 6 · Nothing persists
Ephemeral sandboxes
If a sandbox were reused across untrusted runs without resetting, one run could leave behind malware, state, or traps for the next. How do you prevent cross-run contamination?
How do you stop one untrusted run from affecting the next in the same sandbox?
Each run gets a clean sandbox that is thrown away after, so nothing an untrusted run does — files written, processes left, memory state — can persist to affect another run or user. Ephemerality is what guarantees isolation holds over time, not just per run.
Manually cleaning a reused environment is error-prone and leaves subtle state (installed packages, background processes, kernel state) that untrusted code can exploit against the next run. A fresh, destroyed-after sandbox is the safe default.
Untrusted runs can be adversarial precisely toward the next tenant. You must structurally prevent contamination with ephemeral, per-run sandboxes, not trust.
Make sandboxes ephemeral: a fresh environment per run (or per user session), destroyed afterward, never reused with dirty state. Nothing an untrusted run does — files, leftover processes, memory, installed packages — can persist to affect another run or user. For workflows that need continuity (an agent iterating, a notebook), keep a per-session sandbox that lives only for that session and is still isolated and disposable — persistence is scoped to one trust boundary, then wiped.
Why this piece earns its place
The session case is where this stops being free, and the cost is scheduling, not security. A session pins one sandbox to one host for as long as it lives, so the fleet stops being interchangeable capacity — you cannot drain that host to patch it without killing live sessions, which runs straight into the patched-hosts defense in Step 8. So a session gets a maximum lifetime and an idle timeout whatever the user is doing, and the client has to survive losing one: the real state belongs in the caller's transcript or an object store, with the sandbox holding a reconstructible copy rather than the only copy. Then look at what a session consumes. It holds its memory the whole time it is idle, and a notebook is idle for most of its life while a human reads what the last cell printed. Sessions are billed in memory-hours, not CPU-seconds, so a quota model written around execution time prices them at nearly nothing — and they quietly become the largest line item on a bill nobody can explain.
- freshper run/session
- destroyedafter
- sessionisolated + disposable
Step 7 · Fast enough to use
Warm pools & scaling
Booting a fresh microVM/container per run adds startup latency, and an agent that runs code in a loop can't wait seconds each time. How do you keep it fast while staying ephemeral?
Keep a warm pool of pre-booted, ready sandboxes so a run starts in milliseconds instead of waiting for a cold boot — hand one out, run, discard it, and replenish the pool in the background. Autoscale the pool and worker fleet on demand. MicroVMs like Firecracker are chosen partly because they boot in ~milliseconds, making fresh-per-run affordable. Balance reuse vs isolation carefully: pre-warming the environment is fine, but never reuse one that has already run untrusted code.
Why this piece earns its place
A pool is only warm for the environment you pre-warmed. Add a second language, then a common dependency set, and the pool partitions: each partition needs its own headroom, so idle capacity multiplies by the number of variants and the least-used language is always the one that boots cold. The way out is fewer base images with the language-specific layer mounted at hand-out, rather than a pool per permutation. The knob that matters is the refill rate, not the pool size. A burst drains the pool faster than boots replace it, and the failure is a cliff rather than an error — every start is fast until the pool empties, then every start pays a full boot. The mean start latency tracks that shift exactly, climbing with the share of requests that miss the pool, which makes it coincident rather than early: by the time it has visibly moved you are already booting for real traffic, and the tail moved first. Occupancy and time-to-refill are the leading pair, so alert on those. One rule falls out of pre-booting: a sandbox exists before anyone has been authorized to use it, so nothing tenant-specific can be baked in at boot. Credentials, inputs and mounts arrive at hand-out or not at all.
- warm poolpre-booted, ready
- ~ms startnot cold boot
- autoscalematch demand
What the new pieces do
- Warm Pooldata
- Pre-booted sandboxes kept warm so a run starts in milliseconds instead of waiting for a cold VM boot.
Step 8 · The sharp edges
Escapes, egress & real workloads
Even with isolation and limits, the hard parts remain: sandbox escapes, controlling network egress (data exfiltration), letting real code install packages or access data it legitimately needs, and abuse.
Assume escapes are possible and defend in depth — keep hosts patched, minimize the guest, run untrusted workloads on isolated infrastructure, and monitor. Control network egress tightly: default-deny, and if code needs the internet, allowlist/proxy it — uncontrolled egress means data exfiltration. Handle package installs / data access via a controlled path (a vetted mirror, mounted read-only inputs) rather than open network. Add rate limits, quotas and monitoring to stop abuse (crypto-mining, DoS). And log/observe runs (without weakening isolation) so you can detect and respond.
Why this piece earns its place
Default-deny has a hole teams leave open for years: name resolution. A sandbox that can reach a resolver can exfiltrate through it — the data leaves encoded in the names it asks about, and no connection you blocked is ever opened. The resolver belongs inside the allowlist, and the proxy should be the only thing in the guest's world that resolves anything. Nothing about that allowlist requires terminating TLS: an explicit forward proxy reads the target off the CONNECT line and dials that name itself, and a transparent one filters on the SNI. You terminate to read the payload, which is a separate decision. The split that matters is between those two proxies — an explicit one dials the name it allowed, while a transparent filter lets the guest pick the address and only reads a name off the handshake, so a permitted name can front a destination you never allowed. Allowlisting by address closes that and stops being enforceable the moment a permitted service sits behind a CDN whose addresses are shared with everything else. A successful escape raises nothing, so your highest-value telemetry is the refusals — blocked egress, denied syscalls, kills at quota — every one of which is recorded outside the sandbox by something the code cannot reach. What the code prints is attacker-controlled input to your logging pipeline: capture it as an opaque blob, never as a line something downstream will parse.
You did it
You just designed a code execution sandbox.
Everything you assembled, in order
- Running untrusted (LLM-generated or user) code directly is arbitrary code execution on your infra — a full compromise.
- Wrap execution in a service: code in, isolated run, only captured result out — a one-way boundary.
- Isolate with hardened containers (restricted syscalls) or microVMs, least privilege, no network by default.
- Enforce resource limits (CPU/memory/time/processes/disk) and hard-kill overruns to stop hangs and fork bombs.
- Pipeline: queue → orchestrator assigns a fresh sandbox under limits → capture output → destroy.
- Make sandboxes ephemeral (fresh per run/session, destroyed) so nothing contaminates the next run.
- Keep a warm pool for millisecond starts; control egress, package/data access, and abuse — defense in depth.
