Sandbox Untrusted AI-Generated Code with gVisor
Run LLM-generated Python behind gVisor's user-space kernel with no network, no capabilities, and hard limits.
What you'll build
A local execution sandbox your agent can call to run LLM-generated Python. Snippets execute inside a Docker container whose "kernel" is gVisor's user-space sentry, so malicious code talks to an emulated Linux instead of your host, with no network, no capabilities, and hard CPU, memory, and process caps.
flowchart LR
A[Agent produces code] --> B[sandbox_run.py]
B --> C[docker run --runtime=runsc]
C --> D[gVisor sentry<br/>user-space kernel]
D -->|narrow filtered syscall set| E[Host kernel]
Prerequisites
- A Linux host, x86_64 or ARM64, kernel 5.6 or newer. gVisor intercepts Linux syscalls, so macOS and Windows are out; use a Linux box, VM, or cloud instance.
- Docker Engine managed by systemd. Verified against Docker Engine 29.8.0.
- Root or sudo access.
Commands below were verified against gVisor release-20260831.0 (August 2026) on Ubuntu 24.04. gVisor ships a new release every couple of weeks; the steps don't change between releases, only the version string.
1. Install the runsc runtime
runsc is gVisor's OCI runtime binary, a drop-in replacement for runc. On Debian or Ubuntu, install it from Google's apt repository:
sudo apt-get update && \
sudo apt-get install -y apt-transport-https ca-certificates curl gnupg
curl -fsSL https://gvisor.dev/archive.key | \
sudo gpg --dearmor -o /usr/share/keyrings/gvisor-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/gvisor-archive-keyring.gpg] https://storage.googleapis.com/gvisor/releases release main" | \
sudo tee /etc/apt/sources.list.d/gvisor.list > /dev/null
sudo apt-get update && sudo apt-get install -y runsc
On other distros, download the signed release tarball instead; the install guide has the exact commands. Confirm the binary works:
$ runsc --version
runsc version release-20260831.0
spec: 1.2.1
2. Register runsc as a Docker runtime
runsc install adds a runsc entry to the runtimes section of /etc/docker/daemon.json. Docker only reads that file at startup, so restart the daemon after:
sudo runsc install
sudo systemctl restart docker
Check that Docker sees it:
$ docker info | grep -i runtimes
Runtimes: io.containerd.runc.v2 runc runsc
Smoke test:
docker run --runtime=runsc --rm hello-world
If that prints the usual Docker greeting, every syscall it made went through gVisor's sentry, not your kernel.
3. Lock down the run command
gVisor covers the syscall layer. Everything else, network, filesystem, resources, and identity, you lock down with plain Docker flags. Run this once to confirm the full stack works and to pull the image:
docker pull python:3.13-slim
docker run --rm -i --runtime=runsc \
--network=none \
--cap-drop=ALL \
--security-opt=no-new-privileges \
--read-only --tmpfs /tmp:size=64m \
--memory=256m --cpus=0.5 --pids-limit=128 \
--user 65534:65534 \
python:3.13-slim python3 -c 'print("sandboxed")'
What each layer buys you: --network=none kills exfiltration and command-and-control outright, --cap-drop=ALL and no-new-privileges remove privilege escalation paths inside the sandbox, --read-only plus a small tmpfs means generated code can scribble in /tmp but can't persist anything, the resource caps stop fork bombs and memory balloons, and --user 65534:65534 (nobody) means even reads of root-owned files fail. None of these flags alone would justify running untrusted code. Stacked on a user-space kernel, the remaining attack surface is the narrow, seccomp-filtered set of host syscalls the sentry itself uses, rather than the hundreds a normal container can reach.
4. Wrap it in a runner your agent can call
Save this as sandbox_run.py:
import subprocess
import sys
import uuid
IMAGE = "python:3.13-slim"
TIMEOUT_SECS = 10
def run_untrusted(code: str) -> subprocess.CompletedProcess:
name = f"sbx-{uuid.uuid4().hex[:12]}"
cmd = [
"docker", "run", "--rm", "-i", "--name", name,
"--runtime=runsc",
"--network=none",
"--cap-drop=ALL",
"--security-opt=no-new-privileges",
"--read-only", "--tmpfs", "/tmp:size=64m",
"--memory=256m", "--cpus=0.5", "--pids-limit=128",
"--user", "65534:65534",
IMAGE, "python3", "-",
]
try:
return subprocess.run(cmd, input=code, text=True,
capture_output=True, timeout=TIMEOUT_SECS)
except subprocess.TimeoutExpired:
# Killing the docker CLI doesn't kill the container; do it by name.
subprocess.run(["docker", "kill", name], capture_output=True)
raise
if __name__ == "__main__":
result = run_untrusted(sys.stdin.read())
print(result.stdout, end="")
print(result.stderr, file=sys.stderr, end="")
sys.exit(result.returncode)
Two deliberate choices here. The code goes to python3 - over stdin, which keeps it out of argv (visible to anyone running ps) and out of shell-quoting trouble. And the timeout handler kills the container by name, because subprocess only kills the local docker client while the container keeps running on the daemon.
From your agent, import run_untrusted() and hand back stdout, stderr, and returncode to the model.
Verify it works
Feed the runner the kind of code you're actually worried about:
cat <<'EOF' | python3 sandbox_run.py
import os, urllib.request
print("kernel:", os.uname().release)
try:
open("/etc/shadow").read()
except OSError as e:
print("shadow read blocked:", e)
try:
urllib.request.urlopen("https://example.com", timeout=3)
except Exception as e:
print("network blocked:", type(e).__name__)
EOF
Expected output:
kernel: 4.19.0-gvisor
shadow read blocked: [Errno 13] Permission denied: '/etc/shadow'
network blocked: URLError
That first line is the proof. 4.19.0-gvisor is the release string gVisor's sentry advertises through its emulated uname(2); your host kernel is something else entirely (uname -r on the host to compare). The code never spoke to the real kernel. A container escape now requires a gVisor sandbox escape and a host kernel exploit, chained.
Troubleshooting
docker: Error response from daemon: unknown or invalid runtime name: runsc: Docker doesn't know about the runtime. Runsudo runsc install, thensudo systemctl restart docker. The restart is the step people skip.flag provided but not defined: -console: your Docker Engine predates the current OCI runtime interface. Upgrade Docker to a current version.fork/exec /proc/self/exe: invalid argument(or a runsc panic containingunable to attach: operation not permitted) when starting containers: therunscbinary isn't readable and executable by all users. Fix withsudo chmod a+rx $(which runsc).bad address 'somehost'if you later enable networking: Docker's embedded DNS for user-defined bridges listens on the host loopback, which gVisor's network stack isolates. Use the default bridge, connect by IP, or better, keep--network=noneand pass data in through stdin.
Next steps
Batch-shaped workloads fit this runner as-is; for a persistent service, cap captured output size (a hostile snippet can print gigabytes) and queue executions. On bare metal or nested-virt-enabled VMs, try gVisor's KVM platform instead of the default Systrap; the platforms guide covers the tradeoffs, and the production guide covers tuning at scale. Running agents on Kubernetes? gVisor plugs into containerd as a RuntimeClass, so pods opt in per-workload. And before you trust the sandbox with anything hotter, read gVisor's security model to understand exactly what it does and doesn't defend against.
Sources & further reading
- Installation — gvisor.dev
- Docker Quick Start — gvisor.dev
- FAQ — gvisor.dev
- Security Model — gvisor.dev
- gVisor Releases — github.com
- Docker Engine 29 release notes — docs.docker.com
Emeka has spent over a decade tracking threat actors, vulnerability disclosures, and the evolving landscape of application security, bringing a sharp continent-spanning perspective to his reporting. He's known for translating dense CVE advisories into clear, actionable context that developers and security teams alike actually read.
Discussion 2
the syscall filtering is what matters here—ran into this exact problem last year with a code execution feature in an OSS project and ended up going down the seccomp rabbit hole. gvisor's appeal is you get that filtering + resource limits without hand-rolling a syscall allowlist, which is where most projects get sloppy. having the sentry handle it means fewer places for things to slip through.
finally, a practical way to not get pwned by your own ai code. gvisor overhead is real but beats the alternative of ransomware in prod at 3am.