Skip to content
Security Advanced Tutorial

Sandbox Untrusted LLM-Generated Code with gVisor and Firecracker

Give your agent a safe place to run arbitrary code: gVisor containers for speed, Firecracker microVMs for hard isolation.

Ji-ho Choi
Ji-ho Choi
Security & Cloud Editor · Aug 4, 2026 · 6 min read
Sandbox Untrusted LLM-Generated Code with gVisor and Firecracker

What you'll build

A local execution sandbox for LLM agents: a shell wrapper that runs model-generated Python inside a gVisor-isolated container with no network and hard resource caps, plus a Firecracker microVM path for when you want a full hardware-virtualization boundary. Your agent pipes code in, gets stdout back, and the host never trusts a single syscall.

Prerequisites

  • A Linux host, x86_64 or arm64. gVisor needs kernel 4.14.77+, so any current distro works; its default systrap platform doesn't need KVM. The Firecracker steps do need read/write access to /dev/kvm — bare metal or a cloud instance with nested virtualization.
  • Docker Engine and sudo access. Commands assume Ubuntu 24.04 LTS.
  • curl, wget, and squashfs-tools (sudo apt-get install -y squashfs-tools) for the Firecracker rootfs step.
  • Verified August 2026 against gVisor release-20260727.0, Firecracker v1.16.1, Docker Engine 29.7, and the python:3.14-alpine image.

1. Install gVisor and register the runsc runtime

gVisor is an application kernel: syscalls from your workload are intercepted and served by a userspace kernel written in Go, so untrusted code never talks to the host kernel directly. It ships as an OCI runtime called runsc that plugs straight into Docker.

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

Register it as a Docker runtime and restart the daemon — runsc install writes the runtime entry into /etc/docker/daemon.json for you:

sudo runsc install
sudo systemctl restart docker
docker run --rm --runtime=runsc hello-world

If hello-world prints its usual banner, the runtime is wired up.

2. Wrap runsc in a locked-down executor

gVisor removes the shared-kernel risk; Docker flags remove everything else the code doesn't need. Save this as run-untrusted.sh:

#!/usr/bin/env bash
# Reads Python source on stdin, executes it in a gVisor sandbox.
set -euo pipefail
exec docker run --rm -i \
  --runtime=runsc \
  --network=none \
  --memory=256m --cpus=0.5 --pids-limit=64 \
  --read-only --tmpfs /tmp:size=16m \
  --cap-drop=ALL --security-opt=no-new-privileges \
  python:3.14-alpine \
  timeout 10 python3 -
chmod +x run-untrusted.sh
echo 'print(sum(range(100)))' | ./run-untrusted.sh

Each flag closes a hole: --network=none gives the sandbox only a loopback interface, so exfiltration and reverse shells are dead on arrival; --pids-limit kills fork bombs; --read-only plus a small tmpfs means nothing persists between runs; --cap-drop=ALL strips capabilities even inside gVisor's kernel; timeout 10 bounds infinite loops (the container exits non-zero and --rm cleans it up). Your agent calls this script per snippet — fresh container every time, nothing shared.

3. Fetch Firecracker, a kernel, and a rootfs

gVisor's boundary is a hardened userspace kernel. If your threat model wants hardware virtualization — the same line AWS Lambda draws around customer code — use Firecracker, a KVM-based VMM that boots a minimal VM in ~125 ms. First confirm KVM access, then grab the latest release binary:

lsmod | grep kvm
[ -r /dev/kvm ] && [ -w /dev/kvm ] && echo "OK" || echo "FAIL"

ARCH="$(uname -m)"
release_url="https://github.com/firecracker-microvm/firecracker/releases"
latest_version=$(basename $(curl -fsSLI -o /dev/null -w %{url_effective} ${release_url}/latest))
curl -L ${release_url}/download/${latest_version}/firecracker-${latest_version}-${ARCH}.tgz | tar -xz
mv release-${latest_version}-${ARCH}/firecracker-${latest_version}-${ARCH} firecracker

A microVM needs a guest kernel and a root filesystem. Firecracker's CI publishes both; these commands pick the newest matching your release line:

CI_VERSION=${latest_version%.*}
latest_kernel_key=$(curl "http://spec.ccfc.min.s3.amazonaws.com/?prefix=firecracker-ci/$CI_VERSION/$ARCH/vmlinux-&list-type=2" \
  | grep -oP "(?<=<Key>)(firecracker-ci/$CI_VERSION/$ARCH/vmlinux-[0-9]+\.[0-9]+\.[0-9]{1,3})(?=</Key>)" \
  | sort -V | tail -1)
wget "https://s3.amazonaws.com/spec.ccfc.min/${latest_kernel_key}"
KERNEL_FILE=$(basename "$latest_kernel_key")

latest_ubuntu_key=$(curl "http://spec.ccfc.min.s3.amazonaws.com/?prefix=firecracker-ci/$CI_VERSION/$ARCH/ubuntu-&list-type=2" \
  | grep -oP "(?<=<Key>)(firecracker-ci/$CI_VERSION/$ARCH/ubuntu-[0-9]+\.[0-9]+\.squashfs)(?=</Key>)" \
  | sort -V | tail -1)
ubuntu_version=$(basename $latest_ubuntu_key .squashfs | grep -oE '[0-9]+\.[0-9]+')
wget -O ubuntu-$ubuntu_version.squashfs "https://s3.amazonaws.com/spec.ccfc.min/$latest_ubuntu_key"

unsquashfs ubuntu-$ubuntu_version.squashfs
sudo chown -R root:root squashfs-root
truncate -s 1G ubuntu-$ubuntu_version.ext4
sudo mkfs.ext4 -d squashfs-root -F ubuntu-$ubuntu_version.ext4
ROOTFS_FILE=ubuntu-$ubuntu_version.ext4

The squashfs is unpacked and rebuilt as ext4 because Firecracker drives are block devices. This is also your hook for customization: drop your language runtime and executor into squashfs-root/ before the mkfs.ext4 line.

4. Boot the microVM with no NIC attached

Firecracker takes a JSON config at startup. Note what's missing: no network-interfaces section means the guest has no network device at all — stronger than a firewall rule, because there's nothing to misconfigure.

cat > vm_config.json <<EOF
{
  "boot-source": {
    "kernel_image_path": "${KERNEL_FILE}",
    "boot_args": "console=ttyS0 reboot=k panic=1"
  },
  "drives": [
    {
      "drive_id": "rootfs",
      "path_on_host": "${ROOTFS_FILE}",
      "is_root_device": true,
      "is_read_only": false
    }
  ],
  "machine-config": {
    "vcpu_count": 1,
    "mem_size_mib": 512
  }
}
EOF

sudo rm -f /tmp/firecracker.socket
sudo ./firecracker --api-sock /tmp/firecracker.socket --config-file vm_config.json

Your terminal becomes the guest's serial console. Log in with root / root, run whatever hostile code you like, then type reboot — Firecracker treats a guest reboot as shutdown and exits. For repeated runs, keep the ext4 image pristine and copy it per execution.

Verify it works

Confirm code actually runs under gVisor — its fake dmesg is unmistakable:

$ docker run --rm --runtime=runsc python:3.14-alpine dmesg | head -n 1
[    0.000000] Starting gVisor...

Confirm the executor computes but can't reach the outside world:

$ echo 'print(sum(range(100)))' | ./run-untrusted.sh
4950
$ echo 'import socket; socket.create_connection(("1.1.1.1", 443), timeout=3)' | ./run-untrusted.sh
Traceback (most recent call last):
  ...
OSError: [Errno 101] Network is unreachable

For Firecracker, the boot log should scroll to an Ubuntu login prompt on ttyS0 in about a second. After logging in as root, confirm the guest runs its own kernel and has no network interface beyond loopback:

# uname -r
6.1.141
# ip link
1: lo: <LOOPBACK> mtu 65536 ...

Troubleshooting

docker: Error response from daemon: unknown or invalid runtime name: runsc — Docker doesn't know about the runtime yet. Run sudo runsc install, then sudo systemctl restart docker (a reload isn't enough for runtime changes).

panic: unable to attach: operation not permitted or fork/exec /proc/self/exe: invalid argument — the runsc binary isn't readable/executable by the container user. This bites manual installs; fix with sudo chmod a+rx /usr/local/bin/runsc (the apt package sets permissions correctly).

KVM check prints FAIL — your user can't open /dev/kvm. Grant access with sudo setfacl -m u:${USER}:rw /dev/kvm or add yourself to the kvm group and re-login. On cloud VMs, /dev/kvm missing entirely means no nested virtualization — pick a bare-metal instance type or enable nested virt; gVisor's systrap path works either way.

Firecracker starts but the console stays blank — your boot_args are missing console=ttyS0, so the kernel is booting silently with output going nowhere. Add it and restart.

Next steps

  • In production, never run firecracker bare: wrap it in the project's jailer, which chroots and drops privileges before the VMM starts.
  • Cut microVM cold starts to milliseconds with Firecracker snapshots — boot once, snapshot after the interpreter loads, restore per request.
  • If sandboxed code needs some egress (pip installs, API calls), don't hand it a NIC — proxy through a host-side allowlist, or in gVisor keep --network=none and mount vetted wheels read-only.
  • Read gVisor's production guide for platform tuning (systrap vs KVM) and its own per-sandbox resource controls.

Sources & further reading

  1. Installation - gVisor — gvisor.dev
  2. Docker Quick Start - gVisor — gvisor.dev
  3. FAQ - gVisor — gvisor.dev
  4. Getting Started with Firecracker — github.com
  5. Firecracker v1.16.1 Release — github.com
  6. Docker Engine version 29 release notes — docs.docker.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