A short guide to sandboxing dev containers (e.g. an agent like Claude Code, or untrusted test runs) with gVisor under Docker / Docker Compose.
runsc interposes a user-space "Sentry" kernel between the container and
the host. It intercepts syscalls and re-implements a subset of the Linux
4.4 ABI in Go, so a compromised container cannot directly invoke host
syscalls.
| Threat | Mitigated by runsc? |
|---|---|
| Linux kernel exploit via unusual syscall | Yes - Sentry is the only target |
Container escape via /proc, /sys, capabilities |
Yes - stripped, virtualised |
| Accidental damage outside the bind mount | Yes - host FS not visible |
| Untrusted code execution inside the container | Yes - same kernel boundary |
| Application-level bugs (SQLi, SSRF, deserialisation) | No - same app code runs |
| Supply-chain compromise of the image | No - image is trusted by you |
Host network exfiltration (with --network=host) |
No - host stack is shared |
GPU / KVM passthrough / --privileged workloads |
Not supported (will fail) |
| Heavy syscall- or I/O-bound workloads | Works, but noticeably slower |
Reported kernel is always a synthetic 4.4.0, regardless of host -
useful as a sandbox fingerprint, irrelevant for security.
Linux ≥ 4.14.77 required. KVM (/dev/kvm) recommended; falls back to
ptrace otherwise (slower, works rootless).
apt-get install -y runsc
sudo runsc installThis should be added automatically by runsc install,
just verify that...
cat /etc/docker/daemon.json:
{
"runtimes": {
"runsc": {
"path": "/usr/local/bin/runsc",
"runtimeArgs": ["--network=host"]
}
}
}sudo systemctl restart docker, then verify:
docker run --rm --runtime=runsc alpine dmesg | head -1 # "Starting gVisor..."To sandbox every container by default, add "default-runtime": "runsc".
services:
claude:
image: my-claude-sandbox:latest
runtime: runsc
volumes:
- ./:/workspace
working_dir: /workspaceOnly services with runtime: runsc are sandboxed; the rest keep using
runc.
dmesg | head -1 # Starting gVisor...
cat /proc/version # Linux version 4.4.0 ...
ls /sys/firmware 2>&1 # absent - gVisor exposes a stripped /sysThe fixed 4.4.0 kernel version is the most reliable signal.
gVisor's default --network=sandbox uses its own user-space network
stack (netstack). Docker's embedded DNS server at 127.0.0.11:53 relies
on iptables DNAT rules that netstack does not honour the same way, so
Compose service-name resolution silently fails: getent hosts db
returns nothing even though db is in the same project.
Pick one fix:
--network=hostinruntimeArgs(used in step 2 above). Network stack passes through to the host; syscall/FS/process isolation is preserved. Lose: network-stack isolation. Recommended for dev sandboxing.- Static IPs +
extra_hosts:. Keep--network=sandbox; declare each service's IP in the compose network and add hostname entries so resolution happens via/etc/hosts, not DNS. - External DNS via
dns:. Fixes resolution ofpypi.orgetc., but not of Compose service names - those only exist in Docker's embedded resolver.
Quick diagnosis from inside the container:
getent hosts db # compose service name
getent hosts pypi.org # external
cat /etc/resolv.confIf only the service name fails, you are hitting the netstack/embedded-DNS gap - apply fix 1 or 2.
Opting in to runsc as the default runtime may cause
some existing container workloads to fail due to unsupported features (e.g. --privileged).
The runtimeArgs: ["--network=host"] relaxes a networking constraint that would otherwise break
the internal Docker Compose DNS resolution of service names (see above).
"default-runtime": "runsc",
"runtimes": {
"runsc": {
"path": "/usr/bin/runsc",
"runtimeArgs": ["--network=host"]
}
}- Workloads that legitimately need
--privileged, raw sockets, GPU, or nested virtualisation. - Hot paths doing heavy I/O against bind mounts (use a named volume or
tmpfs, or stay on
runc). - Threat models that include hostile kernel-network exploits and service-to-service DNS - these conflict; consider Firecracker microVMs instead.