Skip to content
Security Advanced Tutorial

Sandbox LLM-Generated Code with gVisor and Docker

Run untrusted agent-generated code behind gVisor's user-space kernel so hostile scripts never touch your host kernel.

Ji-ho Choi
Ji-ho Choi
Security & Cloud Editor · Sep 9, 2026 · 5 min read
Sandbox LLM-Generated Code with gVisor and Docker

What you'll build

An execution path for code your LLM agent writes: a Docker container that runs under gVisor's runsc runtime instead of the default runc, so untrusted Python is serviced by a user-space kernel and never talks to your host kernel directly. By the end you'll run a generated script under gVisor with network, capabilities, and filesystem writes stripped away, and you'll be able to prove the sandbox is active.

The threat model in one sentence: a normal container shares your host kernel, so hostile agent output is one kernel exploit away from the host, while gVisor's Sentry intercepts every syscall the workload makes and forwards only a small, seccomp-filtered set to the real kernel.

Prerequisites

  • A Linux host on x86_64 or ARM64 with kernel 5.6 or newer. Bare metal or a cloud VM both work. Docker Desktop on macOS or Windows does not; you can't register custom runtimes in its managed VM, so use a Linux box.
  • Docker Engine installed and managed by systemd. The --runtime flag has been stable for years, so any recent version works.
  • Root or sudo access.
  • Verified against gVisor release 20260831.0 (September 2026) on Ubuntu 24.04. The install commands below are for Debian/Ubuntu; the manual binary route in step 1 covers everything else.

Step 1: Install gVisor

On Debian or Ubuntu, add Google's apt repository and install runsc:

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 any other distro, grab the latest release binary bundle directly:

(
  set -e
  ARCH=$(uname -m)
  URL=https://storage.googleapis.com/gvisor/releases/release/latest/${ARCH}
  wget ${URL}/gvisor.tar.zstd ${URL}/gvisor.tar.zstd.sha512
  sha512sum -c gvisor.tar.zstd.sha512
  sudo tar --zstd -xf gvisor.tar.zstd -C /usr/local/bin
  rm -f gvisor.tar.zstd gvisor.tar.zstd.sha512
)

Step 2: Register runsc with Docker

runsc ships a helper that writes the runtime entry into /etc/docker/daemon.json for you:

sudo runsc install
sudo systemctl restart docker

Your daemon.json now contains a runtimes entry pointing at the binary (/usr/bin/runsc for the apt install, /usr/local/bin/runsc for the manual one). Confirm Docker picked it up:

docker info | grep -i runtimes

You should see runsc listed alongside runc. The default runtime is still runc, which is what you want: only the untrusted workloads opt in.

Step 3: Build the runner image

Keep the image minimal and run as a non-root user, because gVisor protects the host kernel, not the container's own filesystem. Create a Dockerfile:

FROM python:3.13-slim
RUN useradd --create-home runner
USER runner
WORKDIR /home/runner

Build it:

docker build -t llm-sandbox .

Step 4: Run generated code under the sandbox

Simulate agent output. In practice your agent framework writes this file; here we fake it with something that reports where it's running:

mkdir -p untrusted
cat > untrusted/agent_code.py <<'EOF'
import platform, sys
print("python", sys.version.split()[0])
print("kernel", platform.release())
print("result", sum(i * i for i in range(10)))
EOF

Now execute it under runsc with everything else locked down too. gVisor handles kernel isolation; the remaining flags are defense in depth you'd want even under runc:

docker run --rm \
  --runtime=runsc \
  --network=none \
  --cap-drop=ALL \
  --security-opt=no-new-privileges \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  --memory=256m --cpus=1 --pids-limit=128 \
  -v "$PWD/untrusted:/home/runner/job:ro" \
  llm-sandbox \
  python /home/runner/job/agent_code.py

What each layer buys you:

  • --runtime=runsc: syscalls hit gVisor's Sentry, not your host kernel.
  • --network=none: generated code can't exfiltrate data or pull payloads.
  • --cap-drop=ALL and --security-opt=no-new-privileges: no capabilities, and no way to gain any via setuid binaries.
  • --read-only plus a small noexec tmpfs: the script gets scratch space but can't persist anything or drop executables.
  • --memory, --cpus, --pids-limit: an infinite loop or fork bomb burns its own budget, not your machine.
  • The code mounts in read-only; results come back on stdout.

Verify it works

First, prove the sandbox is real. gVisor boots its own tiny kernel, and its dmesg output is unmistakable:

docker run --runtime=runsc --rm llm-sandbox dmesg

Expected output (the middle lines are randomized jokes; the first and last are what matter):

[    0.000000] Starting gVisor...
...
[    2.613217] Ready!

Second, compare kernel identities with and without gVisor:

docker run --rm llm-sandbox uname -r
docker run --rm --runtime=runsc llm-sandbox uname -r

The first prints your host's real kernel release, something like 6.8.0-45-generic. The second prints gVisor's synthetic version (4.4.0 at the time of writing), because the workload only ever sees the emulated kernel.

Third, the full sandboxed run from step 4 should print:

python 3.13.7
kernel 4.4.0
result 285

Your Python patch version may differ. If kernel shows your host release, you forgot --runtime=runsc.

Troubleshooting

docker: Error response from daemon: unknown or invalid runtime name: runsc Docker doesn't know about the runtime yet. Run sudo runsc install, confirm /etc/docker/daemon.json has the runsc entry, and restart with sudo systemctl restart docker. A docker restart of a container is not enough; the daemon itself must reload.

panic: unable to attach: operation not permitted or fork/exec /proc/self/exe: invalid argument The runsc binary's permissions are wrong, which happens with manual installs. Fix with sudo chmod a+rx /usr/local/bin/runsc. The binary must be executable by all users since it re-execs itself in the sandbox.

SELinux is not supported: system_u:system_r:container_t:s0... On Fedora, RHEL, and friends, Docker's SELinux labeling conflicts with runsc. Per gVisor's FAQ, run the container with --security-opt label=disable. You're trading SELinux confinement for gVisor's, which is the point of this setup anyway.

Container works under runc but fails under runsc gVisor implements most of the Linux syscall surface, not all of it. Reinstall a debug runtime and trace what the workload attempted: sudo runsc install --runtime runsc-debug -- --debug --debug-log=/tmp/runsc-debug.log --strace, restart Docker, rerun with --runtime=runsc-debug, and search the log for unimplemented. For an LLM sandbox this is usually acceptable breakage: exotic syscalls in generated code are a signal, not a feature.

Next steps

  • Pick a platform deliberately. The default syscall interception mode is systrap, which works everywhere including inside VMs. On bare metal, benchmark --platform=kvm (passed via runtimeArgs in daemon.json) for syscall-heavy workloads.
  • Keep the runsc-debug runtime around in staging. --strace logs every syscall generated code makes, which doubles as an audit trail for what your agent's output actually does.
  • Cut Docker out of the hot path: runsc runs OCI bundles directly (runsc spec, then runsc run), useful when your agent orchestrator manages rootfs images itself.
  • Scaling to a cluster? gVisor plugs into Kubernetes through containerd's runsc shim and a RuntimeClass, so agent pods opt in with one line of spec. Start at gVisor's production guide on gvisor.dev.

Sources & further reading

  1. Installation - gVisor — gvisor.dev
  2. Docker Quick Start - gVisor — gvisor.dev
  3. Platform Guide - gVisor — gvisor.dev
  4. FAQ - gVisor — gvisor.dev
  5. gVisor Releases — github.com
Ji-ho Choi
Written by
Ji-ho Choi · Security & Cloud Editor

Ji-ho covers the increasingly tangled overlap between cloud architecture and security, drawing on a background as a penetration tester to keep his reporting grounded in real-world attack paths. He never lets a vendor claim go unquestioned and insists that every buzzword come with a proof of concept.

Discussion 0

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading