Running "untrusted Python" means executing code you did not write, a user's formula, an AI agent's generated script, or a plugin, without letting it read your files, open network connections, or seize the machine. It matters because Python was designed for trusted developers, so the interpreter offers no security boundary and the isolation has to be built deliberately around it.
Key takeaways
The classic exec() with stripped builtins is not a sandbox: Python's introspection lets any object walk up to a class that still holds import.
RestrictedPython's own maintainers state it "is not a sandbox system or a secured environment": it stops accidents and casual mischief, not a determined attacker.
Pyodide gives strong isolation for pure computation in WebAssembly, but its Python-to-JavaScript bridge has been the route of real production escapes.
Docker with a seccomp profile and a read-only root filesystem suits broadly trusted code; adversarial code needs gVisor or a microVM because containers share the host kernel.
Pick the boundary from the threat model, and never rely on interpreter-level tricks as the only layer.
Every few months a team learns this the expensive way: a "run user code" feature ships with exec() and a trimmed builtins dict, a researcher spends an afternoon on it, and the postmortem reads like a magic trick. Python looks sandboxable because you can see the dangerous names. The trap is that those names are never the whole attack surface.
Why can't you just use exec() with restricted builtins?
The naive sandbox looks like this:
exec(user_code, {"builtins": {}})
Emptying builtins feels thorough. It is not. Python's introspection gives every object a ladder back out: ().class reaches tuple, .base reaches object, and .subclasses() lists every loaded class (opencomputer.dev). From there the code picks a class whose module kept normal builtins, reaches through init.globals to grab import, imports os, and calls system, all without naming anything your filter blocked.
Here is the uncomfortable fact stated plainly: CPython was never designed to run hostile code inside its own process. Audit hooks and resource limits each close one hole, but the introspection surface is too large to close hole by hole (opencomputer.dev). Any defence that lives inside the interpreter is playing whack-a-mole against the language's own object model.
So the security boundary has to move somewhere the interpreter cannot reach: out to the operating system, the browser, or the hardware. That is what the three serious options do, each at a different layer.
What does RestrictedPython actually guarantee?
RestrictedPython, maintained by the Zope Foundation, compiles a restricted subset of Python by rewriting the abstract syntax tree at compile time. Attribute access to names starting with underscores is blocked, dangerous builtins are removed from the namespace your code sees, and imports go through a guard you control. The official example shows both sides:
from RestrictedPython import compile_restricted
from RestrictedPython import safe_globals
Harmless code runs fine
source = """
def example():
return 'Hello World!'
"""
byte_code = compile_restricted(source, '', 'exec')
namespace = {}
exec(byte_code, safe_globals, namespace)
print(namespace'example') # 'Hello World!'
Hostile code is stopped at the import
evil = """
import os
os.listdir('/')
"""
byte_code = compile_restricted(evil, '', 'exec')
exec(byte_code, safe_globals, {})
ImportError: import not found
In production you layer more on top: a custom getattr guard and a vetted import hook that only allows the modules your use case needs. It powers formula fields and scripted templates in systems like Plone, where semi-trusted users extend an application the operator controls.
Now the honest part, in the maintainers' own words: RestrictedPython "is not a sandbox system or a secured environment, but it helps to define a trusted environment and execute untrusted code inside of it" (PyPI). It stops accidents and casual mischief: a user who writes a bad formula gets an error, not your filesystem. It was never designed to hold a hostile expression, and treating it as a security boundary against untrusted code is a misuse. Against an adversary, it is at most one layer, never the wall.
Does Pyodide provide real isolation?
Pyodide is CPython compiled to WebAssembly, running in the browser or Node.js. For pure computation the isolation is genuinely strong: WebAssembly modules have no ambient access to the host and receive only the capabilities you grant them.
The catch is the foreign function interface between Python and JavaScript, which is the whole point of embedding Pyodide in a page. Through the js module, Python code can reach JavaScript objects, and by default it sees the host runtime's global scope. One real escape (blog.barrack.ai, CERT VU#414811) is instructive: the sandbox handed Python a mock document built as a plain object literal, and because every such object inherits from Object.prototype, the payload walked .constructor.constructor to the Function constructor, evaluated "return globalThis", and landed in the real global scope. In Node.js that means process, require, and child_process.
This was not a one-off. Researchers disclosed CVE-2025-68668 against n8n's Pyodide-backed code node: n8n blocked risky functions by patching names, but two bypasses worked anyway, one using ctypes to call libc's system() directly, the other using _pyodide._base.eval_code() where the patches did not apply (cyera.com). The proof of concept was two lines. Earlier, Grist disclosed a CVSS 9.1 escape in its Pyodide formula engine (GHSA-7xvx-8pf2-pv5g), fixed in Grist 1.7.9 by running Pyodide under Deno: a real process boundary outside the interpreter.
The pattern is consistent: Pyodide has never claimed to be a security boundary, and the escapes happen at the bridge the embedder built, not in WebAssembly itself. For a browser playground, it is an excellent zero-server-cost choice. On a server, treat the JavaScript globals configuration as the actual security surface: anything reachable from Python is reachable by the attacker.
// Pin a version you have tested; the snippet below is illustrative
const pyodide = await loadPyodide();
// Runs in the visitor's browser: no server, no bill, no shared state
const result = await pyodide.runPythonAsync();
def fib(n):
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a
fib(30)
console.log(result); // 832040
Is Docker enough for hostile code?
Containers move the boundary to the operating system: Linux namespaces and cgroups give the code its own filesystem view, process tree, network stack, and resource limits. For code you broadly trust, Docker with a hardened configuration is the workhorse answer:
docker run --rm \
--cap-drop=ALL \
--security-opt=no-new-privileges \
--security-opt seccomp=default.json \
--read-only --tmpfs /tmp:rw,noexec,nosuid \
--network none \
--memory 256m --cpus 0.5 \
--pids-limit 64 \
--user 1000:1000 \
python:3.13-slim \
python /app/user_code.py
Each flag closes a real door: no Linux capabilities, no privilege escalation through setuid binaries, a seccomp filter on syscalls, a read-only root filesystem, no network, and hard caps on memory, CPU, and processes so a fork bomb is a billing event rather than an outage.
The caveat is architectural: containers share the host kernel, so a kernel-level exploit escapes the container. For code you broadly trust, that risk is acceptable with a seccomp profile and a read-only root filesystem. For adversarial code, the boundary must move again: gVisor interposes a user-space kernel that intercepts syscalls, and microVMs such as Firecracker add a hypervisor, so an escape must defeat virtualisation rather than a single kernel bug (opencomputer.dev).
Production systems rarely bet on one layer. Cohere's Python interpreter documents a defence-in-depth stack: untrusted code runs in a WebAssembly sandbox, inside a Node.js process, inside a Docker container, inside cloud infrastructure, with network egress and the filesystem locked down at every level (Cohere North security docs). No single layer is trusted.
Which one should you use?
Start from the threat model, not the tool. The question is never "which sandbox is best" but "who is writing the code, and what happens if they turn malicious?"
Semi-trusted users extending your app (formulas, templates, workflow expressions): RestrictedPython fits: friendly errors instead of breaches, and fast in-process code your test suite can exercise. Still run the whole application in a container, since RestrictedPython is the guardrail, not the wall.
Public playgrounds and tutorials: Pyodide in the browser is hard to beat. Execution happens on the visitor's machine, so there is no server to attack and no compute bill. Keep the JavaScript bridge minimal and never expose Node.js globals server-side.
Customer or agent-generated code on your servers that you broadly trust: Docker with the hardening flags above, plus timeouts and output limits. This is the standard answer for a reason.
Adversarial or multi-tenant code: containers alone are not enough. Use gVisor or a microVM per execution, and treat the container image as untrusted input too.
Much of what remains unknown is operational: sandbox overhead depends on your workload, and public benchmarks measure different things, so treat vendor numbers as directional. What is settled is the principle every story here converges on: CPython cannot sandbox itself, so the boundary must live outside the interpreter. Choose the layer that matches your adversary, stack more than one, and test the configuration the way an attacker would.
Originally published on LogicStack. Run the code examples right in your browser with our free interactive Python and SQL playgrounds — no setup, no sign-up.
Top comments (0)