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.
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
--runtimeflag 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=ALLand--security-opt=no-new-privileges: no capabilities, and no way to gain any via setuid binaries.--read-onlyplus a smallnoexectmpfs: 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 viaruntimeArgsindaemon.json) for syscall-heavy workloads. - Keep the
runsc-debugruntime around in staging.--stracelogs 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:
runscruns OCI bundles directly (runsc spec, thenrunsc run), useful when your agent orchestrator manages rootfs images itself. - Scaling to a cluster? gVisor plugs into Kubernetes through containerd's
runscshim and aRuntimeClass, so agent pods opt in with one line of spec. Start at gVisor's production guide on gvisor.dev.
Sources & further reading
- Installation - gVisor — gvisor.dev
- Docker Quick Start - gVisor — gvisor.dev
- Platform Guide - gVisor — gvisor.dev
- FAQ - gVisor — gvisor.dev
- gVisor Releases — github.com
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
No comments yet
Be the first to weigh in.