Skip to content
Chock
The sandbox (18 of 23)

This page is the documentation for unstable. Read the current version.

The sandbox

Every tool call runs inside a sandbox, in a throwaway copy of the project. Your real project is never written by a tool call. This page says what the boundary is made of.

The layers below are the Linux ones. macOS builds the boundary out of Seatbelt, and it gives less. The sandbox on macOS says what it holds and what it cannot.

The layers are separate on purpose. Each one is tested, each one can be absent on a given machine, and chock doctor says which are on. A red team run set up an io_uring ring inside a tool call, which is the classic way past a system call filter, and the paths were still refused by Landlock and the network was still unreachable. That is what layers are for.

Namespaces

One unshare call takes them all.

Namespace When
user always, with no option to turn it off
PID always
IPC always
mount on by default
network on by default, and a policy rule is what opens it

There is no UTS namespace and no cgroup namespace. sethostname is refused by the seccomp filter instead.

After the unshare, Chock writes setgroups=deny, then maps your uid to itself and your gid to itself. Nothing maps to root.

The mount namespace is what lets the workspace appear at the project’s own path, so a compiler that writes an absolute path into an error message writes the path you would expect, and nothing else of the machine is in the tree.

The network

A tool call gets a fresh network namespace. Nothing brings an interface up in it. Chock configures no interface at all, so a call reaches no host.

There are three modes, and a tool call gets the first:

  • none, the default. No route out at all.
  • filtered. The same closed namespace, plus one descriptor to a broker passed in over a socket, so a named connection can be brokered against the policy table. In practice this is set only when a rule allows an MCP server’s net.connect.
  • host. The host’s own namespace, with nothing removed. chock doctor uses this for its own probes.

fetch_url does not work by opening the sandbox. The broker reads the page in a process the agent cannot reach, and the host is a row on the action table.

So a program that opens a socket in a tool call fails. ping answers socktype: SOCK_RAW and exits 2, and curl cannot resolve a name. That is the boundary and not a fault in the command. Chock says so in a line of its own beside the result, because the program’s own words do not.

Landlock

Landlock is the kernel naming which paths a tool call may open, in the kernel rather than in Chock.

Chock asks the kernel which ABI it has and takes the answer, rather than requesting a version. The ruleset then handles every filesystem right that kernel knows, masked to the ABI it reported, because an unhandled right stays permitted everywhere. Rights the running kernel does not have are masked out of each rule and reported back, so a session on an older kernel says what it did not get.

Three grant sets are used: read only, read only for one regular file, and read and write. Only LANDLOCK_RULE_PATH_BENEATH rules are written. PR_SET_NO_NEW_PRIVS is set first.

A kernel with no Landlock is a hard failure and not a quiet downgrade. Sandbox.spawn refuses rather than running a tool call with one layer silently missing.

seccomp

The filter is a denylist. It reads the audit architecture first and kills a foreign one before it reads a system call number, and on x86_64 it kills an x32 call as well.

31 calls are blocked, and a blocked call kills the process. Not an errno: a program that meant to escape does not get to handle the failure. The groups are:

  • namespace escape: unshare, setns
  • the whole mount family, mount through open_tree_attr, and pivot_root and chroot
  • modules and kexec: init_module, finit_module, delete_module, kexec_load, kexec_file_load
  • keyrings, after a fresh session keyring is joined: add_key, keyctl, request_key
  • process introspection and injection: ptrace, process_vm_readv, process_vm_writev, bpf, userfaultfd
  • the machine: reboot, syslog, sethostname
  • open_by_handle_at. This one is belt and braces rather than a hole closed on faith. The kernel’s own capability check already refuses it with EPERM, because it needs CAP_DAC_READ_SEARCH in the user namespace that owns the target filesystem’s superblock, and a process born from CLONE_NEWUSER, which every sandboxed process here is, never holds a capability in an ancestor namespace over an object that namespace owns. A probe outside Chock and a probe inside a real sandbox both get EPERM, whether the caller is an ordinary user or root inside a fresh unshare -U -r. name_to_handle_at, the call that only encodes a handle and grants nothing on its own, is left reachable.

Two more rules inspect an argument and kill rather than answer EPERM, because each closes a boundary and not a cost:

  • execveat, when its flags argument sets AT_EMPTY_PATH. This is the step that runs a file with no path at all: memfd_create makes an anonymous file with no directory entry, and execveat with AT_EMPTY_PATH can run it without ever naming one, so Landlock’s execute right, which attaches only to a path, has nothing to check. Without this rule, a ruleset granting a directory every ordinary right except execute still lets a process write those bytes into a memfd and run them, right after the identical bytes on disk are refused. memfd_create itself is left reachable: it grants nothing by itself, and strace against Node, Python and Go each running an ordinary program shows none of them calling either memfd_create or execveat at all.
  • socket, when its domain argument is AF_VSOCK. Every other address family a tool call can reach answers to the network namespace namespace.enter always builds. A vsock address names a hypervisor CID, and the kernel does not consult the calling process’s network namespace to decide whether one is reachable, so on a host where the sandbox is itself a VM guest, this is a channel to the hypervisor with no Network mode and no policy rule in front of it. Codex also refuses AF_VSOCK for this reason, even when its own network policy allows a connection.

Three more are refused with EPERM, and io_uring is all three: io_uring_setup, io_uring_enter, io_uring_register. A ring lets a kernel worker perform the operation, so the filter never sees the system call at all. The refusal makes no ring, submits no operation, and gives no ring a buffer, so io_uring is exactly as unavailable as it was on the kill list. The one difference is that the process learns it was refused.

The reason is Node v24.19.0. libuv calls io_uring_setup six times while Node starts, before it runs one line, and UV_USE_IO_URING=0 does not stop it. The probe is meant to fail on a kernel older than 5.1, and libuv then falls back to its thread pool. So Node does not need a ring: it needs the probe to fail survivably. A kill ended every Node, Deno and Bun program at startup and bought nothing, because a hostile program can leave io_uring alone.

Four rules inspect an argument and answer EPERM instead of killing, so a program can recover: personality asking for READ_IMPLIES_EXEC, shmat with SHM_EXEC, and mmap, mprotect or pkey_mprotect asking for a page that is both writable and executable. That last one raises a cost and is not a boundary, and the code names three ways past it. io_uring being refused is the stronger statement.

A project that needs a just in time compiler

V8 asks for a page that is writable and executable over a 268 MB code range, so Node, Deno and Bun cannot work under the write and execute rule unless they are started with --jitless. A project turns that one rule off with one row on the policy table:

.{
    .policy = .{
        .rules = .{
            .{ .action = "sandbox.jit", .decision = .allow },
        },
    },
}

It is a policy row and not a chock.zon key of its own, because it is the one setting that widens. The table already folds an organisation’s bundle over a project and a parent over a child, so three things come free:

  • An organisation forbids it for every project at once with .{ .action = "sandbox.jit", .decision = .deny } in its policy bundle. A project cannot raise what the bundle lowered, because the answer is a minimum.
  • A subagent holds no more than its parent.
  • allow and nothing else turns the rule off. An action nobody named answers ask, so a project that has never heard of this row keeps the hardening.

A session that gave the rule up says so, three times. chock run prints a warning at start, the session log carries a sandbox.open event naming relaxed and the policy answer behind it, and chock doctor reads the same rules and prints write^execute OFF before a session starts. Nothing else in the filter moves: every blocked call still kills, io_uring is still refused, and Landlock and the network namespace are untouched.

The workspace

The workspace is a throwaway copy, and the caller never chooses which kind it gets:

  • A git project gets a linked git worktree, detached at HEAD. A linked worktree records nothing about the process that made it, so it costs no git command to hand one to another process.
  • Anything else gets an overlay, with your project as the read only lower layer. On Linux that is overlayfs. On Darwin it is a clonefile copy.

By default the agent sees the committed state. chock run --allow-dirty copies your uncommitted work in as well.

Work reaches your project only when the agent commits it in the workspace and the policy permits the apply, which approvals.md describes.

What is in the tree, and what is not

The mount set of a tool call is built per call, and the tools do not share one. The read only toolchain is this project’s Nix dev shell closure, each store path at its own path, so the rest of the Nix store is not there. /proc is a fresh procfs, read only, with 20 entries masked. /run/chock/tasks is read only, so an agent cannot edit its own evidence.

The credential store is never mounted in. It is a file in the data directory, and no mount ever names that directory. Two per project subdirectories below it are mounted, the knowledgebase and the toolchain cache, and binding a subdirectory does not expose its parent. The knowledgebase is mounted for read_memory and write_memory and for no other call, so it is not merely read only to the rest of the sandbox: it is not there.

A tool call with a network takes /etc for itself. The sandbox has a resolver of its own, and a program only finds it through /etc/resolv.conf, so Chock writes that file and two more. On a machine with no Nix the host’s own /etc is in the mount set, because the CA certificates are there, and Chock does not own it. So a routed call puts an overlay on that directory: everything the host has stays readable at the same path with the same bytes, the three files Chock writes go into a layer the sandbox made, and nothing reaches the host. This is the one place a tool call needs rootless overlayfs on such a machine, and chock doctor says so on the resolver files row.

The full list is in toolchains.md. Paths a project denies by name are covered before the workspace is built, and their bytes are not in the tree at all.

Limits

Two mechanisms, and the smaller one always applies.

cgroup v2 is the better answer, and it needs the memory and pids controllers, both, delegated to a directory Chock can make its own below. Chock never writes subtree_control itself. It sets memory.max, memory.swap.max to zero, and pids.max. After a call ends it reads memory.events and pids.events, so a bare SIGKILL can be named for what it was.

cpu.max is deliberately not set. It throttles rather than refuses, which turns a clear failure into a slow program that dies on the wall clock deadline instead.

cgroups are best effort and never a refusal to run. A machine without them still gets the second mechanism, and chock doctor says which half it did not get.

The rlimit floor needs nothing at all, so it always applies. It is set last in the child, immediately before the program runs.

Limit Default
core dump 0
data segment 16 GiB
open files 1024
file size 1 GiB
CPU seconds 3600 soft, 3630 hard
processes 256, on kernel 5.14 and above
memory, through cgroup 2 GiB
a scratch tmpfs 256 MiB

Soft equals hard everywhere except CPU, because prlimit64 is not blocked and a soft only limit would be raised back in one call. CPU keeps a 30 second gap so the first signal is SIGXCPU and not SIGKILL.

RLIMIT_AS, RLIMIT_STACK, RLIMIT_RSS, RLIMIT_MEMLOCK, RLIMIT_SIGPENDING and RLIMIT_MSGQUEUE are not set, each for a reason written next to it in the code.

A project’s chock.zon can lower any of these and can never raise one. It is the same ratchet the policy table keeps.

One setting widens, and it is not one of these. The write and execute rule is given up with a row on the policy table and never with a key in this file. See “A project that needs a just in time compiler” above.

A cgroup the caller made

Everything above is for the cgroup Chock makes. A program that embeds chock-sandbox as a library can hand it a cgroup instead, with Config.containment, and that is a different promise:

who makes the cgroup who writes the limits when the process goes in when it cannot be done
the default Chock Chock after the fork, first act of the child the program runs with the rlimit floor
a supplied cgroup the caller the caller at creation spawn refuses

Chock writes no limit file into a cgroup it was given. Not memory.max, not memory.swap.max, not pids.max. The caller owns that tree and the numbers in it, and a second writer is how two numbers stop agreeing. Chock reads nothing out of it either, so a program the caller’s own memory.max killed arrives as a bare SIGKILL that Chock does not name. The caller holds that cgroup and can read its own memory.events.

The process is created inside it, and is never outside it. Linux does that with clone3 and CLONE_INTO_CGROUP, which charges the new task to the destination cgroup as it makes it. The alternative, a write to cgroup.procs after the fork, leaves a window in which the child is in the caller’s own cgroup, and a process that runs even briefly outside its cgroup can fork faster than the write that would contain it.

It refuses rather than degrades. CLONE_INTO_CGROUP needs Linux 5.7. On an older kernel, on a descriptor that is not a cgroup v2 directory, or where a seccomp filter answers ENOSYS for clone3, spawn answers error.CgroupPlacementUnsupported or error.CgroupPlacementRefused and starts nothing. There is no second attempt without the cgroup. macOS has no cgroup at all and refuses such a config before it allocates anything.

The limits report then reads supplied for the cgroup row, which says exactly what happened: the program is contained, and Chock wrote none of what contains it. Nothing in Chock itself uses this. A tool call takes the default.

Device passthrough

Device passthrough is Linux only. Sandbox.expresses.device_passthrough answers true on Linux and false on macOS, and it stays that way. Darwin’s driver reads the same config a device asks through and applies none of it, so a project that names a device on macOS gets no device and no crash, and no row for it in chock doctor either.

When a device is named in a project’s devices block and let through by a policy rule, Chock binds its node into the sandbox by the same mechanism a workspace path gets: mknodat makes a placeholder and a bind mount lands the real node on top of it, read and write. actions.md has the two-block shape that has to agree before any of this runs.

The grant is the whole device, and never a part of it. A device node is a direct channel to a kernel driver, and most of what the sandbox reasons about does not reach past it. seccomp filters ioctl by its request number, and a filter has no way to read which file descriptor a call was made on, so “let the programmer’s own ioctls through and refuse the disk’s” is not a rule Chock, or seccomp itself, can write. A policy that allows device.usb.1d50.6018 allows every operation that node’s driver answers to, not a chosen subset of them.

Chock grants nothing the person at the keyboard could not already open. The node is bound in and the sandboxed program opens it under the same uid and the same file mode bits the node already has on the host. If the person running Chock is not in the dialout group, a program in the sandbox is not either, and no policy rule changes that. This is ordinary Unix file permission, working exactly as it does outside the sandbox, and it is not a control Chock adds.

The sandbox on macOS, and what it does not do

A session runs on macOS, with four layers on. Seatbelt holds the paths, the network including unix sockets, the signals and shared memory. Darwin’s own resource limits bound what a program can consume. The driver declares exactly four guarantees, and a test on a real Mac tries to break each one, in test/sandbox/darwin_escape.zig:

Guarantee On macOS
network_isolated yes
signal_isolated yes
ipc_isolated yes
path_restricted yes
syscall_restricted no, and it is permanent
workspace_mounted no, and it is permanent
Mach services (mach-lookup) yes, no opt in list needed

The Mach services row is not one of the four declared guarantees, and it is in this table anyway. No program a session starts can look up a Mach service by name, launchd’s own bootstrap namespace included, unless a caller later widens Options.mach_services, which nothing does today. On Apple Silicon, macOS 15.7.9, arm64, on 2026-09-05: a profile holding (deny default) and nothing naming mach-lookup at all already refuses a real, registered service, the same answer a profile spelling out (deny mach-lookup) gives, where the identical lookup outside any profile succeeds. See test/sandbox/darwin_escape.zig’s own mach-lookup tests.

A layer this driver reports as on, and does not enforce, is worse than a refusal. Chock compares a policy against a driver and refuses a driver that claims too little, and it trusts a driver that claims too much. So every claim above comes from a run on Apple Silicon, macOS 15.7.9, arm64. Two layers were tried and then dropped:

  • There is no system call filter. (deny syscall-unix (syscall-number 26)) compiles, applies, and ptrace still returns 0. Only the blanket (deny syscall-unix) has an effect, and that stops execve, so it cannot be used.
  • The other processes of the machine are not hidden. A sandboxed program reads the whole host process table through sysctl KERN_PROC_ALL, and taking sysctl-read away stops ordinary software starting. Darwin has no PID namespace. The program still cannot act on any process it sees.

What spawn refuses, and why a session still runs

macOS has no bind mount. A path means the same thing inside the sandbox and outside it only when nothing was remapped, and remapping is what a mount namespace does. So spawn refuses a config that needs a mount tree, by name, before it allocates anything and before it forks:

Refused Because
a root that is not / there is no pivot_root
a bind whose target is not its source this is the mount gap itself
an overlay mount macOS has no overlayfs
a procfs mount macOS has no procfs, and no PID namespace
a capped scratch area an ordinary user cannot mount a filesystem, so there is nothing to cap

The capped scratch area is refused and not quietly left out. A caller that asked for a bounded writable area and got an unbounded one would learn nothing until the disk filled.

A config whose every path stays where it is does run. That is what Layout.in_place builds in lib/chock-workspace/layout.zig: the checkout, the project’s own .git and the scratch object store each keep their real path, so the workspace becomes a set of rules instead of a set of mounts. This is why workspace_mounted is absent and a tool call still works.

Two things follow from having no mount tree. An absolute path that a program writes into a file names a directory that is deleted with the session. TMPDIR and CHOCK_SCRATCHPAD name one directory, and a file written through either survives the call. chock doctor reports both.

Checking a machine

chock doctor

chock doctor tries each layer for real, in a forked child, because a user namespace and a seccomp filter cannot be undone. It infers nothing from a kernel version.

Row Blocks a session
user namespace yes
mount namespace yes
pid namespace yes
ipc namespace yes
net namespace yes
router network yes
router filter yes
resolver files yes, when the sandbox has to take a host /etc and the kernel has no rootless overlayfs
landlock, with its ABI number yes
seccomp yes
write^execute no, it is hardening and not a boundary
pidfd yes
disk cap tmpfs yes
overlayfs no on its own. The resolver files row is where it stops a tool call
cgroup v2, with the vantage it found no, it degrades
device passthrough no, and it is absent outright on a build with none rather than reading unsupported
nix no
dev shell no
credential yes, when one is configured and unreadable
toolchain cache no
workspace space, against a 1 GiB floor no
card seal no
audit sink, one row per required sink no

It exits 0 when a first run can work here, even with a row degraded, and 2 when a blocking row is off. A machine that works with a layer missing gets one answer for a script and the whole report for a person.

pidfd is there so a cancelled tool call is signalled by handle. A late cancel then reaches nothing, rather than a stranger that took the same process id.