Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Zygo — a warm sandbox per request

CI crates.io PyPI npm Hex Licence: Apache-2.0

Zygo forks a warm, sandboxed interpreter for every request: 1.4 ms through its API, and every request starts from a process that has never served one. Rootless, OCI images, and no daemon to install: the one long-lived process is a supervisor under your own user, not a system service.

This page is the first page of the Zygo book, also on the web at mhmtskrc2.github.io/zygo.

Try it now, once Zygo is installed — one program in a fresh sandbox, thrown away when it exits:

zygo run python:3.12-slim python3 -c 'print("hello")'   # pulls the image the first time

And what Zygo is for — a warm function, forked for every request:

zygo serve ./handler.py --name resize        # a warm zygote: ~150 ms, once
zygo exec resize '{"url": "..."}'            # a fresh, sandboxed process: 2.8 ms (1.4 by API)
zygo exec resize '{"url": "..."}'            # and again, from the same clean copy

Why

A warm worker that serves many requests is fast and dirty: request n sees whatever request n-1 left behind. A container per request is clean and slow. Zygo is the third thing. zygo serve starts an interpreter, lets it do its imports, and parks it inside a sandbox. zygo exec forks it. Each child has its own cgroup, deadline and secrets, and is thrown away afterwards.

The same import-heavy Python script, run fresh for each call on one host (a Linux 6.8 VM, 2 vCPU, aarch64; chapter 25 has the method, and make bench-embed repeats it):

usuallywhat it pays for
docker run --rm542 msdaemon, containerd, shim, runc, a container object to remove
zygo run70.8 msa fresh sandbox — namespaces, cgroup, mounts — in one process
zygo exec2.8 msa fork() of the warm interpreter, plus starting the CLI

Most of the 70.8 ms is Python importing those sixteen modules: the sandbox itself is about 3.6 ms, and python3 -c pass inside one is 12 ms. Through the HTTP API rather than the CLI, a warm request is 1.44 ms usually and 10.5 ms for 1 in 100, and one function sustains 1,108 requests a second. zygo bench all reproduces every number on your own machine.

The zygote idea is older than Zygo: Android starts apps that way, and serverless research forked handlers from a pre-imported process in SOCK (Oakes et al., USENIX ATC 2018) and restored them from a snapshot in Catalyzer (Du et al., ASPLOS 2020). Zygo’s part is the packaging — one static binary, rootless, every limit on, an agent protocol any language can speak — not the idea.

Why not bubblewrap, nsjail, nono, sandbox-runtime or kern?

They are good at what they do, and none of them keeps a sandboxed process warm and forks it per request.

What it isWhat Zygo adds
bubblewrap, nsjailbuilding blocks for one confined processimages, mandatory limits, an egress allowlist — and the warm fork
nono, sandbox-runtimeconfinement for a command you were running anyway (Landlock and seccomp, or bubblewrap and Seatbelt)a sandbox with its own root filesystem and limits, for code you did not write
kerna daemonless, rootless container per call, in a few msa warm interpreter: no interpreter start and no imports on the request path
E2B, Modal, Daytonaa microVM or gVisor per session, in their cloudruns on your hardware, and costs a fork rather than a VM per call
Sandlock, Zeroboota copy-on-write fork of a Landlock-confined process, or of a Firecracker snapshota fork that lands in a sandbox with its own root, pid namespace, cgroup and network — and the tenants, secrets and API around it

Similar projects compares all of them but sandbox-runtime, with measurements against nsjail and kern.

The boundary

The default backend, ns, is the host kernel: namespaces, cgroup v2, a seccomp allowlist, Landlock, no capabilities and a read-only root. That is the right wall for code that is semi-trusted — your customers’ scripts, an agent’s tools. A kernel bug is a way through it, as it is for every container. The same spec also runs on gvisor (a kernel in user space) or vm (libkrun), one-shot only. Neither is a full wall for hostile code yet. Both lack a network. A rootless gvisor cannot enforce its limits, and vm has none inside the guest and a kernel you build yourself. For anonymous code, the honest answer today is a separate machine.

make escape-linux attempts 21 of the vectors in the threat model, with 0 escapes, and every syscall number is swept against the seccomp profiles. The same chapter lists the tenant-against-tenant vectors not attempted yet, and says where the boundary is weaker than it looks. No external audit has been done. Fork safety, question by question covers what a fork shares with its parent and what it does not.

Install

# Linux, x86_64 or aarch64: one static binary, checked against the release's checksums
url=https://github.com/mhmtskrc2/zygo/releases/latest/download
curl -fsSLO "$url/zygo-$(uname -m)-unknown-linux-musl.tar.gz"
curl -fsSL "$url/SHA256SUMS" | sha256sum -c --ignore-missing
tar xzf zygo-*-unknown-linux-musl.tar.gz && sudo install -m 0755 zygo-*/zygo /usr/local/bin/

brew install mhmtskrc2/zygo/zygo      # macOS: the shim, and a Linux VM it manages
cargo install zygo-cli                # from source

Linux needs kernel 5.3 or newer (6.1 recommended), unprivileged user namespaces and delegated cgroup v2. On a Mac every command runs in a Linux VM, about 22 ms away. There is also a signed container image. Getting started covers all of it, signatures included.

zygo doctor                           # can this host run sandboxes? prints the fix if not
zygo run --mem 128M --timeout 10s python:3.12-slim python3 -c 'print("hello")'   # pulls the image
echo 'def handler(event): return {"got": event}' > handler.py
zygo serve ./handler.py --name echo && zygo exec echo '{"n": 1}'   # serve never pulls
zygo stop --all                       # everything serve started; on a Mac, the VM too

What else is in the box

  • One file per project. sandbox.toml declares functions, their images, limits, network and secrets; zygo up deploys it blue/green and pins image digests in zygo.lock. Chapter 20
  • Every limit is on by default — memory, CPU, pids, wall clock, scratch, open files — and the deadline kills the request’s whole process tree.
  • The network is off by default. egress is an allowlist of names; private ranges and the cloud metadata address stay closed. Chapter 14
  • Secrets are files, written from outside the sandbox for one request: never environment variables, never in the warm process’s memory.
  • Any language. Python and Node agents ship; anything else is a fresh process per request in a held sandbox (about 1.4 ms), or an agent of your own against the protocol.
  • For programs: an HTTP API, dependency-free Python and Node clients (zygo-sdk), an Elixir client (zygo_sdk), and an MCP server. Chapter 17
  • For a multi-tenant product: tenants with their own tokens and budgets, a script or a whole workspace (a tar) sent with each request, streamed output, and cancellation — all over the API.

Building on it? Read 13 (warm functions), then 17 (the API, the SDKs, MCP), then 23 (the threat model), in that order.

Status

v0.1.x: one machine; Linux in production, macOS for development. The warm path is ns-only by decision (ADR 0002). ROADMAP.md says what comes next.

Not for: an interactive session or a REPL (a request is one call, not a shell you keep); more than one machine (no scheduler, no cluster); GPUs; Windows without WSL2.

More

Contributing · Security policy · Changelog · Roadmap · Licence: Apache-2.0

Zygo is not affiliated with Zygo Corporation, the metrology company.