Running LLM-generated Python safely means executing code nobody reviewed inside a boundary that keeps it away from your host, your credentials and your other users, with hard limits on what it can reach, how long it runs and how much memory it takes.
An analytics agent gets a question about last quarter’s churn, writes forty lines of pandas, and hands them back to be run. It was written by a model that read a CSV uploaded by a customer, and that CSV can carry instructions as easily as data. Whatever runs the code decides what a bad line can touch.
Calling exec() in the web process means the answer is everything: the database URL in the environment, the cloud credentials on disk, the network behind the firewall. The rest of this guide is about making the answer one disposable machine with nothing in it worth stealing.
What Is an LLM Code Sandbox?
An LLM code sandbox is an isolated execution environment that receives model-written code, runs it, and returns the output, while denying that code any route to the system that called it.
A useful sandbox has three parts. The isolation boundary decides what the code can touch at all: a filtered interpreter, a container, a user-space kernel or a separate virtual machine. The limits decide how much it can consume and where it can connect, inside that boundary. The lifecycle decides what survives a run: a fresh environment per execution, one per user, or one per agent sandbox that the agent works in for days.
Most failures come from getting one of the three wrong while the other two look fine. A strong VM with open egress still lets code send your data out.
Why Is Running LLM-Generated Code With exec() Dangerous?
exec() runs the code with every permission your process has, and Python’s own documentation warns that calling it with untrusted input will lead to security vulnerabilities.[1] The model controls the code, and whoever controls the model’s context controls the model: a user’s prompt, a web page the agent fetched, a document it summarized.
From inside your process, that code can read os.environ and post your API keys anywhere it can reach. It can open files your service can open, start subprocesses, and connect to internal services that trust traffic from your network.
The tempting fix is a filter: strip import os, block open, run with empty globals. RestrictedPython, the best-known tool in this space, states plainly that it is not a sandbox system or a secured environment.[2] A filter has to anticipate every path through a language built for introspection, and the code it filters only has to find one. Pyodide, a port of CPython to WebAssembly for the browser and Node.js,[3] changes the runtime rather than solving the server’s problem. The boundary has to sit outside the interpreter, where the code cannot reach it.
Is a Docker Container Enough? Containers vs gVisor vs MicroVMs
A container is better than exec() and weaker than a VM, because the code still talks directly to your host’s kernel. Docker isolates a container with kernel namespaces and control groups,[4] so every system call the generated code makes lands on the same kernel your other workloads use, and a kernel bug reachable from a syscall is a way out.
gVisor narrows that path. It is an application kernel, written in Go and running in user space, that implements the Linux interface for each sandbox so the host kernel’s interface is not exposed directly, specifically to reduce the risk of a container escape.[5] A Firecracker microVM goes further: each sandbox gets its own guest kernel, isolated by KVM hardware virtualization, with a device model cut down to five emulated devices.[6] The detailed tradeoffs are in Firecracker vs gVisor and microVM vs container.
| Boundary | Kernel the code runs against | What separates it from your host | What an escape needs |
|---|---|---|---|
exec() in your process |
Your host’s | Nothing | Nothing: it is already in |
| Container (runc) | Your host’s | Namespaces and cgroups | One host kernel bug reachable by syscall |
| gVisor (runsc) | gVisor’s user-space kernel | A per-sandbox application kernel | A gVisor flaw, then a path to the host |
| Firecracker microVM | Its own guest kernel | KVM hardware virtualization | A guest kernel flaw, then a hypervisor or VMM flaw |
Sources: Docker[4], gVisor[5], Firecracker[6]. Checked 2026-10-06.
The honest cost of a VM is that it boots a kernel. For code a model wrote on behalf of a stranger, that cost buys the boundary with the fewest shared parts.
What Limits Should You Set: Network, Timeouts and Memory?
Set four limits on every run: outbound network, wall-clock time, CPU time and memory. Each one has to be enforced from outside the code’s reach, or the code can lift it.
Network: deny by default
Outbound network is how a successful attack becomes a data leak, so start from deny-all and add only the domains the code needs, such as a package index. Domain-based rules have to be backed by packet filtering, otherwise code that dials an IP address directly walks around a DNS-only block.
Time: wall clock and CPU
A wall-clock limit stops code that sleeps, waits on a socket or loops forever. timeout starts a command and kills it if it is still running after the duration you give it.[7] A CPU limit catches code that burns cycles fast inside a short window: under RLIMIT_CPU, the kernel signals the process when it reaches the soft limit and terminates it at the hard limit.[8] Use both, because each misses what the other catches.
Memory and OOM behaviour
A memory cap turns “the host ran out of RAM” into “this run failed”. With a cgroup v2 memory.max limit, the kernel invokes the OOM killer inside that cgroup once usage hits the limit and cannot be reclaimed.[9] The generated code dies; the machine and everything outside the cgroup keep running.
One Sandbox Per Execution, Per User or Per Agent?
Use one sandbox per execution when runs are unrelated, one per user when a user’s work builds on itself, and never share one between users.
A sandbox per execution gives every run a clean machine, so a poisoned file or a leftover background process cannot outlive the run that created it. The price is a create step on every call. A sandbox per user keeps installed packages, uploaded files and intermediate results between runs, which is what an analysis session needs, and still keeps one customer’s data away from another’s.
Hugging Face’s smolagents documentation draws the other line that matters: sandbox only the generated snippets and keep the agent on your server, or run the whole agentic system inside the sandbox.[10] The first keeps model API keys outside; the second contains the agent loop as well. The tradeoffs of keeping state are covered in ephemeral vs persistent sandboxes.
Coming From E2B’s Code Interpreter?
Each E2B Code Interpreter call maps to a sprites-py call except the sandbox timeout. E2B creates a sandbox and runs a code string in it with run_code;[11] with Sprites you write the code to a file in the Sprite and run it inside the timeout and CPU wrappers from the previous section.
| E2B (Python) | Sprites (sprites-py) |
|---|---|
Sandbox.create() |
client.create_sprite(name) |
sbx.run_code(code) |
(sprite.filesystem("/tmp") / "main.py").write_text(code), then sprite.run("python3", "/tmp/main.py", capture_output=True) |
sbx.commands.run("ls -l") |
sprite.run("bash", "-c", "ls -l", capture_output=True) |
sbx.files.write(path, data) / sbx.files.read(path) |
(sprite.filesystem("/") / path).write_text(data) / .read_text() |
Sandbox.create(allow_internet_access=False) or network={"deny_out": ..., "allow_out": [...]} |
sprite.update_network_policy(NetworkPolicy(rules=[...])) |
Sandbox.create(timeout=60), sbx.set_timeout(30) |
No sandbox timeout: the Sprite lives until destroy(), and sleeps when idle |
sbx.kill() |
sprite.destroy() |
Sources: E2B quickstart[11], lifecycle[12], internet access[13], files[14], commands[15]. Checked 2026-10-06.
Two differences change the code around the call. E2B’s sandbox timeout acts on a clock, pausing or shutting down the sandbox when it expires,[12] while a Sprite persists until you destroy it, so per-run time limits belong around the command. E2B sets network rules as the sandbox is created;[13] a Sprite’s policy is set from outside on a running Sprite and reloads live. A side-by-side of the two platforms is in Fly.io vs E2B.
Running LLM-Generated Python Safely on Fly.io
On Fly.io, each execution gets a Sprite: a Firecracker microVM with its own kernel, created by API, limited from outside, and destroyed when the run ends. This is the whole pattern in Python with sprites-py:
import os
import httpx
from sprites import NetworkPolicy, PolicyRule, SpritesClient
TOKEN = os.environ["SPRITES_API_TOKEN"]
client = SpritesClient(token=TOKEN)
def run_untrusted(user_id: str, code: str, allow: tuple[str, ...] = ()) -> str:
sprite = client.create_sprite(f"run-{user_id}-{os.urandom(4).hex()}")
try:
# Deny all egress except the domains this run needs.
rules = [PolicyRule(domain=d, action="allow") for d in allow]
sprite.update_network_policy(NetworkPolicy(rules=rules + [PolicyRule(domain="*", action="deny")]))
# Cap the workload at 512 MB; past it, the OOM killer stops the code.
httpx.post(f"https://api.sprites.dev/v1/sprites/{sprite.name}/policy/resources",
headers={"Authorization": f"Bearer {TOKEN}"},
json={"memory": {"limit_mb": 512}}).raise_for_status()
(sprite.filesystem("/tmp") / "main.py").write_text(code)
# 10 seconds of CPU and 30 seconds of wall clock, enforced inside the Sprite.
result = sprite.run("bash", "-c", "ulimit -t 10; timeout -s KILL 30 python3 /tmp/main.py",
capture_output=True, timeout=60)
return result.stdout.decode() if result.returncode == 0 else result.stderr.decode()
finally:
sprite.destroy()
The network policy is enforced outside the code: denied domains get a DNS REFUSED, raw IP connections are blocked unless they came from an allowed name, private ranges are always blocked, and code inside the Sprite can read its policy but cannot change it. Egress is open by default, which is why the function sets the policy before it writes a byte of code. The memory cap goes through the Sprite’s resources policy, and the finally block means no user’s code outlives its run.
Per-user isolation comes from the name: every run gets its own Sprite, so one customer’s files and processes never meet another’s. For sessions where state should carry over, keep one Sprite per user instead of destroying it; it sleeps when idle and wakes on the next command with its filesystem intact.
Frequently Asked Questions
Can Python be sandboxed in the same process with RestrictedPython or Pyodide?
Not safely for untrusted code. RestrictedPython describes itself as not a sandbox system or a secured environment, and an in-process filter shares memory, file handles and environment variables with your application. Pyodide moves Python into a WebAssembly runtime in the browser or Node.js rather than giving a server a boundary. For production, run model-written Python in a separate VM; on Fly.io that is a Sprite, a Firecracker microVM with its own kernel.
How do I pass a pandas DataFrame or files into the sandbox?
Serialize the data to a file, write it into the sandbox, and have the generated code read it. Parquet or CSV both work, and results come back the same way. With Sprites on Fly.io, the Python SDK writes and reads files in the Sprite through its filesystem API, so the DataFrame never travels through the code string itself.
Should the agent itself run inside the sandbox?
It depends on what the agent holds. Sandboxing only the generated snippets keeps model API keys on your server, while running the whole agent in the sandbox contains the agent loop too and moves those keys inside. On Fly.io, either works with Sprites; for the second, Connectors let a Sprite call a provider without the key ever entering the Sprite.
How do I give an LLM agent its own Linux VM to run shell commands in?
Create a VM per agent and send it commands over an API rather than giving the agent a shell on your host. On Fly.io, a Sprite is that VM: a persistent Linux computer with its own kernel, created with one call and driven through a CLI, a REST API, an MCP server or SDKs for Python, JavaScript, Go and Elixir.
Is it safe to run LLM-generated code on my own machine?
No, not without isolation. Generated code runs with your user’s permissions, so it can read your SSH keys, cloud credentials and every repository you can reach. Run it in a VM you can throw away instead; on Fly.io, that is a Sprite you create for the job and destroy afterwards.
What happens when sandboxed code runs out of memory?
With a cgroup memory limit in place, the kernel’s OOM killer stops the code inside that cgroup once usage reaches the limit and cannot be reclaimed. On Fly.io, a Sprite’s resources policy sets that limit for the Sprite’s workload, so an out-of-memory Python process dies inside its own Sprite and your application sees a failed run, not an outage.
Can the sandbox install pip packages?
Yes, if its network policy allows the package index. A deny-all policy blocks pip along with everything else. On Fly.io, a Sprite’s network policy can include the defaults preset, which covers PyPI and other package registries, or list only the domains the code needs.
How long does a sandbox take to start?
It depends on the isolation layer: a process starts fastest, a container next, and a VM has a kernel to boot. On Fly.io, a Sprite created for each execution adds that create step to every run, so reuse one Sprite per user when the same user runs code repeatedly and the filesystem can safely carry over.
Sources
- ^ Python Software Foundation, “Built-in Functions: exec()”. docs.python.org/3/library/functions.html. Checked 2026-10-06.
- ^ RestrictedPython, “RestrictedPython documentation”. restrictedpython.readthedocs.io/en/latest. Checked 2026-10-06.
- ^ Pyodide, “Pyodide”. pyodide.org/en/stable. Checked 2026-10-06.
- ^ Docker, “Docker Engine security”. docs.docker.com/engine/security. Checked 2026-10-06.
- ^ gVisor, “What is gVisor?”. gvisor.dev/docs. Checked 2026-10-06.
- ^ Firecracker, “Firecracker”. firecracker-microvm.github.io. Checked 2026-10-06.
- ^ Linux man-pages, “timeout(1)”. man7.org/linux/man-pages/man1/timeout.1.html. Checked 2026-10-06.
- ^ Linux man-pages, “getrlimit(2): RLIMIT_CPU”. man7.org/linux/man-pages/man2/getrlimit.2.html. Checked 2026-10-06.
- ^ The Linux kernel, “Control Group v2: memory.max”. docs.kernel.org/admin-guide/cgroup-v2.html. Checked 2026-10-06.
- ^ Hugging Face, “smolagents: Secure code execution”. huggingface.co/docs/smolagents/tutorials/secure_code_execution. Checked 2026-10-06.
- ^ E2B, “Running your first Sandbox”. docs.e2b.dev/quickstart. Checked 2026-10-06.
- ^ E2B, “Sandbox lifecycle”. docs.e2b.dev/sandbox. Checked 2026-10-06.
- ^ E2B, “Internet access”. docs.e2b.dev/network/internet-access. Checked 2026-10-06.
- ^ E2B, “Read and write files”. docs.e2b.dev/filesystem/read-write. Checked 2026-10-06.
- ^ E2B, “Running commands in sandbox”. docs.e2b.dev/commands. Checked 2026-10-06.