# Sandbox LLM-Generated Code with gVisor and Docker

> Source: <https://sourcefeed.dev/a/sandbox-llm-generated-code-with-gvisor-and-docker>
> Published: 2026-09-09 17:39:21+00:00

# 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](https://sourcefeed.dev/u/jiho_choi)

## What you'll build

An execution path for code your LLM agent writes: a Docker container that runs under [gVisor](https://gvisor.dev)'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](https://docs.docker.com/engine/) 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:

``` python
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](https://gvisor.dev/docs/user_guide/install/)
                                — gvisor.dev
2. 
                                    [Docker Quick Start - gVisor](https://gvisor.dev/docs/user_guide/quick_start/docker/)
                                — gvisor.dev
3. 
                                    [Platform Guide - gVisor](https://gvisor.dev/docs/architecture_guide/platforms/)
                                — gvisor.dev
4. 
                                    [FAQ - gVisor](https://gvisor.dev/docs/user_guide/faq/)
                                — gvisor.dev
5. 
                                    [gVisor Releases](https://github.com/google/gvisor/releases)
                                — github.com

[Ji-ho Choi](https://sourcefeed.dev/u/jiho_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

No comments yet

Be the first to weigh in.
