Skip to content
Chock

Isolation

A machine does not replace the sandbox. It runs it.

Every other harness that sandboxes at all shells out to bubblewrap, Docker or a platform sandbox. Chock builds its confinement in the process itself, and the microVM goes underneath that rather than in place of it. A tool call runs in a guest with a kernel of its own, and the namespaces, the filter, Landlock and the limits are built in there, by the same driver from the same configuration.

There is a sandbox on each side of the guest. One inside it, holding the tool call, and one around the process that runs the guest, built from a much shorter list because that process runs a machine rather than your code.

01host sandbox

around the process that runs the guest

02microvm guest

a kernel of its own

03the sandbox, in the guest

built by the same driver from the same configuration

user namespace

no privilege in the guest

mount namespace

a throwaway copy of your project

pid + ipc namespace
empty network namespace
landlock

the filesystem, held by the guest kernel

seccomp

31 calls killed, 3 refused by argument

the tool call

your tool call, in a copy of your project

Three boundaries. The rings inside one are what that boundary is made of, not a count of its own.

A reader's first assumption is that a virtual machine stands in for the sandbox. It does not. The guest is a second kernel, so every layer that holds a tool call without one holds it in there too, and getting out of that sandbox leaves you inside the guest, which is itself inside a sandbox.

What getting out would take

Step 1

Out of the sandbox

Namespaces, Landlock, a system call filter and the limits, built in the guest by the same driver that builds them on a machine without one.

Step 2

Out of the guest

Through the virtio and KVM interface, held by a kernel of its own. A fault here is a different class from a Landlock or seccomp bypass, and that is the step worth having.

Step 3

Out of the host process

The process holding the guest is itself confined, and its allow list is far smaller than a tool call's, because it runs a machine rather than your code.

What is not independent of what

The first step and the third use the same two mechanisms, seccomp and Landlock, so a single technique against either one could apply at both ends. They are not independent of each other and we would rather say so than count three layers. The middle step is the one that stands on its own, because a virtio or hypervisor fault is a different class of bug held by a different kernel.

Why a machine, and why we built it in

Every layer of a sandbox is a judgement your own kernel makes about a process it is running. Namespaces, Landlock and the system call filter are all enforced by the kernel a tool call is already sitting on, so a fault in a call the filter permits is a fault in the kernel running your laptop. A guest moves that work onto a kernel of its own, and gives the agent one we are prepared to lose.

It also levels the platforms. Seatbelt is to the Darwin driver what Landlock and seccomp together are to the Linux one, but the native Darwin driver expresses none of the five capabilities the sandbox names: no bind mount, no capped area, no procfs, no cgroup placement, no device passthrough.

A Darwin guest is a Linux guest, running the same driver from the same configuration, so inside the guest the layers are identical wherever you work. The sandbox around the host process is not identical: seccomp and Landlock on Linux, a Seatbelt profile on macOS. Same intent, different mechanism.

Why not shell out to bubblewrap

A harness that shells out has a dependency that can be absent, and the usual answer to a missing one is to run without it.

There is a ceiling as well. bubblewrap takes the whole mount set on its command line, and a Nix closure is thousands of paths: Codex hit Exceeded maximum number of arguments that way, and Claude Code hit the 128 KB argument limit. Chock builds the namespaces itself, so there is no command line to overflow and nothing to be missing.

What an exec brings with it

Starting a helper runs the dynamic loader before the helper itself, and the loader reads the environment, so LD_PRELOAD and its relatives sit on the path that is meant to be building your boundary.

It is work as well as surface: finding the program, reading it, the kernel's own checks, then every shared object mapped before a line of it runs. Chock never leaves the process to build a boundary. It forks, installs the layers in the child, and execs your command once they are on.

Why the monitor is part of Chock

A confinement has to be installed before an exec, so a monitor started as a program has to be granted everything it will ever open: /dev/kvm, the kernel, the initrd, its own libraries. Chock opens all of that first and confines afterwards, with no exec to come, so the grant is the share set and nothing more.

It is also what carries the network across. The guest writes a name and a descriptor comes back already open, on the library's own message type. Hand a guest a tap device instead and it resolves and connects for itself.

What builds the boundary

Every other harness that sandboxes at all shells out to bubblewrap or to the platform's own sandbox, around the commands an agent runs. Read at the commit beside each name, on the date beside that.

  • Chockthis project

    Built in the process itself, then a microVM guest with a sandbox on each side of it.

  • Claude Code2.1.260, sandbox-runtime 0.0.74, read 2026-10-02

    bubblewrap on Linux and Seatbelt on macOS, around shell commands, and off until you turn it on.

  • Codexca46606, read 2026-10-02

    bubblewrap on Linux and Seatbelt on macOS, around shell commands.

  • Zed5a9b955, read 2026-09-05

    bubblewrap on Linux, around the terminal and fetch.

  • OpenCode012c2f5 (v1.18.11), read 2026-08-21

    Nothing.

  • oh-my-pi9350b799, read 2026-08-21

    Nothing.

The whole comparison

The guest opens no socket

It resolves no name either. The guest writes a name, the host decides whether the policy permits it, resolves it and connects, and hands back a descriptor that is already open. So a guest cannot ask for one host and be handed another, and a name it was refused never reaches a resolver at all.

A refusal arrives inside the guest as nothing more than a stream that ended.

inside the guest
# the guest asks for a name$ connect api.example.com:443# the host decides, resolves, connects# and hands back a descriptor already open# a refusal arrives as a stream that ended

A guest that cannot be reached is a refusal

Falling back to the driver underneath would confine a session more weakly than the operator asked for, with nothing in the log saying so. There is no fallback here, the same way there is no switch: a layer that cannot be applied fails the tool call, and a guest that cannot be started fails it too.