Skip to content
Security Advanced Tutorial

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.

Emeka Okafor
Emeka Okafor
Security Editor · Sep 6, 2026 · 5 min read
Sandbox Untrusted AI-Generated Code with gVisor

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. Run sudo runsc install, then sudo 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 containing unable to attach: operation not permitted) when starting containers: the runsc binary isn't readable and executable by all users. Fix with sudo 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=none and 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

  1. Installation — gvisor.dev
  2. Docker Quick Start — gvisor.dev
  3. FAQ — gvisor.dev
  4. Security Model — gvisor.dev
  5. gVisor Releases — github.com
  6. Docker Engine 29 release notes — docs.docker.com
Emeka Okafor
Written by
Emeka Okafor · Security Editor

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

Join the discussion

Sign in or create an account to comment and vote.

Maya Ito @opensource_maya · 3 hours ago

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.

Nina Petrova @night_owl_nina · 5 hours ago

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.

Related Reading