Sandbox matrix: what isolation do you actually get?

Why Sandbox? What's wrong with existing solutions

To get maximum productivity from an AI agent, we need to give it maximum autonomy, while also minimizing risk. A sandbox is a key component here.

An agent that can run a shell will run one - so once you start an agent, it can completely take over the machine it is running on if it wants to. Any stored credential on the machine becomes available to the agent, any file on the machine can be read and sent to the cloud. The only way to guarantee that an agent doesn't have access to these things is to make sure its execution environment does not have access to them. That's where a sandbox comes in.

What's wrong with normal VMs and containers? They isolate well, but they are not built for the way agents work: sharing the workspace as context, and keeping secrets out of the agent's reach. We will come back to those trade-offs below.

Overview - Ok, how do we sandbox?

We will cover the technologies we can use for sandboxes, the trade-offs for each, and finally review what projects in the industry use.

This is a map of the sandboxes people put around agents and other untrusted workloads: a plain process, a user namespace (including rootless containers), an OCI container on runc, gVisor, Firecracker, and Kata. The table is the default shape of each one. The notes are the configurations that delete a cell.

The matrix

LayerFilesystemNetworkKernelHost privilege
Shared kernel
ProcessCaller's filesCaller's networkSharedCaller's uid and capabilities
ContainerOwn rootfs. Bind mounts are host filesOwn network namespace, unless joined to the hostSharedUid 0 is host uid 0
Rootless containerOwn rootfs, as the mapped uidOwn network namespace, usually via slirp4netns or pastaShared. More of the kernel is reachable without host rootRoot inside is an unprivileged host uid
Isolated kernel
gVisorHost paths only through the goferUser-space netstack, unless host networking is selectedSentry handles guest syscalls. The host sees a small syscall setSentry runs unprivileged
Separate kernel
FirecrackerGuest disk image. No host-directory deviceGuest NIC only if you attach oneGuest kernel, behind KVM and a small device modelGuest root stays in the guest
KataGuest rootfs. Shared volumes follow the VMM, often virtio-fsGuest NIC, then the pod networkGuest kernel. Host surface is the VMM you configuredGuest root stays in the guest

The trade-off runs down the rows: the easier it is to share files with the sandbox, the more of the host the sandbox can reach.

Shared kernel

Every row below runs on the host kernel. The differences are how much of it the workload can reach, and with what privileges. Within this level the real distinction is the user namespace: whether uid 0 inside the sandbox is uid 0 on the host.

Process

A new process gets its own address space and file-descriptor table. That is the isolation Unix has always had. It is not a sandbox.

It runs as the user who started it, with that user's capabilities and namespaces. A coding agent launched from your shell can open ~/.ssh, ~/.aws, and the kubeconfig, because you can. It can connect to anything you can, including services bound to localhost.

A process can be tightened until it resembles a sandbox - seccomp-bpf, Landlock, bubblewrap are a few examples. If the thing in front of you is a subprocess, this is the row you have.

The kernel is the host kernel. A local kernel bug is a host bug. Dropping privileges can mitigate some attacks, but local privilege escalations (LPEs) are easier to find when the kernel is shared.

Container

An OCI container - Docker, containerd, or Podman - is a group of Linux namespaces around a root filesystem, plus cgroups, plus the seccomp and LSM profile the runtime applied. runc is the program that usually enters those namespaces.

The mount namespace gives the process a new root; host paths are absent unless bind-mounted. The pid namespace hides the host's process list. On Docker's default bridge, localhost is the container's localhost (not the host). cgroups can be used to cap CPU, memory, and IO.

Docker's default daemon does not enable user namespaces. Uid 0 in the container is uid 0 on the host (That is container breakout with root in the container gets you root on the host).

Rootless container

A user namespace gives the process a new uid map. Inside it, a mapped uid can look like 0 and hold every capability in that namespace; on the host it is whatever the map says - often the calling user, or a subuid from /etc/subuid. This means that a container breakout with root in the container does NOT get you root on the host.

Side Note: Enabling user namespaces

The user namespace is the switch between the two rows above. In Kubernetes it is opt-in: the pod sets spec.hostUsers: false. With that, the container's uid 0 maps to an unprivileged host uid and the pod gets the rootless row. Without it, you get the default: uid 0 is host uid 0.

bubblewrap is the same idea applied to a single process instead of a container. It builds a user namespace, a mount namespace, and a pid namespace around one command, with no daemon and no rootfs. Codex and Claude Code use it on Linux to sandbox the commands an agent spawns. It is the process row, tightened.

Trade-offs

The shared kernel is where filesystem sharing is easy. A bind mount or a volume is just a path; the sandbox sees the same files the host does, no need to copy/rsync/scp/etc... That matters for agents, because the workspace is the context. Mounting the repo, the build cache, or the home directory lets your agent work at native speed without the need to copy everything in and out. Startup is fast and any Linux binary runs.

A shared kernel means that a kernel bug reachable from inside is a host bug, and uid 0 in a rootful container is uid 0 on the host. The user namespace removes the second problem but not the first. The seccomp profile shrinks the reachable syscalls, but it is an allowlist with a wide middle.

While not impossible, there's no easy way to control egress or do credential injection.

Snapshot and restore is not "easy" to do here, with various harnesses doing approximations of this (tracking code changes and reverting them when restoring a session checkpoint). A container's state is entangled with the host kernel - open files, sockets, in-flight syscalls - so checkpointing needs CRIU and is fragile. That is why the snapshot column in the table below is empty for the container-based projects.

Isolated kernel

gVisor implements the kernel in user space. It is not the host kernel, and it is not a separate kernel in a VM - it sits between the two.

gVisor

gVisor keeps the OCI interface and replaces the kernel the application talks to. Syscalls are intercepted and handled by the Sentry, a user-space kernel written in Go that runs unprivileged.

The default network is inside the Sentry, so the sandbox's localhost is not the host's. Host networking is a configuration option, and gVisor documents it as a reduction in isolation.

The gain is a much smaller set of host syscalls: the Sentry has to be compromised before a guest syscall becomes a host syscall. It is not a hardware VM - the Sentry is a reimplementation, so some things fail or behave differently. A bug in the Sentry lands in its own unprivileged process. That second hop is real, but it is a different boundary from a guest kernel.

Trade-offs

gVisor keeps most of the filesystem-sharing convenience of the shared kernel - the workspace mounts the same way - while cutting the host syscall surface to a small set. It needs no /dev/kvm, so it runs on CI runners and nested VMs where a microVM cannot.

The cost is compatibility and performance. The Sentry is a reimplementation, so some things fail or behave differently. It is also a large user-space program, and a bug in it is code execution as the user who started it - still unprivileged, but a real second hop.

Snapshot and restore is the payoff. The whole guest state lives in the Sentry's memory, so checkpoint/restore is native - that is what Substrate's sub-500ms resume is built on.

Separate kernel

The two rows below run a separate kernel. The host kernel sees a virtual machine monitor; the workload sees a guest kernel. This is the strongest isolation in the matrix: a guest kernel exploit stays in the guest, unless it can continue into KVM or the monitor's device model.

The guest filesystem is a disk image you attached; sharing a host directory is not part of the device model, so it needs a daemon on the host. The guest network is a NIC you attach - no NIC means no network. The VM boundary does not choose destinations.

Guest root is root of the guest. The monitor is a separate host process, meant to run unprivileged. A monitor running as root, with its API socket reachable by the workload you were trying to contain, is a control plane sitting outside this matrix.

VMs need /dev/kvm. A CI runner or nested VM without nested virtualization cannot start them.

Firecracker and Kata

The difference between the two is the layer they sit at. Firecracker is a VMM: a specific piece of software with a fixed, minimal device list - no USB, no arbitrary PCI passthrough, and no way to share a host filesystem. Kata is a container runtime that wraps a VMM: it keeps the OCI and pod interface, and the node picks the hypervisor. The name Kata in a manifest does not record which one the node used - the runtime configuration does. Kata's hypervisor choices are the thing to read before treating two Kata pods as the same sandbox.

The guest runs a small agent that sets the container up. It sits inside the VM.

Trade-offs

The separate kernel is the strongest boundary, and the hardest to share with. A block device for the rootfs keeps the host out of the guest's filesystem entirely; a workspace volume from the host brings a sharing daemon back. VMs also cost more than the other rows - a kernel to boot, memory for the guest - and they need /dev/kvm on the node.

Another advantage of VMs over containers is that they snapshot cleanly. The whole machine state is well-defined - memory, disk, devices - so a VM can be frozen and resumed as a unit.

One more note - The image is not the sandbox

The same rootfs under runc, gVisor, and Kata is three kernels; the image does not record which one ran. And the workspace is supposed to be visible - the question is every other path: the home directory, the Docker socket, kubelet credentials, and the host's /proc and /sys when those really are the host's.

What the projects use

The runtime's name is a weak source of truth, and so is the product's name. Here is where a few popular agent sandboxes land on the matrix.

ProjectSandboxSnapshot / restoreMatrix row
Agent SubstrategVisor by default, or a Kata microVM on Cloud Hypervisor. Plain Linux containers are not supported; the microVM class needs /dev/kvmYes - checkpoint/restore. Idle actors are snapshotted out of memory and restored in under 500msIsolated, or separate
E2BFirecracker. One microVM per sandbox, in its own cgroup and network namespace, with an nftables egress firewallYes - a sandbox is a resumed snapshot, not a booted kernel; pause and resumeSeparate
ModalgVisor or microVMsYes - memory, filesystem, and directory snapshotsIsolated, or separate
OpenHandsDocker. Each conversation runs in its own container, or directly on the host if you skip the sandboxNoShared (container), or process
Claude CodeLocal: permission system plus sandboxed bash (bubblewrap on Linux, Seatbelt on macOS). Cloud: isolated VMsNoShared (process, tightened), or separate in the cloud
CodexLocal: bubblewrap on Linux, Seatbelt on macOS, the Windows sandbox on Windows. A workspace-write sandbox bounds files and networkNoShared (process, tightened)
DaytonaOCI/Docker-compatible sandboxes. Marketing claims a dedicated kernel, but the public repo is archived and the runtime is hard to verifyYes - stateful environment snapshots for persistent agent sessionsShared (container), per its OCI/Docker basis