Sandboxing LLM-Generated Code: How to Let Agents Execute Untrusted Output Safely
Your agent's code interpreter tool is the single riskiest line in your stack. Here's how to isolate it properly, with real Docker and gVisor configs, not just 'run it in a container' hand-waving.
If your agent has a "code interpreter" or "run_python" tool, you have shipped a remote code execution vector, and the payload author is a language model that hallucinates, gets prompt-injected, and occasionally decides os.system("rm -rf /") is a reasonable way to "clean up temp files." This isn't hypothetical caution. It's the actual threat model every team building coding agents, data-analysis copilots, or agentic RAG pipelines has to design for in 2026.
Most teams get this wrong in one of two directions. Either they skip isolation entirely because "it's just a demo," and it ships to production anyway. Or they wrap subprocess.run() in a try/except and call it a sandbox, which stops nothing โ a process can still see the host filesystem, hit the internal network, and read environment variables with your API keys.
This post is about doing it properly: the actual isolation primitives, resource limits, network policy, and a reference implementation you can adapt today.
Why "just use a container" isn't the whole answer
Docker containers share the host kernel. A container escape isn't science fiction โ it's a documented, exploitable category of vulnerability (CVE-2024-21626, runc breakouts, and others). For LLM-generated code specifically, the threat model is worse than normal untrusted code because:
- The attacker is adversarial by proxy. Prompt injection from a webpage, a PDF, or a poisoned tool response can steer the model into writing malicious code without the end user ever typing anything suspicious.
- The code is novel every time. Static allowlisting of "safe" functions doesn't work when the model can write
getattr(__builtins__, chr(101)+chr(118)+chr(97)+chr(108))to dodge a string-matching filter. - The blast radius includes your own credentials. If the sandbox process shares environment variables or a network namespace with your app, "sandboxed" code can exfiltrate your OpenAI key or hit your internal database.
The rule to internalize: treat every LLM code-execution tool call as untrusted input from the internet, even if the prompt came from an authenticated internal user. The model is the untrusted intermediary.
The architecture pattern that actually works
Split your system into two trust zones:
- Control plane: your app server, the LLM API calls, orchestration logic, conversation state. Normal infrastructure, normal trust level.
- Execution plane: a fully isolated sandbox that runs generated code, with no ambient authority โ no shared credentials, no shared network, no shared filesystem beyond an explicit allowlist.
The control plane sends code + a strict resource budget to the execution plane, gets back stdout/stderr/return value, and the sandbox is torn down completely afterward. No sandbox instance should survive to serve two different requests.
โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโ
โ Control Plane โ code โ Execution Plane โ
โ (LLM calls, โ โโโโโโโบ โ (gVisor/Firecracker โ
โ orchestration, โ โโโโโโโ โ microVM, no creds, โ
โ your app DB) โ result โ no host network) โ
โโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโ
Isolation tiers, ranked by what they actually protect against
| Approach | Isolation strength | Startup latency | Use when |
|---|---|---|---|
Bare subprocess / exec() |
None โ same kernel, same filesystem | ~0ms | Never, for untrusted input |
| Docker container (default) | Weak โ shared kernel, namespace-based only | ~200-500ms | Trusted internal tooling only |
gVisor (runsc) |
Strong โ userspace kernel intercepts syscalls | ~150-300ms | Most agent code-exec use cases |
| Kata Containers | Strong โ lightweight VM per container | ~300-800ms | Regulated environments, need VM-grade isolation with container UX |
| Firecracker microVM | Strongest โ hardware-virtualized, minimal attack surface | ~125ms cold start | High-scale multi-tenant (this is what AWS Lambda and Modal use) |
| WASM (Wasmtime/Wasmer) | Strong for pure compute, weak for syscalls | ~1-5ms | Untrusted numeric/data-transform code with no I/O needs |
For most teams building an agent product, gVisor is the pragmatic default: it drops into your existing Docker/Kubernetes workflow (it's a container runtime, runsc, that swaps in for runc), intercepts syscalls in userspace instead of trusting the host kernel, and doesn't require you to rearchitect around microVMs. Reach for Firecracker or a managed platform (Modal, E2B, Daytona) when you need high-density multi-tenant execution at scale.
Reference implementation: gVisor + strict resource limits
Here's a minimal but production-shaped setup. First, the Docker runtime config to enable gVisor:
// /etc/docker/daemon.json
{
"runtimes": {
"runsc": {
"path": "/usr/bin/runsc"
}
}
}
sudo systemctl restart docker
Now the actual execution wrapper. Note what's explicit here: no network, hard memory/CPU/pids caps, no volume mounts beyond a scratch dir, and a wall-clock timeout enforced from outside the container (never trust a timeout implemented inside the sandboxed process itself).
import subprocess
import tempfile
import shutil
import uuid
import os
def run_untrusted_code(code: str, timeout_seconds: int = 10) -> dict:
work_dir = tempfile.mkdtemp(prefix="agent-exec-")
script_path = os.path.join(work_dir, "snippet.py")
with open(script_path, "w") as f:
f.write(code)
container_name = f"exec-{uuid.uuid4().hex[:12]}"
cmd = [
"docker", "run",
"--rm",
"--name", container_name,
"--runtime=runsc", # gVisor, not the default runc
"--network=none", # no network namespace at all
"--memory=256m",
"--memory-swap=256m", # disable swap-based memory bypass
"--cpus=0.5",
"--pids-limit=64",
"--read-only", # root filesystem is read-only
"--tmpfs", "/tmp:size=32m", # writable scratch, size-capped, in-memory
"--security-opt=no-new-privileges",
"--cap-drop=ALL",
"--user", "nobody",
"-v", f"{script_path}:/sandbox/snippet.py:ro",
"python:3.12-slim",
"python", "/sandbox/snippet.py",
]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout_seconds, # enforced by the parent process, not the sandbox
)
return {
"stdout": result.stdout[:10_000], # cap output size too
"stderr": result.stderr[:10_000],
"exit_code": result.returncode,
"timed_out": False,
}
except subprocess.TimeoutExpired:
subprocess.run(["docker", "kill", container_name], capture_output=True)
return {"stdout": "", "stderr": "execution timed out", "exit_code": -1, "timed_out": True}
finally:
shutil.rmtree(work_dir, ignore_errors=True)
Three details that matter and are easy to skip:
--network=noneisn't optional. If the model's code needs to fetch a URL, that should be a separate, explicitly-allowlisted tool call your control plane makes on the code's behalf โ never direct network access from inside the sandbox.- The timeout is enforced by the parent process, via
subprocess.run(timeout=...), not by asignal.alarm()inside the sandboxed script. Code you don't trust can trivially catch or ignore its own timeout handler. --memory-swapequal to--memorydisables swap, which otherwise lets a process exceed its memory cap by paging to disk.
The confused deputy problem
Even with perfect isolation, there's a subtler failure mode: your agent has legitimate permissions (read this S3 bucket, call this internal API), and a prompt-injected instruction can trick it into using those permissions for something the user never asked for. Sandboxing the code-exec tool doesn't fix this, because the danger isn't the sandbox escaping โ it's the agent, acting exactly as designed, doing something it shouldn't have been told to do.
The mitigation is capability scoping at the tool layer, not the sandbox layer: every tool the agent can call should carry the minimum permission needed for that specific call, scoped per-request, not a standing credential the agent can direct at will. If your agent has one API key that can read and write the entire production database, no sandbox saves you.
Common pitfalls
- Trusting
--network=nonealone without also dropping capabilities. A container withCAP_NET_ADMINcan sometimes reconfigure its own namespace. Always pair network isolation with--cap-drop=ALLand add back only what's needed. - Reusing a "warm" sandbox across requests to save startup latency. This is the most common way state (files, env vars, cached credentials) leaks between one user's session and another's. Tear down and rebuild every time, or use a pool of pre-warmed but state-scrubbed instances.
- Letting stdout/stderr be unbounded. A
while True: print("x" * 10**6)will happily OOM your logging pipeline if you don't cap captured output size. - Forgetting the image itself is an attack surface.
python:3.12-slimstill shipscurl,pip, and a filesystem the code can read from within its own container. If code doesn't need pip access, build a minimal image without it. - No audit trail. Log every code snippet executed, its output, and its resource usage. When something goes wrong (and eventually it will), this is the only way to reconstruct what the model was trying to do.
Next steps
If you're building this from scratch, don't reinvent the wheel: E2B, Modal Sandboxes, and Daytona all provide managed microVM-backed execution with SDKs that handle the plumbing above. Reach for a self-hosted gVisor setup when you need full control over cost, data residency, or air-gapped deployment. Either way, the design principle is the same: the execution plane gets zero ambient authority, every credential is explicit and scoped, and nothing survives past a single request.
If you're building agent systems and want to go deeper on tool design, capability scoping, and the failure modes that show up once these systems hit real users, that's exactly what we cover hands-on in the Joinloop AI engineering cohort.
Want to build this yourself?
Join a live cohort and go from reading about AI to shipping it, with practitioners who do it daily.