Zygo — a warm sandbox per request
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):
| usually | what it pays for | |
|---|---|---|
docker run --rm | 542 ms | daemon, containerd, shim, runc, a container object to remove |
zygo run | 70.8 ms | a fresh sandbox — namespaces, cgroup, mounts — in one process |
zygo exec | 2.8 ms | a 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 is | What Zygo adds | |
|---|---|---|
| bubblewrap, nsjail | building blocks for one confined process | images, mandatory limits, an egress allowlist — and the warm fork |
| nono, sandbox-runtime | confinement 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 |
| kern | a daemonless, rootless container per call, in a few ms | a warm interpreter: no interpreter start and no imports on the request path |
| E2B, Modal, Daytona | a microVM or gVisor per session, in their cloud | runs on your hardware, and costs a fork rather than a VM per call |
| Sandlock, Zeroboot | a copy-on-write fork of a Landlock-confined process, or of a Firecracker snapshot | a 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.tomldeclares functions, their images, limits, network and secrets;zygo updeploys it blue/green and pins image digests inzygo.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.
egressis 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.
The Zygo book
← Previous page: the project README — the book’s first page, and the five-minute version of everything below.
All of Zygo’s documentation, in one place, written to be read from the start.
It begins with Container 101: what the Linux kernel gives you to build a sandbox, and what Docker builds from it. Then it explains Zygo — how it works, where it saves time and memory, and how it compares with everything around it. Then it teaches you to use it, and ends with the full reference: every command, every flag, every field, every file and every exit code. You do not need to know any of it already. If you can use a shell and you know what a process is, you can read every page.
Each section is short on purpose — a few sentences, a picture where one
helps, and a link to the detail. Read Parts I and II once; after that, the
headings work as a reference. This book is the source of truth: when Zygo
changes, the book changes in the same commit (AGENTS.md).
Part I — Container 101
| 0 | The project README | What Zygo is, why it exists, and how to try it — the first page. |
| 1 | The kernel and the process | What a process is, how one is born, and who may do what. |
| 2 | Namespaces | How a process gets its own view of the machine. |
| 3 | Control groups | How a group of processes gets a limit on what it can use. |
| 4 | The other locks | Capabilities, seccomp, Landlock, the root filesystem, the network. |
| 5 | Docker | What a container is, what an image is, and where Docker’s time goes. |
Part II — Zygo, explained
| 6 | How Zygo works | The one-shot sandbox, the warm zygote, and the parts around them. |
| 7 | Where the time and memory are saved | Each saving, how big it is, and what it costs. |
| 8 | The rules Zygo is built on | Eight principles, and the price of each. |
| 9 | FreeBSD jails, and Zygo | The older idea, and whether “jails for Linux” is fair. |
| 10 | Similar projects, and Docker side by side | nsjail, bubblewrap, kern, gVisor, Firecracker…; docker run against zygo run, flag by flag. |
Part III — Using Zygo
| 11 | Getting started | Install, check the host, run a sandbox, warm a function. |
| 12 | One-shot sandboxes | zygo run, step by step. |
| 13 | Warm functions | Handlers, warm-exec, runtime pools, and living with them. |
| 14 | Limits, networking and secrets | What each limit does, the egress allowlist, secrets as files. |
| 15 | Images and dependencies | Registries, venvs, apt layers, bytecode, cleaning up. |
| 16 | Deploying and running in production | zygo up, watching, upgrading, containers, Kubernetes, capacity. |
| 17 | The HTTP API, the SDKs and MCP | Calling Zygo from a program or an AI agent. |
| 18 | Writing an agent | A warm path for a language of your own. |
Part IV — Reference
| 19 | Every command | Every command and flag, with defaults and exit codes. |
| 20 | sandbox.toml, field by field | Every section and field, its default and its rules. |
| 21 | Environment, files and exit codes | Every variable read, every file written, every way it ends. |
| 22 | Troubleshooting | The errors people hit, and the fix for each. |
Part V — Security and speed
| 23 | Security: the threat model | Every attack, what stops it, and whether a test tries it. |
| Fork safety, question by question | What a forked request shares, what it does not, and where that is weaker than it sounds. | |
| 24 | Seccomp profiles | The three syscall profiles, and which to choose. |
| 25 | What Zygo costs | Every measured number, and the machine it came from. |
Part VI — Decisions
| 26 | Why it is built this way | The design decisions in plain words; the full records are in adr/. |
| Glossary | Every term in the book, in one line each. |
Where to start
first time here? ────────────────▶ the README, then this page
new to containers? ──────────────▶ Part I, then Part II
know Docker, new to Zygo? ───────▶ chapters 6, 7, 10, then 11
want to use it today? ───────────▶ chapter 11, then 13
looking something up? ───────────▶ Part IV
deciding whether to trust it? ───▶ chapter 23
Three sentences to hold on to
- A container is not a thing in the kernel. It is a normal process with several limits put on it; the kernel has no idea what a “container” is.
- The limits are cheap; the tools around them are not. Setting up the limits takes about a millisecond. Docker takes hundreds, and most of that is programs talking to programs.
- Zygo removes the tools, then removes the start-up. It sets the limits up
itself, in one process, and then keeps a ready copy of your program waiting,
so a request costs one
fork().
How the numbers are used
Every number about Zygo in this book comes from chapter 25,
which names the machines and the commands. Numbers about other projects are
their own claims or commonly measured ranges, and they are marked that way.
zygo bench all repeats Zygo’s numbers on your own machine.
1. The kernel and the process
This chapter is about the basics that come before namespaces and
containers: the kernel, system calls, processes, fork and exec, users and
root. They are old and simple, and they are the ground everything else
stands on. A namespace, a cgroup, a container and a Zygo sandbox are all just
new rules added to these same old pieces. If a later chapter feels confusing,
the missing piece is usually here.
chapter 1: kernel · syscalls · processes · fork/exec · users · capabilities
▲
chapter 2–4: │ namespaces, cgroups and filters are rules ON these pieces
▲
chapter 5: │ a container = one process + those rules + a file tree
▲
chapter 6: │ a Zygo sandbox = the same, with no daemon, kept warm
The kernel and user space
The kernel is the one program that talks to the hardware. It owns the memory, the CPU time, the disks and the network cards, and it decides who gets which. Everything else — your shell, Python, a database — runs in user space and cannot touch the hardware directly. When a program in user space needs something, it has to ask the kernel. That split is the first and oldest boundary on the machine, and every sandbox in this book is a way of making the kernel say “no” more often.
USER SPACE ┌───────┐ ┌────────┐ ┌──────────┐ ┌──────────┐
(programs) │ shell │ │ python │ │ postgres │ │ your job │
└───┬───┘ └───┬────┘ └────┬─────┘ └────┬─────┘
│ syscalls │ │ │
═══════════════════╪══════════╪════════════╪═════════════╪══════ the boundary
▼ ▼ ▼ ▼
KERNEL ┌─────────────────────────────────────────────────┐
│ processes · memory · files · network · devices │
└─────────────────────────────────────────────────┘
HARDWARE CPU RAM disk network card
System calls
The way a program asks the kernel for something is a system call, or
syscall. Opening a file is openat, reading from it is read, starting a
process is clone, and there are about 450 of them on a modern Linux. A
syscall is a small, well-defined door: the program puts a number and some
arguments in place and the kernel answers. Because every request passes
through these doors, the kernel can check each one. It is also why the number
of doors matters for safety — each is code in the kernel that a hostile
program can try to confuse.
Processes
A process is a running program: its memory, its open files, its user, and the
kernel’s notes about it. Every process has a number, the pid, and a parent.
The first process on the machine is pid 1, and every other one descends from
it, so the processes form a tree. ps -ef --forest shows that tree. A sandbox
is, at the end, a branch of this tree that the kernel treats differently from
the rest.
fork and exec
Linux starts a new program in two steps. fork makes a copy of the calling
process — same memory, same open files — and both copies carry on from the
same line. exec (execve) then replaces the copy’s memory with a new program
loaded from disk. A shell that runs ls forks itself, and the child execs
ls. The split looks strange, but it gives you a moment between the two steps
where the child can change its own situation — close files, change user, enter
a sandbox — before the new program starts. Every sandbox in this book does its
work in that moment.
shell (pid 100)
│
│ fork()
├──────────────────▶ copy of shell (pid 101)
│ │
│ │ ◀── the moment: change user, close files,
│ │ enter namespaces, join a cgroup,
│ │ install filters …
│ │
│ │ execve("/bin/ls")
│ ▼
│ ls (still pid 101)
│ │ exit
▼ wait() ◀───────────────┘
Copy-on-write
A fork does not really copy the memory. Parent and child share the same physical pages, marked read-only, and a page is copied only when one of them writes to it. So forking a 50 MB process takes well under a millisecond and costs almost no new memory. The child still behaves as if it had its own full copy: what it writes, the parent never sees. This one trick is the base of Zygo’s warm path, and chapter 6 comes back to it.
right after fork after the child writes to page B
──────────────── ────────────────────────────────
parent child parent child
│ │ │ │
▼ ▼ ▼ ▼
┌───┬───┬───┐ ┌───┬───┬───┐ ┌────┐
│ A │ B │ C │ │ A │ B │ C │ │ B' │
└───┴───┴───┘ └───┴───┴───┘ └────┘
one set of pages, shared parent sees A B C
and marked read-only child sees A B' C (one page copied)
Users and root
Every process runs as a user, which the kernel knows as a number, the uid. Files have an owner uid and permission bits, and the kernel checks them on every access. uid 0, root, has always been special: for most checks, root simply passes. That made root the prize of every attack, because one bug in a root program gave away the whole machine. Much of the history of Linux security is the slow work of making root less all-powerful.
Capabilities
Capabilities split root’s power into about forty named pieces. CAP_NET_ADMIN
lets a process change the network, CAP_SYS_ADMIN lets it mount file systems
and much more, CAP_KILL lets it signal anyone. A process can hold some pieces
and not others, and it can drop the ones it does not need, for good. A sandbox
usually drops all of them. Zygo does: a program inside it has an empty
capability set.
Everything is a file, and /proc
Linux shows a lot of its state as files. /proc is a file system the kernel
makes up on the fly: /proc/1234/ describes process 1234, /proc/meminfo
describes memory. /sys does the same for devices and, as
chapter 3 shows, for control groups. This matters for
sandboxes in two ways. Many controls are set by writing a small file, which is
fast and needs no special tool. And a sandbox must be careful what parts of
/proc and /sys it shows, because some of those files are doors of their
own.
One kernel
Here is the fact the rest of the book keeps coming back to. On one Linux
machine, every process — in a container or not — talks to the same kernel.
Namespaces, control groups and filters are all rules inside that kernel.
If the kernel itself has a bug that lets a process break its rules, every
rule fails at once. That is why some sandboxes add a second kernel (gVisor) or
a whole virtual machine (Firecracker, Zygo’s vm backend); chapter 10
compares them.
containers / Zygo `ns` gVisor microVM (Firecracker, Zygo `vm`)
────────────────────── ────── ────────────────────────────────
┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐
│ app │ │ app │ │ app │ │ app │ │ app │ │ app │
└──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘
│ │ │ ┌──▼─────────────┐ ┌──▼────────┐ ┌──▼────────┐
│ │ │ │ user-space │ │ guest │ │ guest │
│ │ │ │ kernel │ │ kernel │ │ kernel │
│ │ │ └──┬─────────────┘ └──┬────────┘ └──┬────────┘
┌──▼───────▼───────▼──┐ ┌──▼─────────────┐ ┌──▼─────────────────▼───────┐
│ host kernel │ │ host kernel │ │ host kernel + KVM │
└─────────────────────┘ └────────────────┘ └────────────────────────────┘
one kernel bug = all out a bug must pass two a bug must pass the guest
kernel AND the hardware wall
2. Namespaces
A namespace changes what a process can see. It does not limit how much it can use — that is control groups, the next chapter.
What a namespace is
Normally every process sees the same machine: the same list of processes, the same files, the same network, the same host name. A namespace gives a group of processes their own copy of one of those things. Inside a new PID namespace, for example, a process sees only its own children and thinks it is pid 1. Nothing is copied and nothing is emulated; the kernel just keeps a separate table and answers from it. Linux has eight kinds, and each one can be used alone or with the others. A “container” is simply a process that has its own copy of most of them.
the host sees the sandbox sees
┌──────────────────────────────────┐ ┌─────────────────────────────┐
│ pids 1 systemd … 4200 zygo │ │ pids 1 python 2 sh │
│ 4201 python 4202 sh │ │ │
│ files /home /etc /var … │ │ files / of the image only │
│ net eth0 wlan0 lo + LAN │ │ net lo │
│ name my-laptop │ │ name sandbox │
│ users 0 root … 1000 you │ │ users 0 root (= you, 1000)│
└──────────────────────────────────┘ └─────────────────────────────┘
same processes 4201 and 4202 — two different views of them
| Namespace | Its own copy of | Since |
|---|---|---|
| mount | the list of mounted file systems | 2.4.19 (2002) |
| UTS | host name | 2.6.19 (2006) |
| IPC | shared memory, message queues | 2.6.19 |
| PID | process numbers | 2.6.24 |
| network | interfaces, addresses, routes, firewall | 2.6.29 |
| user | user and group numbers | 3.8 |
| cgroup | the view of the cgroup tree | 4.6 |
| time | boot-time clocks | 5.6 |
Mount namespace
The mount namespace gives a process its own list of mounted file systems. It
can mount and unmount things, and the rest of the machine does not see it.
This was the first namespace, added in 2002, and it is why the flag is still
called just CLONE_NEWNS — nobody expected others. A sandbox uses it to build
a whole new file tree from an image, then switch its root to that tree, so the
host’s files are simply not there. Chapter 4
explains how the switch is done.
PID namespace
The PID namespace gives a process its own process numbers. The first process inside becomes pid 1, and it can only see and signal processes in the same namespace, or in namespaces below it. From outside, the same processes are still visible, with their normal host numbers. So a sandboxed program cannot list, watch or kill anything on the host. When pid 1 of the namespace exits, the kernel kills everything else inside it, which makes clean-up easy.
Network namespace
The network namespace gives a process its own network: its own interfaces, its
own addresses, its own routes and its own firewall rules. A new one has only a
loopback interface (lo), so by default the process can reach nothing but
itself. To give it more, something outside has to connect it — a virtual cable
to the host, or a program that moves packets in user space.
Chapter 4 covers both, and Zygo’s choice.
UTS namespace
UTS is an old name for “the system’s name”. This namespace gives a process its
own host name and domain name. It is the smallest of the eight and hides no
secret; it mostly stops a program from learning the host’s name, or changing
it. Container tools set it so that hostname inside prints something sensible.
IPC namespace
IPC means “inter-process communication”. This namespace gives a process its own System V message queues, shared memory segments and POSIX message queues. Without it, two programs on one machine could meet through a shared memory segment with a guessable key. It is old and rarely thought about, but a sandbox that skips it leaves a small side door open.
User namespace
The user namespace gives a process its own list of users. Inside it, a process
can be uid 0 — root — while outside it is still your own normal uid. A uid
map (/proc/<pid>/uid_map) says which inside number matches which outside
number. Root inside a user namespace has full capabilities, but only over
things that belong to that namespace: its own mounts, its own network, never
the host’s. This is the namespace that lets a normal user create all the
others, and it is what makes rootless containers — and all of Zygo — possible.
inside the sandbox uid_map on the host
────────────────── ───────────────── ──────────────
uid 0 "root" ───▶ 0 1000 1 ───▶ uid 1000 (you)
uid 1000 "app" ───▶ (a second line, via newuidmap, if the host allows)
any other uid ───▶ not mapped: shows as "nobody", owns nothing
root inside = every capability over the sandbox's own mounts, network, …
= no power at all over anything that belongs to the host
Cgroup namespace
The cgroup namespace changes how a process sees the control-group tree from chapter 3. Inside it, the process’s own group looks like the top of the tree, so it cannot learn where it sits on the host. It hides information; it does not set any limit. Zygo creates one, and in addition does not mount the cgroup file system inside the sandbox at all.
Time namespace
The newest one, added in Linux 5.6. It lets a process see a different value for the clocks that count time since boot. Its main use is moving a running container to another machine without its clocks jumping. Sandboxes for short jobs rarely need it, and Zygo does not create one.
Creating and joining
Three syscalls do the work. clone (and the newer clone3) starts a child in
new namespaces. unshare moves the calling process into new namespaces.
setns joins a namespace that already exists, given a file that points to it,
such as /proc/<pid>/ns/net. Zygo uses clone3 to build a sandbox, because
it makes the child pid 1 of its new PID namespace in one step. It uses setns
on the warm path, to put a new process into a sandbox that is already built.
clone3(flags) unshare(flags) setns(fd)
───────────── ────────────── ─────────
parent process process existing
│ │ │ namespace
└─▶ child, born moves itself into └──────────▶ ┌─────┐
inside NEW NEW namespaces │ net │
namespaces └─────┘
used by Zygo to used by `unshare(1)` used by Zygo to enter
BUILD a sandbox and many tools a WARM sandbox
What namespaces do not do
Namespaces hide things; they do not count or limit anything. A process in its own namespaces can still use all the memory, fill the process table with a fork bomb, or keep every CPU busy. Namespaces also do nothing about which syscalls a process may call, so the whole kernel is still in reach. Those gaps are filled by control groups (chapter 3) and by seccomp and Landlock (chapter 4). You need all of them together; any one alone is not a sandbox.
Seeing them yourself
lsns lists the namespaces on the machine. ls -l /proc/self/ns shows the
ones your shell is in, each as a number. unshare --user --map-root-user --pid --fork --mount-proc bash starts a shell where you are root and pid 1 —
without being root on the host. Type ps inside it and you will see two
processes. That small command is most of the idea behind every tool in this
book.
3. Control groups
Namespaces change what a process can see. Control groups, or cgroups, limit what it can use.
What a cgroup is
A cgroup is a group of processes that the kernel counts and limits together. You can say “this group may use 256 MB of memory, half a CPU and at most 64 processes”, and the kernel holds every process in the group to it. A child process starts in its parent’s group, so a program cannot escape its limit by forking. Without cgroups, one bad program could use up the whole machine; with them, it can use up only its own share.
The tree, as files
Cgroups are shown as folders under /sys/fs/cgroup. Each folder is a group,
and a folder inside it is a smaller group inside the bigger one. You create a
group with mkdir, move a process into it by writing its pid to
cgroup.procs, and set a limit by writing to a file such as memory.max.
There is no special tool and no daemon; it is plain file work. Limits nest: a
group can never use more than its parent allows, whatever its own files say.
/sys/fs/cgroup/ the whole machine
├── system.slice/ system services
└── user.slice/
└── user-1000.slice/
└── user@1000.service/ ◀── handed to you (delegation)
└── zygo.slice/ memory.max = RAM − reserve
├── system/ the supervisor, protected
└── tenants/
└── acme/ one customer's budget
└── resize/ one function: cpu.max, pids.max
└── g4242-1/ one warm sandbox
├── zygote the warm process: memory.max = mem
├── req-01f3… one request ─┐ each has its own group and
└── req-01f4… one request ─┘ its own memory.max = mem
This is Zygo’s real tree; crates/zygo-core/src/cgroup.rs describes each level.
The memory limit is on the leaves on purpose. The function’s group holds the
warm process and every request at once, so a memory.max there would be one
budget for all of them, and a single request going over it would take the
others with it. On its own group, a request that goes over mem is killed
alone, and the warm process and the requests beside it go on.
Controllers
Each kind of resource is handled by a controller. The ones a sandbox cares about most are these:
| Controller | Limits | Example file |
|---|---|---|
memory | RAM, and what happens when it runs out | memory.max |
cpu | CPU time, as a share or a hard quota | cpu.max |
pids | how many processes and threads | pids.max |
io | disk reads and writes | io.max |
When a group goes over memory.max, the kernel’s OOM killer (“out of
memory”) kills a process in that group — not somewhere else on the machine.
When it hits pids.max, fork just fails, which is how a fork bomb is
stopped.
Version 1 and version 2
Linux has two versions of cgroups. Version 1 had a separate tree for each
controller, which was flexible but confusing, and hard to hand to a normal
user safely. Version 2 has one tree for everything, with clearer rules. Most
current distributions use version 2 only. Zygo needs version 2; zygo doctor
checks for it and says so if it is missing.
Delegation: cgroups without root
The cgroup tree belongs to root, so how can a normal user create groups? The
answer is delegation: the system gives a user one branch of the tree, and
the user can do what they like inside it. On a systemd machine every login
already has such a branch, under user@<uid>.service. Zygo creates its whole
tree inside that branch, which is why it needs no root at all.
Troubleshooting covers the machines where the
branch is missing or missing a controller.
Killing a whole group
Killing one process is easy; killing everything it started is not, because a
process can fork faster than you can list its children. Cgroup version 2 has a
file for this, cgroup.kill: write 1 to it and the kernel kills every
process in the group at once (Linux 5.14 and newer). Zygo uses it for every
deadline: when a request runs out of time, one file write ends the request
and all its children, and nothing is left over.
req-01f3/ echo 1 > req-01f3/cgroup.kill
├── python (the request)
│ ├── sh ─▶ all of them, gone in one step,
│ │ └── curl even ones forked a moment ago
│ └── python (a worker)
└── … (a fork bomb in progress)
Counting, not only limiting
A cgroup also keeps numbers. memory.peak says the most memory the group ever
used, memory.events says whether the OOM killer fired, and cpu.stat says
how much CPU was used and how often the group was held back by its quota.
Zygo reads these after each request to report why it ended: out of time, out
of memory, or neither. Its benchmark also reads cpu.stat, and refuses to
judge a latency number when the group was held back, because that number
would describe the limit rather than the code.
Freezing
The cgroup.freeze file stops every process in a group without killing it,
and a second write lets them carry on. The processes keep their memory; they
simply get no CPU time. Zygo uses this to pause a warm function that has
been idle for a while. Waking it again costs one write, much less than
starting it over.
Namespaces and cgroups, side by side
| Namespaces | Cgroups | |
|---|---|---|
| Question they answer | What can this process see? | How much can this process use? |
| Unit | one kind of thing: mounts, pids, network… | one group of processes |
| Stop a fork bomb? | no | yes, pids.max |
| Hide the host’s files? | yes, mount namespace | no |
| Needed for a sandbox | yes | yes |
Neither one limits which syscalls a process may call. That is the next chapter.
4. The other locks
Namespaces decide what a process sees, and cgroups decide how much it uses. This chapter is about the rest: what it is allowed to do, and what its world is built from.
Why more locks are needed
A process in its own namespaces, inside a tight cgroup, can still call almost every syscall the kernel has. Some of those syscalls are large and complex, and most serious container escapes of the last ten years went through one of them. So a sandbox adds locks that work at a finer level: which powers the process holds, which syscalls it may make, and which files it may touch. Each lock is simple on its own. Together they mean that one mistake is not enough to get out.
┌──────────────────────────────────────────────────────────────┐
│ namespaces what it can SEE │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ cgroup how much it can USE │ │
│ │ ┌──────────────────────────────────────────────────┐ │ │
│ │ │ capabilities + no_new_privs what POWER it holds │ │ │
│ │ │ ┌────────────────────────────────────────────┐ │ │ │
│ │ │ │ Landlock which FILES / PORTS │ │ │ │
│ │ │ │ ┌──────────────────────────────────────┐ │ │ │ │
│ │ │ │ │ seccomp which SYSCALLS │ │ │ │ │
│ │ │ │ │ ┌──────────────┐ │ │ │ │ │
│ │ │ │ │ │ your program │ │ │ │ │ │
│ │ │ │ │ └──────────────┘ │ │ │ │ │
│ │ │ │ └──────────────────────────────────────┘ │ │ │ │
│ │ │ └────────────────────────────────────────────┘ │ │ │
│ │ └──────────────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
to get out, a program has to beat every layer — or the kernel itself
Dropping capabilities
As chapter 1 said, capabilities are root’s power cut into pieces. Inside a user namespace a process can hold all of them — over that namespace. A sandbox then drops every one it does not need, from every set the kernel keeps, so they cannot come back. Docker keeps fourteen by default, to be useful to normal software. Zygo keeps none, because a function has no need to change the network or create device files.
no_new_privs
Some programs on disk are marked setuid: they run as their owner, often
root, whoever starts them. sudo and passwd work this way. Inside a
sandbox, that would be a way to gain power back. The no_new_privs flag,
set once on a process, tells the kernel that nothing this process or its
children exec may ever gain power, setuid or not. It cannot be unset. Every
serious sandbox sets it, and seccomp needs it before a normal user may install
a filter.
seccomp
seccomp (“secure computing”) lets a process install a small filter that the kernel runs on every syscall it makes. The filter is a tiny program, written in classic BPF, that looks at the syscall number and its arguments and answers “allow”, “fail with an error” or “kill”. Once installed it cannot be removed, and children inherit it. Docker’s default filter lists what is blocked and allows about 350 syscalls. Zygo’s lists what is allowed — about 215 names, of which 190 exist on an arm64 kernel and all on x86_64 — so a syscall added to the kernel next year is blocked until someone chooses to allow it. Seccomp profiles has the full lists.
program ── syscall(nr, args) ──▶ ┌─────────────────────────┐
│ seccomp filter (BPF) │
│ read, write, openat … │──▶ allow ──▶ kernel does it
│ clone + CLONE_NEW* │──▶ EPERM ──▶ "not permitted"
│ bpf, io_uring, ptrace, │──▶ EPERM
│ mount, unshare … │
│ anything not listed │──▶ EPERM
└─────────────────────────┘
Landlock
Landlock, added in Linux 5.13, lets a normal process limit its own access to
files, and from 6.7 its network connections too. The process says, for
example, “from now on I may read under /usr and write under /tmp, and
nothing else”, and the kernel enforces it, even against root inside the
sandbox. Like seccomp, it can only be tightened, never loosened. It is a
second wall behind the mount namespace: if a mistake ever made a host path
visible inside, Landlock would still refuse to open it. Zygo applies it
wherever the kernel has it.
AppArmor and SELinux
These are security modules: rules, loaded by the system’s administrator, that the kernel checks for every process. Docker on Ubuntu loads an AppArmor profile for each container; on Fedora and Red Hat, SELinux labels do the same job. They are strong, but they need root to set up, so a rootless tool like Zygo cannot use them for its own sandboxes. They can get in its way, though: Ubuntu’s AppArmor rules limit unprivileged user namespaces, and troubleshooting explains the fix.
Resource limits (rlimits)
rlimits are the old, per-process limits that came before cgroups: how many
files a process may have open, how big a file it may write, how much stack
it may use. You see them with ulimit -a. They are weaker than cgroups
because they count per process, not per group. They are still useful for the
few things cgroups do not cover, such as open files. Zygo sets nofile (1024
by default) this way.
The root filesystem
Inside a mount namespace a sandbox builds a new file tree, usually from an
image, and makes it the root. The old way is chroot, which only changes where
path lookups start and has well-known ways out. The better way is
pivot_root: it swaps the old root for the new one in the mount namespace,
and then the old root can be unmounted, so the host’s files are not just
hidden but gone from this view. Zygo uses pivot_root, mounts the root
read-only, and gives the sandbox one small writable place, /tmp, in memory.
Layers: overlayfs, bind mounts and tmpfs
Three kinds of mount do most of the work. overlayfs stacks folders on top of each other and shows them as one, which is how an image made of several layers becomes one tree without copying anything; a normal user may use it from Linux 5.11. A bind mount shows an existing file or folder at a second place — this is how your code gets into a sandbox, and it can be made read-only. tmpfs is a file system in memory, which vanishes when the last process using it exits. Build the root from overlayfs, add your files with bind mounts, give it a tmpfs to write to, and you have a container’s file system.
what the program sees as / where it really comes from
────────────────────────── ──────────────────────────
/app/handler.py (read-only) ◀───── bind mount of ./handler.py on the host
/tmp (writable) ◀───── tmpfs, in memory, 64M, gone at exit
/usr /lib /bin … (read-only) ◀───── overlayfs of the image's layers:
┌───────────────────────┐
│ layer 3 pip packages │
│ layer 2 python │
│ layer 1 debian base │
└───────────────────────┘
stored once, shared by every sandbox
The network
A new network namespace has only loopback. There are two common ways to
connect it. The first is a veth pair: a virtual cable with one end inside
and one on the host, joined to a bridge with NAT — Docker’s way, which needs
root on the host side. The second is a program that moves packets between the
namespace and the host’s normal sockets, in user space and as your own user;
slirp4netns and pasta do this. Zygo uses pasta, and puts an nftables
firewall inside the sandbox’s own namespace, where your user is allowed to.
That firewall is how “this function may reach api.example.com:443 and
nothing else” is enforced.
Docker (bridge, root on the host) Zygo (pasta, your own user)
───────────────────────────────── ───────────────────────────
┌ container netns ┐ ┌ sandbox netns ─────────────┐
│ eth0 │ │ tap0 │
└──┬──────────────┘ │ nftables: allow only │
│ veth pair │ api.example.com:443 │
┌──▼──────────────┐ │ no 10.x / 192.168.x / │
│ docker0 bridge │ ◀─ iptables NAT │ 169.254.169.254 │
└──┬──────────────┘ └──┬─────────────────────────┘
▼ │ packets as data
host network: LAN, cloud metadata, ┌──▼──────────────┐
the internet — all reachable by default │ pasta (as you) │──▶ normal sockets
└─────────────────┘ on the host
Putting it together
A sandbox on Linux is all of these, set up in the right order in the moment
between fork and exec:
clone3 into new namespaces (user, mount, pid, net, ipc, uts, cgroup)
→ write the uid map (from the parent)
→ join a cgroup with limits
→ build the root: overlayfs + bind mounts + tmpfs, then pivot_root
→ set rlimits, drop every capability, set no_new_privs
→ install Landlock, then the seccomp filter
→ execve your program
Each step is cheap — a namespace set is about a millisecond, a cgroup write a tenth of that, a filter microseconds. The whole list is the sandbox. The rest of this book is about who runs this list, how often, and what they put around it.
5. Docker
Docker did not invent any of the parts in the last three chapters. What it did was put them behind one simple command, and add a way to ship software that the whole industry now uses.
The problem Docker solved
Before 2013, putting a program on a server meant installing its language, its libraries and its settings on that server, and hoping they matched the developer’s machine. Docker’s answer was to ship the program with its whole file system, as one thing you can copy, and to run it in namespaces so it did not see or break the rest of the server. “It works on my machine” became “it works in this image”. The isolation was a side benefit; the packaging was the revolution.
Images and layers
An image is a file system plus a little metadata: which command to run,
which user, which environment. It is stored as a stack of layers, each a
tar file of changes on top of the one below. Two images based on the same
python:3.12 share those lower layers on disk and on the network. A layer is
named by the hash of its content, so it can never change without getting a
new name. That is why an image pulled today by digest is the same, byte for
byte, as the one pulled next year.
myapp:1.0 otherapp:2.3
┌─────────────────┐ ┌─────────────────┐
│ COPY . /app │ │ COPY . /app │ each layer is named by
├─────────────────┤ ├─────────────────┤ the hash of its content:
│ pip install … │ │ pip install … │ sha256:9f…, sha256:77…
└────────┬────────┘ └────────┬────────┘
└────────────┬─────────────┘
┌────────▼────────┐
│ python:3.12 │ shared: stored and
├─────────────────┤ downloaded once
│ debian:bookworm │
└─────────────────┘
Why a sandbox brings its own files
A common first question: Python is already installed on my machine, so why
does a container need an image with another Python in it? The answer is that
a program is never one file. /usr/bin/python3 needs libpython, the C
library, about a thousand files of standard library, SSL certificates, time
zone data and more, spread over /usr, /lib and /etc. To run the host’s
Python inside a sandbox, you would have to show the sandbox all of those
host folders — and hiding the host’s files is the sandbox’s first job. An
image solves this: it is a complete, separate set of files, so the sandbox
can see everything it needs and nothing of the host.
USING THE HOST'S PYTHON USING AN IMAGE
─────────────────────── ──────────────
┌ sandbox ────────────────────────┐ ┌ sandbox ────────────────────────┐
│ /usr/bin/python3 ◀── host │ │ /usr/bin/python3 ◀── image │
│ /usr/lib/python3.12 ◀── host │ │ /usr/lib/python3.12 ◀── image │
│ /lib (libc, libssl) ◀── host │ │ /lib (libc, libssl) ◀── image │
│ /etc (certs, config) ◀── host │ │ /etc (certs, config) ◀── image │
│ app.py │ │ app.py │
└─────────────────────────────────┘ └─────────────────────────────────┘
the sandbox can read big parts of the sandbox sees a complete world,
the host, and they change with apt and none of the host's files
An image is also a promise
There is a second reason, just as important. The Python on your laptop is
3.12, the one on the server is 3.10, and apt upgrade can change either one
tomorrow. A program that works on one machine may fail on the other, and
you cannot tell why. An image named by its digest is the same bytes on every
machine, today and next year. And an image costs almost nothing per run: it
is downloaded once, stored once, and shared read-only by every container that
uses it.
The Dockerfile
A Dockerfile is a recipe for building an image, one step per line:
FROM python:3.12, RUN pip install requests, COPY . /app. Each step runs
in a temporary container and its changes become a new layer. It is simple and
very widely known. The cost is that every change of a dependency means a new
build, a new image and a new push. Chapter 6
shows how Zygo avoids this.
OCI: the standards
The Open Container Initiative turned Docker’s formats into open standards.
The image spec says how an image and its layers are stored; the
distribution spec says how a registry serves them; the runtime spec says
how a folder plus a config.json becomes a running container. Because of
OCI, an image built by Docker runs under Podman, Kubernetes, or Zygo, and
comes from any registry: Docker Hub, GitHub, your own. Zygo reads OCI images
and uses none of Docker’s own code.
A container is a process
Run docker run -d nginx and then ps -ef on the host: nginx is there, as a
normal process with a normal pid. There is no box and no small machine. The
kernel sees a process with its own namespaces, in a cgroup, with a seccomp
filter and an AppArmor profile — exactly the list at the end of
chapter 4. “Container” is a word for
that process plus the metadata Docker keeps about it.
The chain behind docker run
docker itself does almost nothing. It sends your request over a socket to
dockerd, the daemon, which runs as root. dockerd asks containerd to
create the container. containerd starts a containerd-shim, which runs
runc. runc does the real work — namespaces, cgroup, mounts, filters — then
execs your program and exits. The shim stays, for as long as the container
lives, to hold its input, output and exit code.
docker ─▶ dockerd ─▶ containerd ─▶ containerd-shim ─▶ runc ─▶ your program
(CLI) (root) (root) (stays) (exits)
Five programs and three hand-overs for one command. Each exists for a good reason — upgrades without stopping containers, Kubernetes, many clients — and each costs time.
Where Docker’s time goes
docker run of a small Python script takes about 300 to 1000 milliseconds.
The isolation itself — the part runc sets up — is around one millisecond of
that. The rest is the chain above, creating the container’s record and its
writable layer, setting up the network bridge, and then starting Python and
its imports from nothing. docker exec into a running container skips some
of this and still costs 50 to 100 ms. For a service that runs for weeks,
none of it matters. For a function that runs for 10 ms, it is almost all of
the cost.
one `docker run` of a 10 ms Python function, roughly
~250 ms ~1 ms ~220 ms ~10 ms
┌────────────────────────────────┬─┬───────────────────────────┬──┐
│ docker → dockerd → containerd │▒│ Python starts and imports │██│
│ → shim; record, layer, network │▒│ its modules from nothing │██│
└────────────────────────────────┴─┴───────────────────────────┴──┘
├────────────┼────────────┼─────────────┼────────────┼────────────┤
0 100 200 300 400 500 ms
▒ the isolation itself
██ your code
What a container leaves behind
A container is an object, not only a process. When the program exits, the
container stays in docker ps -a, with its writable layer on disk and its
logs in the daemon, until someone runs docker rm, or passed --rm. This is
right for services: you can restart them, read their logs, step inside. For
a job that runs once, it is something to clean up.
Docker’s defaults
Docker’s defaults are made for running software you chose. The root file
system is writable, fourteen capabilities are kept, the process runs as root
inside, the network is a bridge that can reach your local network, and there
is no memory, CPU or process limit unless you ask. You can make a container
very tight — --read-only --cap-drop ALL --network none --memory ... — but
you have to know every flag. For running code you did not write, the
defaults point the wrong way. The comparison
lists them side by side with Zygo’s.
Rootless Docker and Podman
The root daemon has always been Docker’s weak spot: access to its socket is
the same as root on the host. Rootless Docker runs the whole chain as a
normal user inside a user namespace. Podman goes further: no daemon at all,
each container started by the podman command itself, with a small helper,
conmon, staying behind like the shim. Both are real steps forward. Both
still create container objects and still start your program from nothing, so
the per-run cost stays in the hundreds of milliseconds.
What Docker is for
Docker is the right tool for building images, running long-lived services, connecting them with networks, publishing ports and restarting them when they fail. Zygo does none of that, and uses Docker’s images happily. The question this book cares about is narrower: what if the thing you run is a short function, called thousands of times, written by someone you do not fully trust? The next chapter is the answer Zygo gives.
6. How Zygo works
Zygo uses the same kernel parts as Docker. What it changes is who sets them up, how often, and what is already waiting when a request arrives.
Zygo in one picture
┌──────────────────────── zygo (your user, no root) ──────────────────────────┐
│ │
zygo run ───────▶│ ONE-SHOT: build a sandbox ─▶ run the program ─▶ exit, nothing left ~12 ms │
│ │
zygo serve ─────▶│ WARM: build a sandbox once, start the interpreter, import the handler │
│ and park it as a "zygote" ~150 ms │
│ │
zygo exec ──────▶│ fork the zygote ─▶ the child runs one request ─▶ exit ~1.4 ms │
zygo exec ──────▶│ fork the zygote ─▶ the child runs one request ─▶ exit ~1.4 ms │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
There are two halves. The one-shot half is a better docker run: a fresh
sandbox per program, built in one process. The warm half is the reason the
project exists: a sandbox that is built once and then copied for every
request. The rest of the chapter takes them in that order.
The one-shot sandbox: zygo run
zygo run python:3.12 python3 app.py does the whole list from
chapter 4 itself. It reads the image
from its own store, works out a mount plan, calls clone3 into seven new
namespaces, and the child builds its root, joins its cgroup, drops every
capability, installs Landlock and seccomp, and calls execve. There is no
daemon, no RPC, and no container record. When the program exits, the kernel
removes the namespaces and the tmpfs, Zygo removes the cgroup, and nothing
is left to clean up. With the image already pulled, this takes a median of
12 ms on a Linux 6.8 VM. That is python3 -c pass. The 70.8 ms on the
README’s table is the same command running a script that imports sixteen
modules: the sandbox costs the same 3.6 ms, and the rest is Python doing the
imports — the work a warm function does once (chapter 25).
docker run zygo run
────────── ────────
docker ─▶ dockerd ─▶ containerd zygo
─▶ shim ─▶ runc ─▶ program └─ clone3 ─▶ child: mounts, cgroup,
caps, Landlock, seccomp
record + layer left: docker rm ─▶ execve ─▶ program
300–1000 ms nothing left · ~12 ms
The idea of a zygote
The name comes from Android. Starting a Java app from nothing is slow, so Android starts one process, the Zygote, loads the common libraries into it, and then forks it every time an app opens. Each app gets a ready-made process in a few milliseconds, and the loaded libraries are shared through copy-on-write. Zygo applies the same trick to a sandbox. The expensive part of running a Python function is not the sandbox, it is Python itself — starting the interpreter and importing modules often takes hundreds of milliseconds. So Zygo does that once, inside the sandbox, and forks the result.
The same idea reached serverless before Zygo. SOCK (Oakes et al., SOCK: Rapid Task Provisioning with Serverless-Optimized Containers, USENIX ATC 2018) forks Python handlers from a zygote that has already imported their packages, and Catalyzer (Du et al., Catalyzer: Sub-millisecond Startup for Serverless Computing with Initialization-less Booting, ASPLOS 2020) restores a function from a snapshot instead of starting it. Zygo’s contribution is the packaging — one static binary, rootless, every limit on, an agent protocol any language can speak — not the idea.
The warm path
zygo serve ./handler.py builds a sandbox, starts a small agent inside it,
and the agent imports your handler and waits. That waiting process is the
zygote. For each request the zygote calls fork(); the child runs your
handler(event) once, sends back the result, and exits. The child starts with
everything already loaded, because it is a copy of a process that had loaded
it. And it leaves nothing behind, because it is thrown away.
┌──────────────── one warm sandbox (namespaces, root, filters) ─────────────────┐
│ │
│ zygote: python + imports done + handler loaded — never runs a request │
│ │ │
request 1 ─┼──▶ ├── fork ─▶ child 1: handler(event) ─▶ reply ─▶ exit (its writes: gone) │
request 2 ─┼──▶ ├── fork ─▶ child 2: handler(event) ─▶ reply ─▶ exit (starts clean) │
request 3 ─┼──▶ └── fork ─▶ child 3: handler(event) ─▶ reply ─▶ exit (starts clean) │
│ │
└───────────────────────────────────────────────────────────────────────────────┘
Why a fork is clean
A normal worker process that serves many requests slowly collects state: a
global that one request changed, an open connection, a patched function, a
file in /tmp. Request n runs in whatever request n−1 left. Zygo’s
children never share that problem, because each one is a copy of the zygote,
and the zygote has never served a request. Files are the one place that needs
more than a fork: each request gets its own temporary folder, named by
TMPDIR and removed afterwards, because the sandbox’s /tmp is shared by
every request in it. So a request sees exactly what the
zygote had after its imports — every time, whatever the requests before it
did. You get the speed of a shared worker and the cleanliness of a fresh
container.
a shared worker zygo exec
─────────────── ─────────
worker ─ req 1 ─ req 2 ─ req 3 ─ … zygote ──┬─ copy ─ req 1 ✗
state piles up: req 3 sees ├─ copy ─ req 2 ✗
what req 1 and 2 left behind └─ copy ─ req 3 ✗
every copy starts from the same clean point
One cgroup per request
Each child is put in a cgroup of its own, under its function’s group — you saw
the tree in chapter 3. That gives every
request its own memory limit, process limit and CPU share. When a request
runs past its deadline, one write to its cgroup.kill ends it and every
process it started, and the zygote keeps serving. When it runs out of memory,
the kernel kills that request, not the zygote and not a neighbour. After
each request Zygo reads the group’s numbers and reports the result:
timed_out, oom_killed, peak memory, wall time.
Warm-exec, for programs that start fast
A Go or Rust binary starts in a millisecond, so there is nothing to keep
warm inside it. For these Zygo keeps only the sandbox warm: namespaces,
mounts and filters built once. Each request is a new process that enters the
sandbox with setns and execs your cmd, reading the event on standard input
and writing the result on standard output. This costs about 1.4 ms, measured
with sh -c cat as the program; a bigger program adds its own start. It works
for any language and any image, with no agent at all.
warm-exec (cmd) | agent (entry) | |
|---|---|---|
| What is kept warm | the sandbox | the sandbox and the loaded interpreter |
| A request is | a new process entered into the sandbox | a fork() of the zygote |
| Overhead | ~1.4 ms + the program’s own start | ~1.4 ms |
| Best for | Go, Rust, C, shell | Python, Node, anything with slow start-up |
The agent and its protocol
The agent is the small program inside the sandbox that loads the handler,
forks, and passes events and results. It talks to Zygo over a simple,
documented wire protocol (spec/protocol.md), not
through a library. So any language can have an agent: Zygo ships one for
Python and one for Node, and zygo agent test checks a new one against the
same conversation. A runtime pool is the same idea with no handler loaded
in advance: the script arrives with the request, which lets one warm zygote
serve thousands of different scripts for about 0.5 ms more.
The supervisor
Someone has to keep zygotes alive, hand out requests, watch deadlines and
collect results. That is the supervisor: a normal process under your own
user, which the first zygo serve starts when it needs one. It is not a
system service and it never runs as root. It owns the cgroup tree, keeps a
reserve of memory for itself so that busy tenants cannot starve it, and
serves the CLI, the HTTP API and the SDKs. If it goes, the sandboxes it
started go with it; nothing is left running on its own.
CLI · HTTP API · Python/Node SDK · MCP
│
┌───────▼────────┐ memory reserved,
│ supervisor │ so tenants cannot starve it
│ (your user) │
└──┬─────┬─────┬─┘
│ │ │ deadlines, cgroups, secrets, logs
┌──────▼┐ ┌──▼────┐ ┌▼──────┐
│zygote │ │zygote │ │sandbox│ one per function (and script version)
│resize │ │fetch │ │parse │
└───────┘ └───────┘ └───────┘
Images without a Dockerfile
Zygo pulls normal OCI images from any registry, into a store under your home
folder. What you would put in a Dockerfile goes in sandbox.toml instead:
requirements = "./requirements.txt" becomes a Python venv, built once inside
a sandbox with the image’s own pip, and system = ["libwebp7"] becomes an
extra layer with those apt packages. Each is keyed on the image digest and
the list, built once, and shared by every function that asks for the same
thing. The image itself is never changed, and there is no build step for you
to run or to push.
python:3.12-slim (from the registry, never changed)
│
├── + bytecode layer (.pyc for the standard library, built once)
├── + system layer (apt: libwebp7) key: image digest + list
└── + /venv (pip: requirements) key: image digest + lockfile
│
shared by every function that names the same image and the same list
Why not zygo ./venv/bin/python app.py?
It looks simpler: you already have a Python and a venv, so why name an image
at all? Because a venv is not a Python. venv/bin/python is only a link to
the host’s interpreter, and the venv folder holds only the extra packages;
the interpreter, its standard library and the C libraries under it all stay
in the host’s /usr and /lib. To run it, Zygo would have to show the
sandbox those host folders, which is exactly what a sandbox exists to hide
(chapter 5). So Zygo has
no mode that uses the host’s own files as the root, on purpose: every sandbox
starts from an image, and sees of your machine only what you --mount,
read-only unless you say :rw.
what you think a venv is what a venv really is
──────────────────────── ─────────────────────
┌ venv/ ──────────────┐ ┌ venv/ ──────────────┐
│ python │ │ bin/python ─────────┼──▶ /usr/bin/python3.12 (host)
│ everything it needs │ │ lib/…/site-packages │ ├─▶ /usr/lib/python3.12/ (host)
└─────────────────────┘ └─────────────────────┘ └─▶ /lib/libc.so … (host)
Why not zygo /usr/bin/python3 app.py?
Then skip the venv and point at the system’s own Python directly? It fails
for the same reason, and trying it shows why, one wall at a time. The file
/usr/bin/python3 is small; the Python it starts is not. On an Ubuntu 24.04
machine it needs five shared libraries from the host’s /lib, and then its
standard library — 1,194 files, 29 MB, in the host’s /usr/lib/python3.12.
Mount only the binary into an image, and each missing piece stops it in turn.
zygo run --mount /usr/bin/python3:/opt/python3 IMAGE /opt/python3 -c "print(1)"
wall 1 the loader alpine:3 "the program does not exist" exit 125
(musl image, and the binary asks for glibc's loader)
wall 2 the libraries debian:bookworm-slim "libexpat.so.1: cannot open exit 127
python:3.12-slim shared object file"
(the image has glibc, but not the exact libraries this build wants)
wall 3 the stdlib python:3.12-slim "No module named 'encodings'" exit 1
+ libexpat mounted too
(it looks for /usr/lib/python3.12; the image keeps its own elsewhere)
Measured on the Lima VM from chapter 25, Ubuntu 24.04, Python 3.12.3, aarch64. Notice the last row: even an image with the same Python version does not help, because the host’s binary looks for the host’s files, in the host’s places.
And if you mounted all of it?
You could keep mounting — /usr/lib/python3.12, then the libraries, then
/etc/ssl — until it starts. By then the sandbox can read most of the host’s
/usr and /lib: every program installed, every version, which is a map for
anyone looking for a weak spot. Worse, those files are not yours to hold
still: the next apt upgrade replaces them under a zygote that is still
running, and a warm function breaks in a way nobody can reproduce. The
system Python is also the operating system’s own tool — apt and other
system programs depend on it, and on Debian and Ubuntu pip refuses to
install into it (the “externally managed environment” error). The image’s
Python belongs to your function alone, and never changes unless you change
its name.
the system's Python the image's Python
─────────────────── ──────────────────
owned by the OS, used by apt owned by your function
changes with every apt upgrade changes only when you change its name
different on every machine the same bytes everywhere (a digest)
shows the sandbox the host's /usr shows the sandbox nothing of the host
What you do instead, for Python
You keep the parts that are yours and take the rest from an image. Your
code comes in with --mount ./app.py:/app/app.py. Your packages come from
--requirements requirements.txt: Zygo builds a venv once, inside the
image, with the image’s own pip, and shares it with every run that asks
for the same list. The Python itself comes from the image, the same bytes on
every machine. Nothing here costs time per request: the image is pulled once,
the venv is built once, and both are mounted read-only in a few milliseconds.
# not: zygo ./venv/bin/python app.py
zygo run --mount ./app.py:/app/app.py --requirements requirements.txt \
python:3.12-slim python3 /app/app.py
Why not zygo ./my_executable_binary?
Now take a compiled program instead — a Go server, a C tool. There is no
interpreter this time, so does it still need an image? It depends on how the
program was linked, which means how it finds the library code it uses.
A dynamically linked program keeps only its own code; when it starts, a
small loader (/lib64/ld-linux-x86-64.so.2) finds libc.so and friends on
the machine and joins them in. That is the default for C (gcc app.c), and
for Go when it uses cgo. Such a program has exactly the venv’s problem: its
libraries live in the host’s /lib, and the sandbox is not meant to see it.
A statically linked program carries every library inside its own file, so
it needs almost nothing — but “almost” is not “nothing”.
dynamically linked (gcc app.c) statically linked (CGO_ENABLED=0 go build)
────────────────────────────── ─────────────────────────────────────────
┌ my_app ───────┐ ┌ my_app ────────────────────────┐
│ your code │──▶ ld-linux.so (host) │ your code │
└───────────────┘──▶ libc.so.6 (host) │ + the Go runtime │
──▶ libssl.so (host) │ + every library it uses │
└────────────────────────────────┘
needs the host's /lib inside still wants a few files around it:
the sandbox: the venv problem again /etc/ssl/certs, /usr/share/zoneinfo, …
What a static binary still needs
Even a fully static program expects a small world around it. To call an
HTTPS API it reads CA certificates from /etc/ssl/certs; to show local time
it reads /usr/share/zoneinfo; to turn a uid into a name it reads
/etc/passwd; to resolve a host name it reads /etc/resolv.conf; if it runs
sh -c, it needs a /bin/sh. And every sandbox needs a root to stand on:
somewhere to put /tmp, /proc and /dev. An image gives all of this in a
few megabytes, the same on every machine, downloaded once. So Zygo keeps one
rule with no exceptions: the root always comes from an image, and from
your machine a sandbox sees only what you --mount.
What you do instead, for Go and C
Mount the binary into a small image and name it as the command. Which image
depends on how the binary was linked, and file ./my_app tells you: it says
either statically linked or dynamically linked, interpreter ….
| Your binary | Build it like this | Run it in |
|---|---|---|
| Go, static | CGO_ENABLED=0 go build -o my_app | alpine:3 (about 3.5 MB) — or any image |
| C, static | gcc -static -o my_app app.c (or musl-gcc -static) | alpine:3 — or any image |
| Go or C, dynamic, built on Debian/Ubuntu (glibc) | go build with cgo, gcc app.c | a glibc image: debian:bookworm-slim |
| Go or C, dynamic, built on Alpine (musl) | the same, on Alpine | alpine:3 |
file ./my_app # "statically linked"? then any small image works
zygo run --mount ./my_app:/app/my_app alpine:3 /app/my_app --port 8080
# a dynamic glibc binary: pick an image with glibc, not Alpine
zygo run --mount ./my_app:/app/my_app debian:bookworm-slim /app/my_app
The mistake to avoid is a glibc binary in alpine:3: it fails with “the
program does not exist”, because the loader it asks for is not in the image
(chapter 12). And for a
binary you call again and again, do not pay even the 12 ms of a fresh
sandbox: keep the sandbox warm with cmd, and each call costs about 1.4 ms
plus the program’s own start
(warm-exec).
[fn.parse]
image = "alpine:3"
mounts = ["./bin/parse:/app/parse:ro"] # a static Go or C binary you built
cmd = ["/app/parse"] # event on stdin, JSON result on stdout
Secrets
A secret, such as an API key, is never put in the environment and never in
the zygote’s memory. While a request runs, the supervisor writes it as a
file, /run/secrets/NAME, from outside the sandbox, readable only inside
that function’s sandbox, and removes it when the function’s last request
ends. A request that is compromised can read the secrets its own function
was given, while it runs, and never one that another function uses.
The network, off by default
A sandbox starts with no network at all. network = "egress" with
allow = ["api.example.com:443"] opens exactly that name and port: Zygo runs
its own small DNS resolver inside the sandbox, and when a name on the list is
asked for, its addresses are added to the nftables filter before the answer
goes back. A name not on the list does not even resolve. Private addresses and
the cloud metadata address stay closed in every mode unless you ask for them
by a flag named for exactly that. Chapter 14 has the
details.
Safe by default, loosened by name
Every limit has a value even if you set none: 256 MB of memory, one CPU,
64 processes, 30 seconds, up to 64 MB of scratch, 1024 open files. The root is
read-only, no capability is kept, and the seccomp allowlist is on. To remove a
limit you type a flag whose name says what it does — --allow-unlimited,
--allow-host-net, --allow-private-net. Docker makes you add safety one
flag at a time; Zygo makes you remove it one flag at a time.
The principles gives the principle and its cost.
Three backends, one command
isolation = "ns" | "gvisor" | "vm" moves the wall without changing anything
else. ns is everything above: the host kernel with its locks, and the only
backend with warm functions. gvisor puts gVisor’s user-space kernel between
your program and the host. vm boots a small virtual machine with its own
kernel through libkrun, for code you trust least, at about six times the
start-up cost. ADR 0002 explains why
the warm path stays on ns.
On a Mac
Sandboxes are a Linux feature, so on macOS Zygo starts and manages a small
Linux virtual machine through Lima. The zygo command on your Mac forwards
every sandbox command into it, with the same arguments, folder and streams.
Crossing into the VM adds about 22 ms per command; from a program, through the
API or the SDKs, the millisecond warm path is still there.
7. Where the time and memory are saved
Zygo is not faster because it found a faster kernel feature. It is faster because it stops doing work that does not need to be done on every request. This chapter lists each piece of that work, how much it costs, and what Zygo does instead.
The whole picture
what one request pays, for a small Python function (not to scale)
─────────────────────────────────────────────────────────────────────────────
docker run ░░░░░░░ chain ░░░░░░░▒▓▓▓▓ Python + imports ▓▓▓▓█ 300–1000 ms
docker exec ░░ daemon ░░█ 50–100 ms, shared state
zygo run ▒▓▓▓▓ Python + imports ▓▓▓▓█ 12 ms, Python included
zygo exec ▪█ 1.4 ms, clean state
─────────────────────────────────────────────────────────────────────────────
░ programs talking to programs ▒ the isolation itself (about the same everywhere)
▓ interpreter start-up ▪ a fork █ your code
The isolation is not what costs. Each saving below removes one of the other blocks.
Saving 1: no chain of programs
docker run passes through a CLI, a root daemon, containerd, a shim and
runc, with a socket or a new process at each step, and each of them keeps
its own records. zygo run is one process that makes the syscalls itself.
There is nothing to ask and nothing to wait for. This alone takes a one-shot
sandbox from hundreds of milliseconds down to about 12 ms with Python in it.
The sandbox itself — the namespace set, the cgroup and the mounts — is about
3.6 ms of that, work the kernel has to do whoever asks for it; the other 8 to
9 ms is Python starting (chapter 25).
Saving 2: no container object to create or remove
Docker makes a record for every container, a writable layer on disk, and log
files, and later has to delete them. Zygo makes none of these. The root is
read-only and shared; the one writable place is a tmpfs that disappears when
the process does. There is no zygo rm because there is nothing to remove.
On a busy machine this also saves disk writes and the slow build-up of old
containers that someone has to clean.
Saving 3: the interpreter starts once, not per request
This is the big one. A Python function that imports a few modules needs
roughly 100 to 500 ms before it can run a single line of your code. A
container per request pays that every time, however fast the container is. A
Zygo zygote pays it once, at zygo serve, and every request after that is a
fork() of a process where it is already done: about 1.4 ms. The same holds
for Node with a large dependency tree, or any runtime whose start-up is slow.
per-request cost, same handler, same host (from the embedder's benchmark)
────────────────────────────────────────────────────────────────────────
a container per request ████████████████████████████████████████ 542.4 ms
a one-shot sandbox █████▏ 70.8 ms
a warm fork ▏ 2.8 ms
The one-shot row is still slow here because it starts Python each time, and
the warm row includes starting the zygo CLI itself — through the API it is
closer to the 1.4 ms above. The embedder’s benchmark has
the full setup.
Saving 4: memory is shared, not copied
A forked child shares every page of the zygote until it writes to one (chapter 1). A request that reads a lot and writes little costs very little new memory. The zygotes also share with each other: a hundred warm Python scripts on the same image share almost all of the interpreter’s pages through the image’s files. Measured, each warm script costs about 11 MB of its own memory, not the 21 MB its process seems to use, so around 300 warm scripts fit in 4 GB. ADR 0005 has the numbers.
Saving 5: compiled bytecode is built once
The official python:*-slim images ship no compiled .pyc files, and a
read-only root means Python cannot save the ones it compiles. So every run
compiled every module it imported again — import ssl alone took 66 ms. Zygo
compiles the standard library once, into a layer of its own, the first time
it sees such an image. Importing ten common modules went from 165 ms to
35 ms. What Zygo costs
has the details.
Saving 6: no image builds for dependencies
With Docker, a new Python package means a new Dockerfile step, a build, a new image and often a push. With Zygo, you list the packages and it builds a venv once, keyed on the image and the exact list, and shares it with every function that asks for the same thing. Changing a function’s code touches no image at all. This saves the build time, the registry space, and the “which image has which version” work that grows with every function.
Saving 7: clean-up is one write
Killing a process tree safely is hard: children can fork while you list them.
Zygo puts each request in its own cgroup and ends it with one write to
cgroup.kill. No scanning, no race, no leftover process. A timeout is cheap,
certain, and it never touches the zygote or another request.
Saving 8: nothing to run or guard
There is no root daemon to keep running, patch, watch and protect. Access to Docker’s socket is the same as root on the host; Zygo has no such socket. The supervisor is a normal process under your user, started when needed. That is a saving in people’s time, not in milliseconds — but on a real team it is often the largest one.
The advantages, in one table
| What you get | Where it comes from | |
|---|---|---|
| Speed | ~1.4 ms per warm request; ~12 ms per fresh sandbox | no chain of programs, and a fork instead of a start |
| Clean state | request n cannot see anything request n−1 did | every request is a copy of a zygote that never served one |
| A limit per request | memory, CPU, processes and a deadline for each request, not each container | one cgroup per request |
| Density | hundreds of warm functions per machine | copy-on-write sharing between and inside zygotes |
| Safe defaults | no network, read-only root, no capabilities, every limit set | the defaults are chosen for code you did not write |
| No root, no daemon | nothing to install as a service, nothing to protect as root | user namespaces and delegated cgroups |
| No builds | dependencies declared, built once, shared | venvs and derived layers keyed on content |
| One wall, three strengths | ns, gvisor or vm with the same spec and command | backends behind one flag |
What is not saved
Your own code costs what it costs; Zygo only removes the work around it. A
warm function uses memory while it waits — about 11 to 21 MB for a Python
zygote — and after cold_after it is dropped and the next request pays the
warm-up again. The ns backend shares the host’s kernel, so a kernel bug
still defeats it, as it defeats every container. And Zygo is one machine: it
does not spread work across servers, publish ports, or run long-lived
services. The threat model and
the comparison say where the
edges are.
8. The rules Zygo is built on
Zygo is built on eight rules, or principles. Each one is a choice, and each choice has a cost. This chapter puts the cost right next to the rule, because a rule whose cost is hidden is only a slogan.
The eight rules at a glance
The rules fall into three groups: how a sandbox is made, how it stays fast, and how it stays safe and honest. The table is the short version; the rest of the chapter takes them one by one.
| Rule | What it costs | |
|---|---|---|
| P1 | A sandbox is a locked-down process, not a container | the wall is the Linux kernel; one kernel bug breaks every lock |
| P2 | The sandbox waits warm | a warm function uses memory while it waits |
| P3 | Isolation is one flag | vm is slower and does less; gvisor is one-shot only |
| P4 | OCI images, no Dockerfile | the first build of a derived layer takes about five seconds |
| P5 | No root, no system service | the host must have a few programs installed |
| P6 | Safe by default | the first thing a new user hits is a limit |
| P7 | Every limit is required | disk I/O and bandwidth have no default |
| P8 | Honest about what is not proven | the status text says “not built” often |
┌──────────── how it is made ────────────┐ ┌──────────── how it is fast ────────────┐
│ P1 one process, no container machinery │ │ P2 the sandbox is ready before a call │
│ P4 any OCI image, dependencies in spec │ │ P3 one flag picks where the wall is │
│ P5 your user, no root, no root service │ │ │
└────────────────────────────────────────┘ └────────────────────────────────────────┘
┌──────────────────────────── how it stays safe and honest ─────────────────────────────┐
│ P6 locked by default · P7 every limit has a value · P8 every claim measured or marked │
└───────────────────────────────────────────────────────────────────────────────────────┘
P1: a sandbox is a locked-down process, not a container
A container in Docker’s sense is an object managed by a chain of programs. Zygo skips that chain. It builds the sandbox from kernel parts — namespaces (separate views of the system), a cgroup (a group with resource limits), seccomp (a filter on system calls) and Landlock (a limit on which files a process may touch) — inside one process, with no RPC (a call to another program over a socket). You met all of these in chapters 2 to 4.
The steps are short. Zygo calls clone3 to make a child process in seven new
namespaces. The child calls pivot_root to make the image its root folder,
drops all its capabilities (pieces of root’s power), installs a BPF filter
(the small program that seccomp runs on each system call), and calls execve
to become your program. There is no daemon (a long-running background
service) to talk to, no shim (a helper process that babysits a container),
and no trip to an image service while a request waits.
zygo ──clone3──▶ child in 7 new namespaces
│
├─ pivot_root onto the image
├─ drop every capability
├─ install the seccomp BPF filter (and Landlock)
└─ execve ─▶ your program
no daemon · no shim · no RPC · no image service on the way
What it costs. The wall is the Linux kernel. A bug that lets a program
gain kernel privileges defeats every lock at once. That is why the vm
backend exists, for code you did not write. zygo doctor and the
security chapter say this plainly.
P2: the sandbox waits warm
Nothing is built while a request waits. There are two ways Zygo does this.
For a compiled program the sandbox is held: namespaces, mounts and locks are
set up once, and each request is a fresh process that enters them. That costs
about 1.4 ms, plus the program’s own start. For an interpreter such as
Python, the sandbox holds an agent, a small program that has already
imported your handler. Each request is a fork() of it — a copy of the
process. The copy is copy-on-write: memory is shared until one side changes
it, so nothing is copied up front. And because each request is a fresh
process, no state carries over from the last one.
Measured: usually 1.4 ms, on the machines named in chapter 25. That is at a steady 250 requests a second, the rate the latency test holds so the host is never the limit. The most one warm function sustained, with four clients sending, is 1,108 a second.
compiled program (warm-exec) interpreter (agent)
──────────────────────────── ───────────────────
held sandbox held sandbox + agent with handler loaded
│ │
├─ new process enters ─▶ run ~1.4 ms ├─ fork ─▶ handler(event) ~1.4 ms
└─ new process enters ─▶ run └─ fork ─▶ handler(event)
What it costs. A warm function stays in memory. A Python zygote (the
loaded process that is forked for each request) uses about 20 MB, of which
about 11 MB is its own and the rest is shared with other zygotes. The idle
rules pause it after idle_timeout: it is frozen but still in memory. They
drop it after cold_after, and the next request then pays the warm-up again.
serving ──(no calls for idle_timeout)──▶ paused: frozen, still ~20 MB in RAM
▲ │
│ (no calls for cold_after)
│ ▼
└──────── next call pays warm-up ─── dropped: memory freed
P3: isolation is one flag
isolation = "ns" | "gvisor" | "vm". The spec, the command and the protocol
stay the same; only the backend changes, and the backend decides where the
wall is drawn. ns uses the host kernel. vm uses KVM (the kernel’s
built-in support for virtual machines) with a guest kernel of its own.
gvisor uses a kernel that runs in user space.
isolation = "ns" your program ─▶ host kernel
isolation = "gvisor" your program ─▶ gVisor (user-space kernel) ─▶ host kernel
isolation = "vm" your program ─▶ guest kernel ─▶ KVM ─▶ host kernel
What it costs. ns and gvisor are built. vm boots a guest and runs
one-shot sandboxes, at about six times the setup cost of ns (422 ms against
73 ms on a Raspberry Pi 5, chapter 25).
The guest writes to a private layer bounded by scratch; it has no network
and no warm functions. zygo backend install gvisor
downloads the gVisor runtime. The same command run on both backends gives the
same answer, while uname -r inside reports 4.19.0-gvisor instead of the
host’s kernel — which is the point.
gvisor is one-shot only so far. A warm function must be entered for each
request; on ns that is setns into namespaces the supervisor holds, and
gVisor’s wall is not those namespaces. So Zygo refuses a warm function on
gvisor instead of quietly running something weaker, and it refuses a
networked sandbox there too. Rootless runsc (gVisor’s runtime) also cannot
write cgroups, so limits there are only advisory, and Zygo says so on every
start. For a warm function today, that means ns.
P4: OCI images, no Dockerfile
Any OCI image (the standard image format Docker also uses) from any registry can be the file system. What a Dockerfile would add — Python packages, apt packages — goes in the spec instead. Zygo builds it once, inside the image, as a venv (a Python folder of installed packages) or as a derived layer (an extra image layer made by running the install). Every function that names the same thing shares the result. The image itself is never changed.
image (never changed) ─┬─▶ + venv from requirements.txt ──▶ mounted at /venv
└─▶ + derived layer from apt list ──▶ shared by all who ask
built once, keyed on the image digest and the list
What it costs. A derived layer is a copy of the image, an install, and a diff; about five seconds the first time. Overlayfs (a file system that stacks folders) would be faster, but rootless overlayfs needs kernel 5.11, and not every host Zygo runs on has it.
P5: no root, no daemon
Zygo needs no root at any point and no system service. The supervisor is a
process in your own login session that zygo serve starts when it needs one.
Outbound networking uses pasta (a user-space network helper) and
nftables (the kernel’s packet filter) inside the sandbox’s own namespace.
This works because entering a user namespace you created gives you a full set
of capabilities inside it, and nowhere else.
your login session (your uid, no root)
└─ zygo serve ─▶ supervisor
└─ sandbox: own user namespace = full caps HERE only
├─ nftables rules for the allowlist
└─ pasta ─▶ the outside network
What it costs. Some features need a program on the host: pasta and
nft for outbound networking, and newuidmap for a separate range of user
ids per tenant. If one is missing, Zygo refuses to start and names the
package. It never silently falls back to something weaker.
P6: safe by default
With no flags, the network is off, the root file system is read-only, the
capability set is empty, the seccomp allowlist is on, and every limit has a
value. To loosen any of it you must type a flag: --allow-host-net,
--allow-private-net, --allow-unlimited. zygo up does not accept these
flags, so a spec that needs one has to be served on purpose.
default to loosen, you type
─────── ───────────────────
network off ──▶ --allow-host-net, --allow-private-net
seccomp allowlist on ──▶ a looser profile, by name
every limit has a value ──▶ --allow-unlimited
(none of these flags work with zygo up)
What it costs. The first thing a new user hits is a limit. The error names the field and the fix.
P7: every limit is required
Memory, CPU, process count, wall-clock time, scratch space, open files, and,
for a networked function, connections: each has a default, and there is no
“unlimited” without the flag. Disk I/O and bandwidth are the two exceptions,
below. The deadline kills the request’s whole process tree (the process and
every child it started), not a single process. The per-request cgroup is what
makes that one write, to its cgroup.kill file.
request cgroup ── cgroup.kill ◀── one write at the deadline
├─ handler process ✗
│ └─ child it spawned ✗
└─ another child ✗ the whole tree ends; the zygote keeps serving
What it costs. Disk I/O and bandwidth have no default limit, because a good value depends on the device. Zygo warns when either is missing.
P8: honest about what is not proven
Every claim in the README is either measured or marked. The test suites try the thing instead of reading a setting. A negative check first proves that the thing really ran. A latency number says whether it hit a limit. Chapter 25 records what was measured and on what hardware. Before these rules existed, several checks passed — or failed — for the wrong reason.
weak test Zygo's test
───────── ───────────
read a setting ─▶ pass 1. a negative check proves the probe really ran
2. attempt the forbidden thing ─▶ must fail
3. a latency number says if it hit a limit
What it costs. The status text is long, and it says “not built” more often than a launch page would.
The two warm modes, side by side
P2 has two shapes. Warm-exec keeps only the sandbox ready and runs your
cmd fresh each time. Agent mode also keeps a loaded interpreter ready and
forks it. Chapter 13 shows how to write each one.
warm-exec (cmd) | agent (entry) | |
|---|---|---|
| What is warm | the sandbox | the sandbox and a loaded interpreter |
| A request is | a fresh process entered into the sandbox | a fork() of the agent |
| Overhead | ~1.4 ms + the program’s own start | ~1.4 ms |
| Needs | nothing: any image, any language | an agent that speaks the protocol; Python and Node ship (in agents/); a POSIX sh one is in examples/agents/sh |
| Use when | the runtime starts fast: Go, Rust, C, sh | starting the runtime is the cost: Python with imports, a JVM, Node with a dependency tree |
warm-exec: [ sandbox ready ] ──▶ start your program ──▶ run (fast for Go, Rust, C)
agent: [ sandbox ready + Python loaded ] ──▶ fork ──▶ run (skips the slow start)
9. FreeBSD jails, and Zygo
Linux was not first. FreeBSD had a working “container” in 2000, thirteen years before Docker, and many ideas in this book are easier to see there.
Where jails came from
Jails arrived in FreeBSD 4.0, in March 2000, written by Poul-Henning Kamp for
a hosting company that wanted to give each customer “root” without giving
them the machine. The paper that describes them has a telling title:
Jails: Confining the omnipotent root. The idea was to take chroot, which
only changes where file paths start, and close every way out of it. A jail
became one clear thing in the kernel: a group of processes with its own
files, its own host name, its own addresses, and a root user who is not
really root.
How a jail works
A jail is one object in the kernel, made with the jail(2) syscall, usually
through the jail(8) tool and a config file, /etc/jail.conf. Every process
carries a pointer to its jail, and the kernel checks that pointer wherever it
matters. A jailed process sees only processes in its own jail. Root inside may
not mount file systems, load kernel modules, change the network, or reach
raw devices. jls lists the jails; jexec runs a command inside one.
FreeBSD: one kernel object Linux: many parts, put together
────────────────────────── ───────────────────────────────
┌──────────── jail ────────────┐ mount ns ─┐
│ root path /jails/web │ pid ns ─┤
│ host name web.example │ net ns ─┤
│ addresses 10.0.0.5 / vnet │ uts ns ─┤
│ root is limited │ ipc ns ─┼─▶ what a tool
│ processes see only the jail │ user ns ─┤ calls a
│ limits via rctl │ cgroup ─┤ "container"
└──────────────────────────────┘ caps ─┤ or "sandbox"
made by jail(2), by root seccomp ─┤
Landlock ─┘
What jails have grown since
Jails kept growing. VNET (since FreeBSD 12 in the default kernel) gives a jail a whole network stack of its own, like a Linux network namespace. rctl limits a jail’s memory, CPU and process count, like cgroups. Jails can nest inside other jails. Capsicum, a separate FreeBSD feature, lets a process lock itself down to only the files and sockets it already holds — close in spirit to seccomp and Landlock together. Tools such as iocage and Bastille manage jails the way Docker manages containers, and Podman now runs OCI images on FreeBSD with jails underneath.
One idea, two designs
FreeBSD and Linux solved the same problem in opposite ways. FreeBSD made one strong, complete object: you ask for a jail and you get all of it. Linux made many small parts — each namespace, cgroups, seccomp — and left it to tools to put them together. The FreeBSD way is easier to reason about: there is one thing to check, and it is hard to forget a piece. The Linux way is more flexible — a tool can take only a network namespace, or only a cgroup — but every tool must assemble the parts correctly, and a missing part is a hole. Much of Zygo’s test suite exists because of that second fact.
What Zygo shares with a jail
At the level of “what does the program inside see”, a Zygo ns sandbox and
a jail are very close. Both give a group of processes its own root file tree,
its own view of processes, its own network (or none), its own host name, and a
root user without real power. Both share the host’s kernel, so both are only
as strong as that kernel. Both are cheap: no second kernel, no virtual
hardware. And both come from the same instinct: the process, not a machine,
is the thing to confine.
Where they differ
| FreeBSD jail | Zygo sandbox (ns) | |
|---|---|---|
| What it is | one kernel object | a process with many Linux locks on it |
| Who can create one | root | any user (user namespaces + delegated cgroups) |
| Users inside | the host’s own uids; jail root is uid 0, limited | its own uids, mapped to yours; root inside is you outside |
| Usual life | long: a web server, a mail server, for months | short: one program, or one request, then gone |
| Syscall filter | none per jail (Capsicum is per process) | a seccomp allowlist on every sandbox |
| File access rules | the jail’s root path | the root, plus Landlock as a second wall |
| Limits | rctl, optional | cgroups, mandatory, one set per request |
| Images | a folder or ZFS dataset you prepare | OCI images from any registry |
| Warm start | none: you start processes in the jail | a zygote, forked per request in ~1.4 ms |
| Network default | the addresses you give it | nothing at all |
The biggest difference: who holds the key
On FreeBSD, making a jail needs root, and root inside a jail is the host’s uid 0, only with fewer rights. The safety comes from the kernel’s list of what jailed root may not do. On Linux with a user namespace, “root” inside is just your user outside, so there is nothing of the host’s to lose even if a check were missed. That is why Zygo can run without any privilege, and why a normal user can start a thousand sandboxes without asking an admin. The cost is on the other side: user namespaces open a lot of kernel code to normal users, which is exactly why Zygo’s seccomp filter refuses to let a sandbox create new ones.
Can Zygo be called “jails for Linux”?
Partly — and it is worth being exact. As a picture it fits well: a program
locked in its own small world, sharing the kernel, cheap to make. People who
know FreeBSD will understand Zygo’s ns backend in one sentence that way. But
a jail is usually a long-lived home for a service, built by an admin, and
Zygo is the opposite: short-lived, built by any user, often one per request,
and it does not run services at all. The honest phrase is “a throwaway jail
for every request”: the jail’s walls, with the lifetime of a function call.
Other relatives
FreeBSD was not alone. Solaris Zones (2005) took the same idea further, with its own resource controls and a strong admin model. On Linux, Linux-VServer and OpenVZ were jail-like kernel patches used by hosting companies long before namespaces were finished. LXC (2008) was the first widely used tool to build jail-like “system containers” from the new Linux parts, and Docker began as a layer on top of it. Every one of these, like Zygo, shares one kernel between everything it hosts.
10. Similar projects, and Docker side by side
Many projects build a sandbox from the parts in chapters 2 to 4. They differ
in three questions, and once you ask those, most comparisons answer
themselves. This chapter asks them, sets docker run and zygo run side by
side in detail, and then walks through the projects one by one.
The three questions
- Where is the wall? The host kernel with locks on it, a second kernel in user space, or a virtual machine with its own kernel.
- What is ready when a request arrives? Nothing — the sandbox is built each time; the sandbox — a new process enters it; or the sandbox and the loaded program — a copy is forked.
- Who has to be root? The tool, a daemon, the admin once, or nobody.
Numbers for Zygo in this chapter are the project’s own measurements; chapter 25 names the hosts and the commands. The claims about other projects are theirs or commonly measured, not measured here. They all move quickly; check before quoting.
The map
The three questions give every project a place. The table below puts the first two on a grid: each row is where the wall is, each column is what is already waiting when a request comes in. Read a column from left to right as “less work per request”: on the left everything is built for each call, on the right the program is already loaded and only copied.
what is ready when a request arrives?
NOTHING THE SANDBOX THE SANDBOX + THE PROGRAM
─────── ─────────── ─────────────────────────
build the sandbox enter the sandbox fork the loaded program
start the program start the program run the request
run the request run the request
tear it all down
most tools work here `docker exec`, warm-exec Zygo `exec`; Sandlock, Zeroboot
slowest per call fastest per call
| Wall ↓ · Ready on arrival → | nothing: build it all | the sandbox: enter it | the program: fork it |
|---|---|---|---|
| Virtual machine | Firecracker, Kata, microsandbox, Zygo vm | — | Firecracker from a memory snapshot (a whole VM per restore); Zeroboot (a copy-on-write fork of one) |
| Second kernel | gVisor, Zygo gvisor | — | — |
| Host kernel | Docker, Podman, runc, nsjail, bubblewrap, firejail, minijail, kern, Zygo run | docker exec (state is shared), Zygo warm-exec | Zygo exec |
| Process confinement only | nono, Landlock-based tools: they confine a process you already run | Sandlock (a copy-on-write fork of a confined Python process) |
The right-hand column held research when Zygo started, not a product: SOCK (USENIX ATC 2018), built into the OpenLambda research platform, forks Python handlers from a zygote on the host kernel (chapter 6). That column is the space Zygo was built for. Since 2026 two more projects fork there, each from a different row: Sandlock and Zeroboot, below. Everything else on this page is a good tool for a nearby job.
What one call costs, tool by tool
The chart shows roughly what one call to a small Python function costs with each tool. The scale is logarithmic: each mark to the right is ten times slower than the one before, so a bar twice as long is not twice as slow but many times slower. A solid bar (█) is the usual cost; a light part (▒) is the range above it. Every bar except the first includes starting Python itself; the first does not, because the warm zygote started Python long before the request.
1 ms 10 ms 100 ms 1 s
│·············│·············│·············│
Zygo exec (warm fork) ██ 1.4 ms
Zygo run, nsjail, kern ███████████████▒▒▒▒▒▒ 12–40 ms
gVisor █████████████████████████▒▒▒▒▒▒ ~60–160 ms
Firecracker microVM ██████████████████████████████▒▒▒▒ ~140–250 ms
docker run ███████████████████████████████████▒▒▒▒▒▒▒ 300–1000 ms
The Zygo numbers are measured by this project: zygo exec is the warm
path’s median, and zygo run the median of python3 -c pass with the image
already pulled (chapter 25). The one-shot row is one
cluster on purpose. zygo run is not drawn faster than nsjail or kern,
because it is not: inside Windmill, nsjail and Zygo in its place cost the
same 19–20 ms per job (measured below),
and on the Raspberry Pi kern box and zygo run tied
(chapter 25).
The light part of that bar is where the same tools land with a heavier
script or an older kernel. The rows below it are each project’s own claims,
here to show the order of size. The lesson is in the shape: the one-shot
tools cluster together, because they all pay for building a sandbox and
starting Python, and only a warm fork leaves that cluster.
The request path
This table compares what each tool costs on every request, and what it costs once, up front. The Zygo columns are measured; the other columns are the projects’ documented or commonly measured figures.
How to read the Zygo numbers: we timed many requests. “Usually” is what a normal request costs. “1 in 100” is what the slowest requests cost — only one request in a hundred was slower than that.
fast ◀──────────────── 100 requests, sorted ────────────────▶ slow
· · · · · · · · · · · · ▲ · · · · · · · · · · · · · · ▲ ·
usually 1 in 100
These are the time a tool adds. Your own code’s time comes on top.
Docker run | Docker exec | Firecracker | gVisor runsc | AWS Lambda (warm) | zygo run | zygo exec --runtime (pool) | zygo exec (warm) | |
|---|---|---|---|---|---|---|---|---|
| Per-request overhead | 300–1000 ms | 50–100 ms | ~125 ms boot; 10–20 ms from a snapshot | 50–150 ms | ~1–5 ms + platform | usually 12 ms | usually 1.9 ms · 1 in 100: 11.4 ms¹ · a different script each time | usually 1.4 ms · 1 in 100: 10.5 ms¹ |
| Paid once, up front | — | a docker run -d | the VM’s own boot, or a snapshot | — | a cold start, platform-side | — | a zygo serve --runtime: the interpreter and its dependencies, once for every script | a zygo serve: ~150 ms for a Python handler, plus its imports |
| Clean state per request | yes | no | yes | yes | no | yes | yes (a fresh process; the script is loaded in it)² | yes (a fresh process)² |
| Daemon | yes | yes | yes (a VMM per VM) | yes (runsc + shim) | n/a | no | no system service: a supervisor under your user | no system service: a supervisor under your user |
| Root | daemon runs as root | same | needs /dev/kvm | no, in rootless mode (how Zygo runs it); then its cgroup limits are advisory | n/a | no | no | no |
| Wall | kernel | kernel | hardware | userspace kernel | hardware | kernel (ns); userspace kernel (gvisor); hardware (vm) | kernel (ns only) | kernel (ns only) |
¹ On Linux 6.x, 1 request in 100 waits about 9 ms for the kernel to move it into its cgroup; on Linux 5.10 the same is 2.6 ms (warm) and 3.2 ms (pool). Chapter 25 explains it. Zygo numbers are from a Lima VM, Linux 6.8, 25 September 2026.
² Clean as in a fresh process forked from a zygote that has never served a
request. A fork still shares what the zygote had before any request: its
memory layout, a socket opened at import time, a literal /tmp/... path.
Fork safety goes through each.
The first row is the whole argument. A container’s cost is the machinery around it and the cold start of the interpreter. Zygo takes the machinery off the request path entirely, and it pays the interpreter’s start only once, in a warm zygote. That is not a new observation: SOCK (USENIX ATC 2018) and Catalyzer (ASPLOS 2020) made the same one for serverless platforms, with a zygote and a snapshot respectively (chapter 6).
The three Zygo columns are three answers to “what is warm?”. zygo run keeps
nothing warm. A pool keeps the interpreter and its dependencies warm, but
no code: each request brings its own script, so one pool serves thousands of
different scripts, for about 0.5 ms more than a function. A warm function
keeps one handler loaded, which is the fastest, but costs one zygote per
script (chapter 13).
what is warm? zygo run pool warm function
──────── ──── ─────────────
sandbox built each time ready ready
interpreter + deps started each time ready ready
your code loaded each time loaded per request ready
per request 12 ms 1.9 ms 1.4 ms
docker run and zygo run, side by side
Both accept the same sentence — IMAGE [COMMAND…] — and both use OCI images
from the same registries. The likeness ends there. The next sections compare
them on who runs the command, what is left behind, the defaults, networking,
images, and the flags you already know.
Who runs the command
docker run is a client. The request crosses a unix socket to dockerd, a
root daemon. dockerd hands it to containerd, which starts a
containerd-shim, which runs runc. runc sets the container up and exits;
the shim stays for as long as the container lives. That is five processes,
three RPC boundaries (calls from one program to another), all of them root.
Nearly all of the 300–1000 ms is that chain; the isolation itself — the
namespaces, the cgroup, the seccomp filter — is about a millisecond of it.
Chapter 5 walks the chain step by
step.
zygo run is one process, with no RPC and no root. zygo itself calls
clone3 into a fresh set of namespaces. The child applies the mount plan,
calls pivot_root, joins its cgroup, drops every capability, installs
seccomp and Landlock, and calls execve. With the image cached, that takes a
median of 12 ms. ps shows zygo with your program directly under it, and
nothing in between.
what ps shows for docker run what ps shows for zygo run
──────────────────────────── ──────────────────────────
docker (client, your shell) zygo (your user)
dockerd (root) └─ your program
containerd (root)
containerd-shim (root)
└─ your program
(runc started it and has already exited)
What is left behind
docker run creates an object. When the process exits, the container stays
(docker ps -a), and its writable layer stays on disk. Something has to
docker rm it, or you must have passed --rm. Logs pile up in the daemon,
there are restart policies, and docker exec gets you back inside.
zygo run is a process. When it exits, the namespaces, the cgroup and the
tmpfs go with it; there is nothing to remove, and there is no zygo rm.
Standard input, output and the exit code pass straight through. The one
addition: a sandbox that Zygo or the kernel killed exits with code 137, and
--outcome file.json says which it was, with timed_out, oom_killed,
peak_rss_kb and wall_ms. --dry-run --json prints the mount plan and the
cgroup values without running anything; Docker has nothing like it.
The container you keep around and step into is a separate idea in Zygo:
zygo serve and zygo exec, a warm function. It looks like docker exec,
except that every request is a fresh process and costs about 1.4 ms.
docker run IMAGE zygo run IMAGE
after exit: after exit:
┌──────────────────────────────────┐ ┌──────────────────────────────────┐
│ container record (docker ps -a) │ │ nothing │
│ writable layer on disk │ │ exit code passed through │
│ logs in the daemon │ │ 137 if killed; --outcome says │
│ → needs docker rm (or --rm) │ │ timed_out / oom_killed │
└──────────────────────────────────┘ └──────────────────────────────────┘
The defaults
Docker’s defaults are made for software you chose; Zygo’s are made for code you did not. This is what you get with no flags at all.
docker run IMAGE | zygo run IMAGE | |
|---|---|---|
| Root filesystem | writable (a copy-on-write upper layer) | read-only; the only writable place is /tmp, a tmpfs sized by scratch (the smaller of 64M and half of mem, so 64M by default) |
| Capabilities | 14 kept (NET_RAW, SYS_CHROOT, MKNOD, …) | none |
| seccomp | a denylist-shaped profile allowing ~350 syscalls | an allowlist of ~215 names (the default profile; 190 of them exist on aarch64, all on x86_64); bpf, io_uring, userfaultfd, ptrace, mount, unshare absent; clone refused with any namespace flag |
| Landlock | no | yes, where the kernel has it |
| Runs as | root in the container, root on the host (outside rootless mode) | the image’s uid 1000, mapped to your uid |
| Network | bridge; everything reachable | none: loopback only |
| Memory, CPU, pids, wall clock | unlimited | all mandatory: mem 256M, cpu 1.0, pids 64, timeout 30s, nofile 1024 |
| Bind mounts | -v is rw unless :ro | --mount is ro unless :rw |
| cgroupfs inside | mounted read-only | not mounted at all, so release_agent is not reachable |
| Escape suite | — | 21 escape vectors attempted on every change, 0 escaping |
A denylist names what is forbidden and allows the rest; an allowlist
names what is allowed and forbids the rest. release_agent is an old cgroup
file that has been used to escape containers, so Zygo does not show the
cgroup file system inside at all.
Adding locks versus loosening them
In Docker you add safety: --cap-drop ALL --read-only --security-opt no-new-privileges --memory --pids-limit --network none. In Zygo you
loosen it, and every flag that does so says it in its name:
--allow-unlimited, --allow-host-net, --allow-private-net. There is no
way to turn a limit off without one of them.
This is not “Zygo is safer than Docker”. Docker’s defaults suit software you chose; Zygo’s suit code you did not. The point is that each set of defaults leans the way its use case needs. And every row in the Zygo column is something a test attempts, not a setting a test reads.
Docker: open ──(--cap-drop ALL, --read-only, --memory, --network none, …)──▶ tight
Zygo: tight ──(--allow-unlimited, --allow-host-net, --allow-private-net)──▶ looser
Networking: four modes, none of them a server
Docker’s network is built for a container that is a service: a bridge with
NAT (address translation, so many containers share the host’s address),
-p 8080:80 to publish a port, containers that find each other by name, and
--network host when you want none of it.
Zygo has four modes, and none of them accepts a connection. none is the
default. egress is an allowlist by name —
allow = ["api.example.com:443", "*.cdn.example.com:443"] — enforced by
nftables inside the sandbox’s own namespace, with a DNS resolver of Zygo’s
own. A name off the list does not resolve; a name on it has its addresses
added to the filter before the answer goes back. full is the public
internet. host means no network namespace and needs --allow-host-net.
Private address ranges and 169.254.169.254 (the cloud metadata address,
which often hands out credentials) stay refused in every namespaced mode. If
pasta or nft is missing, a networked sandbox does not start, instead of
starting without its filter. There is no port publishing; Zygo does not run
services. Chapter 14 covers the modes in use.
mode reaches needs
──── ─────── ─────
none loopback only (default)
egress only the host:port names on the allow list pasta + nft
full the public internet; never private or metadata pasta + nft
host everything the host reaches (no namespace) --allow-host-net
What the sandbox can reach
The same probe, on the same host, was run through Zygo’s --net full and
Docker’s --network bridge: a small script that tries to open a connection to
each target and reports what the kernel answers.
| target | Zygo --net full | Docker --network bridge |
|---|---|---|
cloud metadata 169.254.169.254:80 | no route | routed (refused by the host, not blocked) |
| the host’s own Postgres | no route | reached |
private 192.168.1.1:80 (the LAN router) | no route | reached |
private 10.0.0.1:80 | no route | no route |
| the sandbox’s default gateway | no route | no route |
public internet 1.1.1.1:53 | reached | reached |
┌─────────────────────┐
Docker bridge ─────▶│ host's Postgres │◀──── reached
────▶│ LAN router │◀──── reached
────▶│ metadata address │◀──── routed (refused by host)
└─────────────────────┘
Zygo --net full ──▶ public internet only; private and metadata: no route
Closing the gap in Docker: four rules
Docker needs four firewall rules to close what Zygo closes by default. They drop traffic from containers to the metadata range and the three private ranges.
iptables -I DOCKER-USER -d 169.254.0.0/16 -j DROP
iptables -I DOCKER-USER -d 10.0.0.0/8 -j DROP
iptables -I DOCKER-USER -d 172.16.0.0/12 -j DROP
iptables -I DOCKER-USER -d 192.168.0.0/16 -j DROP
In a product where the code inside the sandbox is written by a tenant (a customer of the product), this is not a matter of taste.
The egress allowlist, end to end
The same host, the same probes, four configurations: a normal HTTPS request, and a raw socket to a public DNS server.
| configuration | https://example.com | raw socket to 1.1.1.1:53 |
|---|---|---|
--net none | DNS fails | PermissionError |
--net egress, no --allow | DNS fails | PermissionError |
--net egress --allow example.com:443 | 200 | OSError |
--net full | 200 | reached |
One named host opens and nothing else does, with no proxy process anywhere.
Images and dependencies
Both pull the same images from the same registries, and zygo run pulls on
first use exactly as docker run does. The stores differ. Docker’s is
/var/lib/docker and belongs to root. Zygo’s is under ~/.local/share/zygo,
content-addressed (each file is named by a hash of its contents), with the
layers unpacked. It uses rootless overlayfs on kernel 5.11 and newer, and a
flattened copy below that. zygo login stores a password for a private
registry, and an existing ~/.docker/config.json is read too.
The real difference is the Dockerfile. In Docker, adding a dependency means
building an image. In Zygo the image is never touched: --requirements ./requirements.txt builds a venv inside a sandbox with the image’s own pip
and mounts it at /venv, and system = ["libwebp7"] installs apt packages
into a derived OCI layer. Both are keyed on the image digest and the list,
built once, and shared by everything that names the same thing.
Chapter 15 has the details.
Docker Zygo
Dockerfile ─▶ docker build ─▶ new image image (untouched)
├─ --requirements ─▶ venv at /venv
└─ system = [...] ─▶ derived layer
built once per (image digest, list)
The flags you already know
| Docker | Zygo | Note |
|---|---|---|
-v ./x:/x | --mount ./x:/x:rw | read-only is Zygo’s default; a single file can be mounted too |
-e K=V | --env K=V | secrets are not environment: they arrive as /run/secrets/<NAME> |
-w /dir | --workdir /dir | default /app, falling back to / |
-u 1000 | --user 1000 | |
-it | --tty | |
--memory 256m --cpus 0.5 --pids-limit 64 | --mem 256M --cpu 0.5 --pids 64 | present in Zygo whether you pass them or not (the default cpu is 1.0) |
timeout 30 docker run … | --timeout 30s | the whole process tree is killed, through the cgroup |
--network none | (the default) | --net egress --allow host:port for an allowlist |
--network bridge | --net full | bridge is accepted as a spelling of full; see what the sandbox can reach for the difference |
--network host | --net host --allow-host-net | the same removal of the wall, and it says so in its name |
--security-opt seccomp=… | --seccomp default|strict|permissive | three shipped profiles; see chapter 24 |
--runtime runsc | --isolation gvisor | same spec, same command; vm is another value of the same flag |
--rm | (always) | |
-d, -p, --restart | — | Zygo does not run services |
Docker
Docker is a general-purpose system for packaging and running services. Zygo
uses its images and none of its runtime. If you need docker build,
docker compose, long-running services, port publishing, networks between
containers or restart policies, you need Docker.
Chapter 5 explains how it works.
nsjail
nsjail, from Google, is the closest older relative of Zygo’s one-shot half. It puts a process into namespaces, cgroups, rlimits and a seccomp filter, which you write in a small policy language called Kafel. It can run a command once, run it again and again, or listen on a TCP port and start a fresh jail for every connection, which made it the standard tool for hosting CTF challenges. Windmill and other job runners can wrap each job in it. It has no images — you give it a folder or bind-mount the host’s — and no warm path: every run starts the program from nothing. Choose it when you want a battle-tested, very configurable jail around a command and you manage the root file system yourself. Zygo in nsjail’s place inside Windmill’s workers cost the same per job; the measurement is further down.
bubblewrap
bubblewrap (bwrap) is the small sandbox tool under Flatpak. It creates
namespaces, builds a root from the bind mounts you list, and then runs a
command — nothing more, on purpose. It has no cgroup limits and no seccomp
policy of its own; you pass a compiled filter if you want one. That smallness
is its strength: it is easy to audit and it runs everywhere, which is why many
desktop and developer tools use it as a building block. Zygo covers what
bubblewrap leaves to the caller: images, limits, the filter, the network
allowlist, and the warm path.
firejail
firejail sandboxes desktop programs — a browser, a PDF reader — using ready profiles for hundreds of applications. It is installed setuid root, so any user can start a sandbox, but its own code runs with root’s power, and bugs in it have mattered in the past. It is made for confining the apps on your desktop, not for running server-side functions at high rates. Zygo needs no setuid program at all, because user namespaces give it what it needs.
minijail
minijail is Google’s sandbox library and tool for ChromeOS and Android system services. It applies namespaces, capabilities, seccomp and user changes to a service before it starts, from a policy file. It is part of the operating system’s own plumbing and is written for that setting: known services, written by the same team. Zygo is aimed at the reverse: code written by someone else, arriving at request time.
systemd-nspawn and systemd-run
systemd-nspawn starts a whole operating-system tree in namespaces — “chroot
on steroids”, in its own words — and is good for booting a distribution in a
container for testing. systemd-run can put any command in a transient unit
with limits and many sandbox options. Both mostly need root, and both think in
units and machines rather than requests. Zygo uses systemd only where it has
to: to get a delegated cgroup under your login.
The three you are really choosing between
Docker, Firecracker, gVisor and Lambda are the landmarks; they are not the shortlist. Someone looking for “run this agent’s code somewhere safe” ends up comparing Zygo with three much closer projects: kern, nono and microsandbox. In two of the three cases the honest answer is that they solve a different problem. Two more, Sandlock and Zeroboot, fork a warm process as Zygo does and have a section of their own below. Their claims below are theirs, not measured here, and every one of them moves quickly.
| kern | nono | microsandbox | Zygo | |
|---|---|---|---|---|
| Shape | rootless container runtime, one static binary | a confinement you apply to a process you already have | microVM runtime and platform | rootless sandbox runtime plus a warm-process protocol |
| Wall | host kernel (namespaces, seccomp, cgroups) | host kernel (Landlock + seccomp; Seatbelt on macOS) | hardware (libkrun) | host kernel (ns), userspace kernel (gvisor), hardware (vm) |
| OCI images | yes | no images at all | yes | yes |
| Per-call cost | a fresh box, single-digit ms | none — it confines a process you were starting anyway | a microVM, boot under ~100 ms | a fresh sandbox, 12 ms — or a fork into a warm one, 1.4 ms |
| State between calls | none: the box is destroyed | whatever your process kept | a sandbox can be kept, branched and snapshotted | none, and not by destroying anything: each request is a fork() of a process that has never served one |
| Runs on macOS | Linux and WSL2 | yes, natively, with Seatbelt | yes | through a Linux VM it manages |
| Daemon | no | no | no | no system service; warm functions live under a supervisor that runs as your user |
kern
kern is the closest match to Zygo’s one-shot half today: rootless,
daemonless, one static Rust binary, OCI images, namespaces with a seccomp
allowlist and cgroup v2, a box made and thrown away per call in single-digit
milliseconds by its own figures. If you want docker run without the daemon
and without the 300 ms, both projects answer the same question, and kern’s
answer is a good one. Measured side by side on a real Python workload, the two
are within 3–5% of each other; the numbers
are further down.
They part ways on what happens next. kern makes the box cheap enough to throw
away every time, so there is nothing to keep warm. Zygo notes that the
expensive part is not the box but the interpreter inside it: a Python
process with its imports done is 150 ms or more that a per-call box pays
again on
every call, whatever the box costs. zygo serve pays it once, and
zygo exec forks into it for 1.4 ms, with request n running on a copy of
the memory the zygote had before request n−1 existed. That warm path, the
protocol behind it, and the per-request cgroup,
deadline and secrets that hang off it are Zygo’s real subject. The one-shot
runner is the part that had to exist underneath it.
kern also has something Zygo lacks: virtual resource slices (vcpu:,
vdisk:, vgpio:) declared in a config file and attachable to a bare host
process. Zygo has no equivalent and no plans for one.
kern, per call Zygo exec, per call
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ make box (cheap) │ │ fork warm zygote ~1.4 ms │
│ Python start+imports ~150 ms │ │ (Python + imports paid once) │
│ run, then destroy box │ │ run, then child exits │
└──────────────────────────────┘ └──────────────────────────────┘
nono
nono is not so much a rival as a different layer, and the words overlap, so it
is worth saying so. nono applies Landlock and seccomp — Seatbelt on macOS — to
a process you are starting anyway: your coding agent, running as you, with
your files. There is no image, no namespace, no cgroup and no runtime. What it
gives you is that the agent cannot read ~/.ssh or reach a host you did not
allow, enforced by the kernel and impossible to undo once applied. It works
natively on a Mac.
That is the right tool for confining an agent that is meant to edit your working folder. It is the wrong one for running code the agent wrote, because that code still runs as you, in your files, with your environment — a narrower version of you, but still you. Zygo is the other half: the agent stays outside, and the code it generates goes into a sandbox with its own root file system, its own pid namespace, a memory limit and a deadline. The two fit together, and on a developer’s machine using both is reasonable.
┌─────────── nono: confines the agent, running as you ───────────┐
│ coding agent (can edit your folder, cannot read ~/.ssh) │
│ │ │
│ └─ generated code ─▶ zygo run / zygo exec │
└──────────────────────────────┬─────────────────────────────────┘
▼
┌─────── Zygo sandbox: own root, own pids ─────────┐
│ memory limit · deadline · cannot see your files │
└──────────────────────────────────────────────────┘
microsandbox
microsandbox is the closest project to Zygo’s vm backend, and further along
that road. It is microVM-first: every sandbox is a libkrun guest with its own
kernel — libkrun is the same library behind Zygo’s vm backend. It supports
OCI images, claims a boot under 100 ms, and can snapshot and branch a live
sandbox, which Zygo cannot do at all. It ships Python, TypeScript and Rust
SDKs and an MCP server, as Zygo does.
The difference is where the default sits. microsandbox’s wall is hardware for
everything. Zygo’s default is the host kernel, with hardware available as
--isolation vm for the work that needs it — the same spec and the same
command. That is a real trade, and it does not go one way. A microVM per
request is a wall a kernel bug does not cross, and Zygo’s ns backend is one
kernel away from the host, as chapter 23 says in as many
words. What Zygo has instead is the warm path — 1.4 ms, a fork, clean state —
which a VM per request cannot reach, and which is the only reason the project
exists.
If your code is truly hostile and 100 ms per call is affordable,
microsandbox’s default is the safer one. If you run a thousand short calls a
minute from your own users’ scripts, Zygo’s is the faster one, and
--isolation vm is there for the part that is not safe to run on ns.
Zygo’s vm backend is also, today, much less than microsandbox: it boots a
guest and runs one-shot sandboxes, and warm functions and networking inside
the guest are not built; ADR 0002 says why.
Sandlock and Zeroboot: two other forks
Two projects from 2026 fork a warm process for each call, as zygo exec does.
Each does it from a different row of the map. Their numbers below are their
own claims, from their pages, not measured here.
Sandlock (Multikernel) confines a process with Landlock and seccomp — no
namespaces, no cgroups, no image. Its template mode starts a Python process
once, lets it run its init(), then forks it for each call: about 0.7 ms a
clone by its own figures, with the interpreter’s state and imported modules
shared copy-on-write. It is a library with Rust, Python and Go bindings, a
CLI, and a shim that lets it stand in as an OCI runtime; its README names
Linux 6.12 for the current release. On the map it is the bottom row: the
clone runs as you, in your filesystem narrowed by Landlock, with no root of
its own, no pid namespace and no cgroup. Zygo’s fork lands in a sandbox with
its own root, its own pid namespace, a cgroup and a deadline per request, and
the supervisor, tenants, secrets and HTTP API around it.
Zeroboot snapshots a Firecracker microVM with the runtime loaded and maps
the snapshot’s memory copy-on-write for every new VM: 0.79 ms usually and
1.74 ms for 1 in 100 by its own figures, each fork a VM with a kernel of its
own. A fork has no network — serial I/O only — and one vCPU; it needs KVM,
and the project calls itself a working prototype that is not production-
hardened. It is the top row’s answer to the same question, and the one to
watch for T3 code, where Zygo has only a one-shot vm today.
Sandlock clone Zygo exec Zeroboot fork
────────────── ───────── ─────────────
fork a confined process fork a sandboxed zygote fork a VM snapshot
Landlock + seccomp namespaces, cgroup, seccomp, KVM, own kernel
your files, narrowed Landlock; own root, own pids no network, 1 vCPU
~0.7 ms (its claim) 1.4 ms (measured, chapter 25) 0.8 ms (its claim)
gVisor
gVisor is a kernel written in Go that runs in user space. Your program’s
syscalls go to it, not to the host, and it answers most of them itself, using
only a small, filtered set of host syscalls. That gives a much smaller attack
surface than the host kernel, without needing KVM or a virtual machine. The
cost is speed on syscall-heavy work, and some programs that need rare kernel
features. Its runtime, runsc, is an OCI runtime. Zygo’s gvisor backend
uses it for one-shot runs: zygo backend install gvisor, then
zygo run --isolation gvisor. Warm functions stay an ns feature, for the
reason given in chapter 8.
Firecracker, Cloud Hypervisor, and platforms on them
Firecracker is the microVM monitor behind AWS Lambda and Fargate: a tiny virtual machine per workload, with a real kernel of its own, booting in about 125 ms or restoring from a memory snapshot faster still. Cloud Hypervisor is a similar microVM monitor. They give the strongest wall on this page, at a boot cost per VM, and they need KVM, so they rarely run inside another cloud VM. Platforms such as E2B build hosted sandboxes for AI agents on top of Firecracker. Its snapshot restore is the one thing here that resembles Zygo’s fork, one level down: a whole VM restored instead of a process copied.
Zygo’s vm backend is built on libkrun for the same purpose: anonymous code,
not your own. It boots a guest and runs one-shot sandboxes, with a private
writable layer over the image. Warm functions and guest networking are
refused on it by decision, not by omission; ADR 0002
explains why.
Kata Containers
Kata Containers runs each container, or each Kubernetes pod, inside a lightweight virtual machine, while still looking like a normal container to Kubernetes. It gives you a hardware wall without changing how you deploy. The price is a VM’s start-up time and memory for every pod. It is built for long-lived services on a cluster, where Zygo is built for short calls on one machine.
AWS Lambda and its relatives
Lambda and similar services are managed platforms. Zygo is a local runtime with a similar shape — a function, a warm instance, a request — and no platform around it: no billing, no scaling across machines, and no ingress (accepting connections from outside).
Hosted sandboxes for agents: E2B, Modal, Daytona and the rest
Since 2025 a new group of products sells a sandbox for an AI agent: E2B, Modal Sandboxes, Daytona, Vercel Sandbox, Cloudflare Sandboxes, Blaxel, Docker’s own Sandboxes, and more every quarter. The README names three of them; this section says where they sit on the map above, because the word “sandbox” covers two different things.
What they are. Each gives an agent a session: a machine of its own with a filesystem, a shell, packages it can install, ports it can expose, and a lifetime of minutes to hours, billed by the second. The wall is a microVM (Firecracker at E2B and Vercel, a custom monitor at Docker) or gVisor (Modal), and the session can often be paused, snapshotted and resumed. They run in the vendor’s cloud; some can be self-hosted, at the cost of running their control plane. Everything in this paragraph is their own description, not measured here.
Where they sit. On the map they are the top-left cell — a virtual machine, built for each session — with one addition: the session stays. That is the right shape for an agent that writes code, runs it, reads the error and tries again for half an hour. It is the wrong shape for what Zygo is for: a function that runs for milliseconds, thousands of times, and must start clean each time. A session per call would cost a VM boot, or a snapshot restore, per call.
a hosted agent sandbox a Zygo warm function
────────────────────── ────────────────────
one session, minutes to hours one call, milliseconds
state kept between commands no state between calls
a VM (or gVisor) per session a fork per call, on one kernel
in their cloud, billed per second on your machine, no billing
pause, snapshot, resume nothing to resume: warm again
ports, a shell, a desktop no ingress, no shell in the request
Choosing. If an agent needs a machine to work in — install packages, run a server, keep files between steps — use one of these, or microsandbox on your own hardware. If a program needs to run many small pieces of untrusted code — an agent’s tool calls, a customer’s plugin, a workflow step — and each must be cheap and clean, that is Zygo. The two combine: an agent living in a hosted session can still call a Zygo function for the tool that must answer in a millisecond.
runc, crun and youki
These are the low-level OCI runtimes: given a folder and a config.json,
they do the list at the end of chapter 4
and exec the program. runc is written in Go, crun in C, youki in Rust. They
are the part of Docker and Podman closest to zygo run, and on their own they
are fast. But they expect someone else to prepare the bundle, pull images,
keep records and clean up — which is the chain from
chapter 5. Zygo does its own
launching, so it needs none of them.
Workflow engines: Windmill and friends
Windmill, Temporal and similar workflow engines run users’ scripts, and they
need a sandbox for each run. Today that is often nsjail or a container per
job. These engines are exactly the embedder Zygo is designed for — the
program that builds Zygo into itself. A worker calls zygo serve once per
script, or uses the SDK, and each script run becomes a fork() instead of a
container. examples/workflow-engine/ is
such a worker.
A prefork pool of your own
The first question an embedder asks is a fair one: why not fork the warm
interpreter yourself? Python can. A prefork pool loads the code once and
forks workers from it. Python’s multiprocessing has a forkserver start
method that imports the modules you name once and forks a child from them
for each task. gunicorn --preload --max-requests 1 loads the application in
its master and replaces each worker after one request. Either gives the speed
trick zygo exec uses: the imports are paid once, and each request runs in a
fresh copy.
What neither gives is a wall. The child runs as your user, in your file system, on your network, with no memory, process or time limit of its own unless you add one. For code your own team wrote, that is enough, and simpler than Zygo: use it. For code a customer or an agent wrote, the fork is the easy half. The hard half is what Zygo puts around the copy before it runs, which chapter 6 walks through. Node cannot fork a running process at all, so there a prefork pool is a pool of pre-loaded workers, as Zygo’s Node agent keeps (chapter 13).
your own prefork pool zygo exec
───────────────────── ─────────
fork the warm interpreter fork the warm interpreter
the child runs as you own user, pid, mount and network namespaces
your files, your network its own root; network off unless allowed
limits: whatever you add a cgroup and a deadline per request
no syscall filter seccomp allowlist, Landlock
right for your own code right for code others wrote
WebAssembly runtimes
Wasmtime, Wasmer, Spin and Extism run code compiled to WebAssembly (Wasm): a portable instruction format that runs inside the runtime’s own process. A Wasm module can touch only the memory it was given and the host functions it was handed, so the wall has no kernel in it at all. A new instance starts in well under a millisecond, by their own figures. Tools such as Wizer can even run a module’s start-up once and save the memory it leaves, which is the Wasm form of a zygote.
The price is the compile step. Your code, and every library it imports, must be built for Wasm. Pure Python and JavaScript run on interpreters compiled to Wasm, but a package with native parts, such as numpy or a database driver, needs a Wasm build of its own. Some projects, Pyodide for one, ship builds of popular packages; most of PyPI and npm has none. A module sees files and the network only as far as the host opens them to it. Zygo makes the opposite trade: any program in any OCI image runs unchanged, and the wall is the host kernel, with what that costs (chapter 23). If your plugins are small, self-contained and built with a toolchain you control, Wasm is the stronger boundary. If they install whatever they import, that is Zygo’s column.
Measured: Zygo against nsjail and kern
nsjail and kern are the two one-shot runners closest to zygo run, so both
were measured against it under load, on 24 September 2026. Every run happened
in the same VM: Ubuntu 24.04, kernel 6.8, aarch64, 2 vCPU, 4 GB, on an M1 Max.
Every binary ran from the VM’s own disk. The load generator ran outside the VM,
so its CPU counts for nobody. “CPU per job” is the CPU of the whole stack being
measured, divided by the jobs it finished. Unlike the numbers in
chapter 25, these tables cannot be repeated from this
repository: the load generator and the raw results were not kept
(what these numbers are not).
Inside Windmill, in place of nsjail
Windmill CE v1.817 with three general workers ran a trivial Python script and a CPU-bound one (about 20 ms of Python) through nsjail, using Windmill’s own nsjail config. Then the same workers ran them through Zygo in nsjail’s place, and nothing else changed. Zygo ran two ways:
- Zygo as
nsjail: thezygobinary, installed under the namensjail, read nsjail’s command line and config itself. This translation was built for the benchmark only and is not in Zygo today. - Zygo through a script: a shell stand-in translated the config and called
zygo run. This is what works with Zygo as it ships.
Per job, 40 runs in a row inside a worker:
| nsjail | Zygo as nsjail | |
|---|---|---|
| wall time | 19–20 ms | 20 ms |
| CPU | 18.8–19.0 ms | 18.8–19.3 ms |
Under load, the whole Windmill stack. nsjail was measured twice; its ranges cover both runs.
| nsjail | Zygo as nsjail | Zygo against nsjail | Zygo through a script | |
|---|---|---|---|---|
| burst of 200, trivial: jobs/s | 51.4 | 50.0 | −3% | 43.6 |
| burst of 200, CPU-bound: jobs/s | 38.0–38.3 | 37.0 | −3% | 33.4 |
| CPU per job, trivial burst | 32.8 ms | 33.2 ms | +1% | 38.1 ms |
| CPU per job, CPU-bound burst | 44.7–45.4 ms | 46.2 ms | +2–3% | 50.7 ms |
| 20/s steady: usually / 1 in 100 | 55–57 / 97–102 ms | 56 / 94 ms | level | 62 / 103 ms |
| 40/s steady: usually / 1 in 100 | 57–60 / 91–99 ms | 65 / 112 ms | +8–14% / +13–23% | 87 / 156 ms |
| highest rate sustained | 48.5–49.6/s | about 47/s | −4–6% | about 41/s |
| idle memory of the stack | 384–634 MB | 534–590 MB | level | 531–565 MB |
| failed jobs | 0 | 0 of 2 600 | 0 | |
| per-job cgroup limits | no | memory, processes | memory, processes | |
| seccomp, Landlock | no | yes, yes | yes, yes |
Level per job, and 1–6% behind at saturation, while doing more. Zygo gave
every job a cgroup with memory and process limits, a seccomp allowlist and a
Landlock ruleset, and Windmill’s nsjail config sets none of those. The likely
cost at saturation is the cgroup Zygo creates and removes per job:
lru_gen_online_memcg, cgroup_addrm_files and tg_set_cfs_bandwidth show in
perf. That was not measured on its own. Below saturation the two cannot be
told apart. Through a shell script, the same swap costs about 15% of
throughput, because the script’s sh, awk, grep and env add about 4 ms
to every job. Idle memory does not move, because neither sandbox stays resident
between jobs.
Running it found three defects in Zygo, all fixed:
- Two runs could share a staging directory. The three workers shared one Zygo store, but each had its own PID namespace, and a staging root was named after the PID. 2 jobs in 200 failed. Every per-process name now carries the PID and 64 random bits.
- A writable mount of a single file never started. Landlock was given
directory rights on a regular file, and the kernel answers that with
EINVAL. nsjail configs hand a job itsresult.jsonexactly this way. A rule on a file is now narrowed to the file rights. - The stand-in script itself cost 4 ms a job, as described above.
Inside n8n, as its Code-node runner
n8n runs a Code node through a task runner, a process apart from n8n.
examples/n8n-runner is one that sends each task
to a Zygo runtime pool, and was measured against n8n 2.38.7’s own runners
behind the same n8n (chapter 25
has the method and every table).
| n8n’s runner | Zygo’s runner | |
|---|---|---|
| Python, one request, usually | 213 ms | 36 ms |
| Python, 200 at once, per second | 7.6 | 32.1 |
| JavaScript, one request, usually | 27 ms | 49 ms |
| JavaScript, 200 at once, per second | 26.5 | 23.6 |
| first run after 30 s idle, JS · Python | 975 · 496 ms | 56 · 38 ms |
| Code node with modules allowed reaches n8n, the LAN, the internet | yes | no |
| a task over its memory limit | no limit | dies alone |
The two languages go opposite ways for one reason. n8n’s JavaScript runner runs every task in one Node process, which is cheap and shares everything; its Python runner starts a process per task and pays about 200 ms of CPU for it. Zygo pays for a process per task in both, about 5 ms in Python and 25 ms in Node, which cannot be forked. The Zygo runner covers the Code node’s items and both run modes, not n8n’s RPC helpers or binary data.
An embedder’s harness, against kern
The workload was a real embedder’s Python harness: it reads an event, runs a
user’s handler, and writes the result through a read-write scratch mount, under
the embedder’s limits. /bin/true measured the runtime alone. There were 64
runs at each concurrency from 1 to 32, twice. The ranges cover both passes and
every concurrency level. kern bc822de ran with --security-profile untrusted.
python:3.12-slim ships no bytecode. Zygo compiles it once into a layer of its
own, automatically (chapter 15).
kern does not, so it is shown both as it ships and with an image precompiled by
hand.
| Zygo | kern, precompiled image | kern, stock image | |
|---|---|---|---|
| the harness | |||
| runs/s | 60.6–66.8 | 63.2–68.5 | 26.5–28.5 |
| CPU per run | 29.5–32.7 ms | 28.4–31.2 ms | 69.8–75.3 ms |
| time per call, one at a time (usually) | 25.9 ms | 23.4–23.7 ms | 59.1–59.6 ms |
/bin/true | |||
| runs/s | 222–252 | 287–314 | |
| CPU per run | 7.5–9.4 ms | 5.5–6.6 ms | |
| time per call, one at a time (usually) | 7.0–7.7 ms | 4.0–4.2 ms | |
| failures | 0 | 0 | 0 |
On the real workload Zygo is within 3–5% of kern at its best, and more than twice as fast as kern as it ships. On an empty program kern is about 2 ms of CPU per run cheaper. That is the start-up floor: a 7.9 MB binary against 2.2 MB, a larger plan (≈1.5 ms), and Landlock, which kern does not apply by default and Zygo keeps.
Before this comparison, Zygo made 42 harness runs a second at 45 ms of CPU each. The comparison found these, all fixed:
- Zygo could not run from a delegated cgroup that also held its caller, a
systemd unit with
Delegate=yesor a service in a container. It exited 125.zygo.slicenow goes to the top of the tree delegated to the user. - Every run re-executed under a transient systemd scope, about 10 ms of CPU, because the delegation check asked about the wrong cgroup.
- The host probe ran on every run. It is now cached per boot for up to ten minutes.
- Zygo moved its own process into a cgroup on every run, 1.9–6.0 ms of a 6–11 ms start. It now moves only when it is in the way.
- The sandbox child was moved into its cgroup after
clone3, which waits out an RCU grace period after a quiet spell. It is now born there withCLONE_INTO_CGROUP, the order kern uses, which also puts the limits on from the child’s first instruction. The sandbox start went from 6–11 ms to 3–4 ms. - The exit wait slept with a backoff, so a program that exited at 13 ms was
noticed at 25 ms. It now uses
pidfd_openandpoll. - Every run built a registry client, with TLS roots and a multi-threaded runtime, to read one local file.
- A failed bytecode build was retried on every run, at 170–200 ms each. It is now remembered for an hour.
A run now starts 4 processes, down from 13 at the worst.
What these numbers are not
- They are all cold. Every job started a fresh interpreter. Zygo’s warm path, a fork into a zygote that has already imported everything, was not part of either comparison. Neither nsjail nor kern has one to compare it with.
- One VM, one kernel. On kernel 5.10, in Docker Desktop’s VM, the same sandbox made Python about twice as slow as a plain container did. That cost belongs to the old kernel, and it would have been measured against every namespace-based runner alike.
- The raw results and the load generator were not kept. The tables here
are their summary, and they cannot be repeated from this repository. The
numbers in chapter 25 can:
make bench-recordwrites them as JSON, andbench/holds the records so far.
Everything in one table
| Wall | Images | Limits | Warm fork | Root needed | Main use | |
|---|---|---|---|---|---|---|
| Zygo | host kernel · gVisor · VM | OCI | cgroups, mandatory, per request | yes | no | short functions, others’ code |
| Docker | host kernel | OCI | cgroups, opt-in | no | daemon (or rootless mode) | services, packaging |
| Podman | host kernel | OCI | cgroups, opt-in | no | no | services, without a daemon |
| nsjail | host kernel | no (a folder) | cgroups, rlimits | no | depends on features | CTFs, job runners |
| bubblewrap | host kernel | no (bind mounts) | none | no | no (or setuid) | desktop apps, a building block |
| firejail | host kernel | no | some | no | setuid root | desktop apps |
| minijail | host kernel | no | some | no | usually | OS services |
| kern | host kernel | OCI | cgroups | no | no | fast throwaway boxes |
| nono | host kernel (Landlock) | no | no | n/a | no | confining an agent |
| Sandlock | host kernel (Landlock, seccomp) | no | some, through seccomp notification (its claim) | yes, of a confined process | no | forking a warm Python without a container |
| Zeroboot | VM | a snapshot | the VM’s | yes, of a VM snapshot | KVM access | sub-millisecond VMs for hostile code; a prototype |
| gVisor | second kernel | OCI | cgroups | no | no, in rootless mode (how Zygo runs it); then its cgroup limits are advisory | safer containers |
| microsandbox | VM | OCI | the VM’s | no (snapshots) | no | hostile code |
| Firecracker | VM | no (a disk image) | the VM’s | no (snapshots) | KVM access | serverless platforms |
| E2B, Modal, Daytona, Docker Sandboxes | VM or gVisor, per session | OCI or their templates | the VM’s | no (snapshots) | their cloud (or self-host) | an agent’s working machine |
| Kata | VM | OCI | the VM’s | no | yes | safer Kubernetes pods |
| FreeBSD jail | host kernel | no (a folder) | rctl, opt-in | no | yes | long-lived services on FreeBSD |
| a prefork pool of your own | none: your user, your files | no | whatever you add | yes, unconfined | no | your own code, quickly |
| Wasmtime, Wasmer, Spin, Extism | the Wasm runtime; no kernel in the wall | no: a Wasm module | memory, and CPU by metering (theirs) | a saved start-up (Wizer) | no | small plugins built for Wasm |
What Zygo does not do
- Run on macOS or Windows natively. Sandboxes are Linux; on a Mac, Zygo manages a Linux VM for you.
- Provide ingress. No mode accepts connections; a function is called through the CLI, the SDKs or Zygo’s own HTTP API.
- Scale past one machine. Capacity is a per-host budget, and requests
past it get HTTP
429(too many requests). - Hide the kernel. The
nsbackend is one kernel, and every chapter of this book says so.
Choosing, in short
For long-lived services, use Docker, Podman or Kubernetes. For hostile code
where 100 ms or more per call is fine, use a VM wall: Firecracker, Kata,
microsandbox, or Zygo’s vm backend. For confining a tool you already run,
look at nono or bubblewrap. For a quick throwaway sandbox around one command,
nsjail, kern and zygo run all do well. For your own code, a prefork pool is
enough; for small plugins built for Wasm, a Wasm runtime is the stronger
wall. For many short calls to code other
people wrote, where each call must start clean and costs must stay in
milliseconds, that is the right-hand column of the map, and that is Zygo.
In one sentence: docker run asks a root daemon to create a container object
from an image; zygo run runs a program as a locked-down process under your
own user — the same kernel parts, the opposite defaults, no chain in between,
and nothing left behind.
11. Getting started
This chapter takes you from nothing to three warm functions behind an HTTP API. You install Zygo, ask it whether your machine can run sandboxes, run one program in a sandbox, and then keep a function warm and call it. Each step is short, and each one links to the chapter that explains it in full.
The road through this chapter
install ──▶ zygo doctor ──▶ zygo run ──▶ zygo serve ──▶ sandbox.toml ──▶ zygo api
(a file) (can this (one (one warm (three (call them
host do it?) sandbox) function) functions) over HTTP)
You can stop after any step. A CI job that only needs zygo run never has
to learn about warm functions, and a Mac user can do all of it without
setting up Linux by hand.
The five-minute version
If you would rather type first and read later, this is the whole road in one block. Every line is explained further down the page.
# install: Linux, x86_64 or aarch64 (a Mac: brew install mhmtskrc2/zygo/zygo)
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/
zygo doctor # can this host run sandboxes? --fix if not
zygo run python:3.12-slim python3 -c 'print("hello")' # one sandbox, thrown away
echo 'def handler(event): return {"got": event}' > handler.py
zygo serve ./handler.py --name echo # warm it once: about 150 ms
zygo exec echo '{"n": 1}' # a fresh fork of it: about 1.4 ms
zygo stop echo
If zygo doctor reports something red, its section
below says what each line means, and zygo doctor --fix applies the usual
fixes for you.
What you need
Zygo is one static binary: a single file that carries everything it needs, with no libraries or runtimes to install beside it. It builds sandboxes out of Linux kernel features, so the sandboxes themselves always run on Linux.
| Your machine | What happens |
|---|---|
| Linux, kernel 5.3 or newer | Zygo runs directly. Nothing needs root. |
| macOS | Zygo starts a small Linux virtual machine for you and runs inside it. |
| Windows | Zygo runs inside WSL2, Windows’ Linux VM; see below. |
| A dev container or a Codespace | The repository’s .devcontainer sets one up with sandboxes working; see below. |
| Anything else | Run Zygo in a Linux VM or container; either is fine. |
On Linux you also need unprivileged user namespaces (a normal user may
make the private views from chapter 2) and cgroup v2
delegation (a normal user may own part of the cgroup tree from
chapter 3). Most modern
distributions have the first; the second often needs one small fix, which
zygo doctor can apply for you.
Installing on Linux
Release builds exist for x86_64 and aarch64 (64-bit ARM, such as a Raspberry Pi 5 or a Graviton server). Both are static musl builds: musl is a small C library that is linked into the binary, so it does not depend on the host’s own libraries.
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 # prints "OK"
tar xzf zygo-*-unknown-linux-musl.tar.gz
sudo install -m 0755 zygo-*/zygo /usr/local/bin/zygo
uname -m prints x86_64 or aarch64, which picks the right file. The
second line checks the archive against the SHA256SUMS file that every
release carries, so you know it is the one that was published. The sudo is
only for copying the file into /usr/local/bin; Zygo itself never runs as
root.
SHA256SUMS is itself signed, in every release after 0.1.1. With
cosign installed, this proves the checksums came
from this project’s release workflow, not only from the same download page:
curl -fsSLO "$url/SHA256SUMS"
curl -fsSLO "$url/SHA256SUMS.sigstore.json"
cosign verify-blob SHA256SUMS --bundle SHA256SUMS.sigstore.json \
--certificate-identity-regexp '^https://github\.com/.*/\.github/workflows/release\.yml@refs/tags/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
Each release also carries an SBOM — a list of every library compiled into the
binary, with its version — as zygo-<version>.cdx.json, in the CycloneDX
format that security scanners read.
Installing on macOS
On a Mac, Homebrew installs three things: a small zygo shim for macOS (a
thin program that passes your commands on), the Linux build of Zygo that the
shim forwards into, and Lima, the tool that starts the Linux VM.
brew install mhmtskrc2/zygo/zygo
The formula lives in a tap (a small GitHub repository of Homebrew formulas),
mhmtskrc2/homebrew-zygo; Homebrew finds it from the name. The release build
writes the formula there, with checksums computed from the same archives, not
typed in by hand. The section
How Zygo runs on a Mac below explains what the VM
is and what it costs.
Other ways to install
# from crates.io, anywhere with a Rust toolchain
cargo install zygo-cli
# from a checkout of the repository
cargo build --release
sudo install -m 0755 target/release/zygo /usr/local/bin/zygo
cargo install zygo-cli builds the default set of features. The vm
backend is not in it. That backend links a virtual machine monitor from a
pinned git tag, and a crate published on crates.io may not point at a git
tag. So make vm-build from a checkout is the way to get it, and
zygo doctor tells you this on a host where vm would otherwise work.
Chapter 16 explains the
backends.
The container image
Zygo also ships as a container image, ghcr.io/mhmtskrc2/zygo. It is Alpine
Linux plus the static zygo binary and the few helpers networking needs.
It does not need --privileged, but it does need a few specific things from
Docker, because Zygo builds sandboxes inside it:
docker run --user 0:0 --security-opt seccomp=unconfined \
--security-opt systempaths=unconfined --security-opt apparmor=unconfined \
--cgroupns=host --cgroup-parent=/zygo -v /sys/fs/cgroup/zygo:/sys/fs/cgroup/zygo:rw \
-p 7700:7700 -e ZYGO_API_TOKEN=... ghcr.io/mhmtskrc2/zygo
| Option | Why Zygo needs it |
|---|---|
seccomp=unconfined | Docker’s own seccomp filter refuses to create a user namespace. |
systempaths=unconfined | Docker hides parts of /proc, and then the kernel refuses a fresh /proc inside a user namespace. |
--cgroupns=host --cgroup-parent=/zygo and the /sys/fs/cgroup/zygo mount | A sandbox needs a cgroup to live in. This gives the container one subtree of its own, not the whole host tree. |
--user 0:0 | The cgroup folder belongs to root. No sandbox runs as that root: each has a user namespace of its own. |
apparmor=unconfined | Only on a host with AppArmor, such as Ubuntu, whose default Docker profile refuses the mounts a sandbox makes. Harmless elsewhere. |
Sandboxes with a network need --device /dev/net/tun as well, and a volume
keeps pulled images across restarts; chapter 16
has the full command. zygo doctor names anything that is missing, inside
the container just as on a host. packaging/oci/ has the details,
and chapter 16 covers running it in production.
On Windows, through WSL2
WSL2 is a real Linux kernel in a small VM that Windows manages, so Zygo runs there as on any Linux host. The project has not tested it yet — no CI runner or development machine here is Windows — so treat this as the route that should work, and tell us if it does not. Three settings matter:
-
A distribution with systemd. In Ubuntu 24.04 under WSL2, put this in
/etc/wsl.conf, then runwsl --shutdownfrom Windows:[boot] systemd=truesystemd is what gives your user a delegated cgroup, as on a desktop Linux.
-
cgroup v2 only. WSL2 can mount the old cgroup v1 hierarchy beside v2, and Zygo needs v2 on its own. In
%UserProfile%\.wslconfigon the Windows side, thenwsl --shutdownagain:[wsl2] kernelCommandLine = cgroup_no_v1=all -
Install as on Linux, with the commands above, then run
zygo doctor. It checks the kernel version, user namespaces and cgroup delegation there, andzygo doctor --fixapplies what it can.
WSL2’s kernel is 5.15 or newer, well above Zygo’s 5.3 floor; below 6.1
doctor calls it degraded, as on any host, and it reports whether the
kernel was built with Landlock. There is no /dev/kvm by default, so the
vm backend is off.
Troubleshooting covers what doctor reports.
In a dev container or a Codespace
The repository has a .devcontainer for VS Code’s Reopen in Container and
for GitHub Codespaces. It builds Zygo, installs the tools the tests and
networked sandboxes need, and runs zygo doctor. Sandboxes work inside it:
one-shot runs, warm functions and egress networking were checked in it.
It needs two things a default container does not give. The container is
started --privileged, so Zygo may create user namespaces and mount inside
them. And a small script arranges the container’s own cgroup tree at every
start, because cgroup v2 hands controllers only to a cgroup with no
processes in it. zygo in that container is a wrapper that starts the real
binary in the empty cgroup the script left for it.
/sys/fs/cgroup (the container's own root: memory, pids, cpu for children)
├── init/ every process the container started, and each new shell
└── launch/ empty until zygo starts in it
└── zygo.slice/ … the sandboxes, as on a host
What does not work there: the vm backend (no /dev/kvm), and on a host
whose kernel is older than 5.13, Landlock; zygo doctor names both. It is a
development machine, not a production shape — chapter 16
covers running Zygo in a container for real, without --privileged.
How Zygo runs on a Mac
macOS has no namespaces or cgroups, so a Mac cannot build a Linux sandbox
itself. Zygo solves this by keeping a small Linux virtual machine (VM): a
whole computer simulated in software, with its own Linux kernel. You never
log into it. The zygo command on your Mac passes every sandbox command to a
Linux zygo inside the VM, with the same arguments, the same working folder
and the same input and output. The exit status comes back out to your shell.
your Mac Lima VM "zygo" (Ubuntu 24.04)
┌───────────────────────────────┐ ┌─────────────────────────────────┐
│ $ zygo run python:3.12 ... │ SSH │ zygo run python:3.12 ... │
│ zygo (macOS shim) ──────────┼───────────▶│ └─▶ sandbox (namespaces, │
│ │ ~22 ms │ cgroup, seccomp ...) │
│ output + exit status ◀────────┼────────────┼── output + exit status │
│ │ │ │
│ /Users/you/project ◀─────────┼── same ────┼─▶ /Users/you/project │
│ │ path │ (your home, mounted inside) │
└───────────────────────────────┘ └─────────────────────────────────┘
The Mac VM in detail
| Name | zygo, managed by Lima |
| System | Ubuntu 24.04 |
| Size | 2 CPUs, 4 GiB of memory, 20 GiB of disk |
| First start | about a minute: the VM is created on the first command that needs it |
| Later starts | about 16 seconds, after the VM was stopped |
| Cost per command, once it is up | about 22 ms, over the SSH connection Lima already holds |
| Stopping it | zygo stop --all stops everything, the VM included |
Your home folder is mounted inside the VM at the same path, and it is
writable. So ./handler.py is one file, seen from two sides. That is also the
limit, and Zygo enforces it: every host path must be under $HOME. A command
run from outside your home folder is refused, and the message names both
folders instead of quietly running somewhere else.
A few commands never need Linux, so they run on the Mac itself:
zygo completion, zygo doctor, zygo agent test and zygo api --openapi.
If the VM cannot be reached, a command exits with status 111;
chapter 22 says what to do.
What the Mac VM costs
A one-shot zygo run python:3.12-slim true typed in a Mac shell takes about
29 ms end to end, when a supervisor is running in the VM. About 6 ms of that
is the sandbox, and about 22 ms is the trip into the VM. true starts no
interpreter; the 12 ms quoted elsewhere for a one-shot run is
python3 -c pass, with Python’s own start inside it. These are medians of
nine runs on the Mac that
chapter 25
names. The same run through Docker Desktop on the same Mac took 397 ms.
Why does a one-shot run care about a supervisor? For its cgroup. Without one,
zygo run on a systemd login must make a transient scope of its own and
re-execute itself inside it, which costs about 12 ms more (25.7 against
13.5 ms inside the Lima VM,
chapter 25). With
a supervisor running, the run is handed to its cgroup tree instead.
The trip is paid per command, not per request. The millisecond warm path is
still there when you call functions through the HTTP API or the SDKs,
because then the calls happen inside the VM and the connection pays the trip
once. zygo api running inside the VM is the answer for anything that must
be fast on a Mac.
The Mac VM from a checkout
Without Homebrew you need the same two parts: Lima, and a Linux build of Zygo to put inside the VM.
brew install lima # what starts the VM
make guest-build # the Linux build that runs inside it, compiled in the VM
make guest-build needs nothing but the VM. It installs a Rust toolchain
inside the VM on first use. On a Mac that already has Docker,
make tests/linux/bin/zygo-linux-musl builds the same binary in a Docker
container instead.
Checking the host: zygo doctor
zygo doctor is the first thing to run on any new machine. It checks
everything a sandbox needs, prints one line per check, and prints the fix for
anything that is missing. It tries each thing rather than reading a setting.
For example, it really builds a user namespace and mounts inside it. So a
kernel that has a feature switched on but ignores it is caught here, not later
inside a sandbox.
zygo doctor # can this host run sandboxes?
zygo doctor --json # the same report, for a script or a health check
On a Mac, doctor checks both sides: the Mac’s side (can it reach the VM?)
and the VM’s kernel. You get one report with one verdict.
What doctor checks
| Check | What it asks |
|---|---|
kernel | Is the kernel 5.3 or newer? Below 6.1 it says degraded. |
kernel age | How old is this kernel series? An old one is behind on hardening, and one out of upstream support may lack security fixes. |
user namespaces | Can a normal user create one and mount inside it? |
procfs (fully visible) | Can a fresh /proc be mounted inside a sandbox? |
cgroup v2 | Is the unified cgroup tree there, with controllers delegated to you? |
cgroup moves | On Linux 6.0+, is cgroup2 mounted with favordynmods? Without it, about 1 warm request in 100 waits several ms (chapter 22). |
systemd OOM policy | Would the systemd unit that holds the sandboxes be stopped when one of them is OOM-killed? degraded under the default OOMPolicy=stop (chapter 16). |
overlayfs (userns) | Can image layers be stacked inside a user namespace? |
landlock | Which Landlock version (ABI) does the kernel offer? |
seccomp | Can a seccomp filter be installed? |
subuid/subgid | Does your user own a range of extra user ids to map? |
kvm | Is /dev/kvm there, for the vm backend? |
guest kernel | Is a kernel for the vm backend’s guest available? |
runsc | Is gVisor installed, for the gvisor backend? |
egress (pasta + nft + tc) | Are the programs that give a sandbox a filtered network present? |
The last lines of the report list the backends this host can use right now: the host has what each one needs and this build of Zygo includes it.
Reading the report
Each line ends in one of four words. The exit status of zygo doctor is 0
exactly when no line says FAIL, and the JSON field ok says the same thing.
| Status | Meaning |
|---|---|
ok | Present and working. |
degraded | Works, but something is missing or old, and a feature is weaker or slower. |
absent (shown as -) | An optional part is not there, such as kvm. Nothing is broken. |
FAIL | Sandboxes cannot run until this is fixed. The fix is printed under the line. |
network = "none", the default, needs no networking tools at all. So a
missing pasta is absent, not FAIL, and only matters when you turn a
sandbox’s network on.
Which kernel version gives what
Zygo needs kernel 5.3 or newer. Later kernels unlock extra features rather
than block Zygo. 6.1 or newer is recommended, because from there
cgroup.kill and memory.peak are both present. Landlock’s network rules
come later, in 6.7: below it, bind and connect are limited by the
network namespace and the firewall alone.
5.3 ─────────── 5.11 ─────────── 5.13 ─────────── 6.1 ─────────── 6.7 ─────────▶
minimum overlayfs in a Landlock recommended: Landlock
to run user namespace (file access cgroup.kill, network rules
(below: layers rules) memory.peak
are flattened,
more disk, slower
first run)
Letting doctor fix things: --fix
zygo doctor --fix applies the fixes it knows. It first prints a plan: each
change, why it is needed, what it costs, and the exact commands. Then it asks
apply these? [y/N]. Nothing happens unless you type y. In a script, where
nobody can answer, add --yes.
zygo doctor --fix # show the plan, ask, then apply
zygo doctor --fix --yes # the same, without asking
| What it can fix | What it does | Needs root |
|---|---|---|
| The AppArmor block on user namespaces | Installs an AppArmor profile, /etc/apparmor.d/zygo, that lets the zygo binary alone create user namespaces; the restriction stays on for every other program. Only where AppArmor cannot load a profile: sets kernel.apparmor_restrict_unprivileged_userns=0 now and in a file under /etc/sysctl.d/, so it stays after a reboot — machine-wide in that case | yes, through sudo |
| No cgroup delegation | Writes ~/.config/systemd/user/user@.service.d/delegate.conf and reloads your systemd user manager | no |
Missing pasta or nft | Installs the passt and nftables packages with apt, dnf, pacman or apk | yes, through sudo |
The AppArmor profile that blocks pasta | Runs aa-complain on the pasta profile, so it logs instead of blocks | yes, through sudo |
Slow cgroup moves (cgroup moves … degraded) | Remounts cgroup2 with favordynmods now, and installs zygo-cgroup-favordynmods.service to do it at every boot. Every fork and exit gets slightly slower. Machine-wide. Not offered inside a container | yes, through sudo |
Ubuntu and Debian: two AppArmor rules
AppArmor is a Linux security module that limits what programs may do. Ubuntu and Debian ship two AppArmor rules that get in Zygo’s way. Both come from the distribution, not from Zygo.
The first is kernel.apparmor_restrict_unprivileged_userns=1. It lets a
normal process create a user namespace, and then refuses the first mount
inside it. That mount is the first thing every sandbox does. zygo doctor
finds this by trying the mount, and zygo doctor --fix installs an AppArmor
profile that lets the zygo binary alone past it. The restriction stays on
for every other program. Chapter 23 says what the rule
protects.
The second is an AppArmor profile for pasta, the program Zygo uses to give
a sandbox a network. Where that profile is enforced, it keeps pasta out of
the sandbox’s user namespace. Then network = "egress" and "full" cannot
start, even though /dev/net/tun works. The error says so and names
aa-complain. The default, network = "none", does not use pasta at all.
cgroup delegation, the usual gap
On a machine with systemd, delegation is the most common missing piece, and it
has two halves. First, your user manager (the systemd process that looks
after your login) has to pass the controllers down to you. This is the fix
doctor --fix writes, and you can also do it by hand:
mkdir -p ~/.config/systemd/user/user@.service.d
printf '[Service]\nDelegate=cpu cpuset io memory pids\n' \
> ~/.config/systemd/user/user@.service.d/delegate.conf
systemctl --user daemon-reexec
Second, the process that runs Zygo has to sit in a delegated cgroup, and a login shell over SSH does not. Zygo handles this half itself, the way Podman does. A command that builds a sandbox restarts itself inside a temporary systemd scope of its own, which is a delegated cgroup made for one command. Chapter 12 has the details.
your SSH login shell's cgroup (not delegated: cannot hold a sandbox)
│
└─ zygo run ... ──re-exec──▶ systemd-run --user --scope -p Delegate=yes
└─ zygo run ... (delegated: builds the sandbox)
One result surprises people. zygo doctor run from a plain SSH session can
report cgroup v2 … FAIL, because that is true of the cgroup it stands in.
zygo run from the same shell still works, because it moves into a scope
first. Chapter 22 has the rest.
Your first one-shot sandbox
A one-shot sandbox is built for one program and thrown away when the
program ends. This is zygo run, and it reads like docker run:
zygo run python:3.12-slim python3 -c 'print("hello")'
The first run downloads the image, just as docker run does. After that, a
run takes about 12 ms (median, with the image already pulled, measured on the
hosts in chapter 25). The sandbox has a read-only root,
no network, no capabilities, and memory, CPU and process limits. A few more
to try:
zygo run --mem 128M --pids 16 --timeout 10s alpine:3 /bin/sh # tighter limits
zygo run --tty alpine:3 /bin/sh # with a terminal of its own
zygo run --dry-run --json python:3.12-slim # the plan, without running it
Chapter 12 covers zygo run in full: mounts,
environment, exit codes and more.
Your first warm function
A warm function is a sandbox that is built once and kept ready, so each request skips the start-up. You write a handler: a function that takes one event and returns a result.
mkdir demo && cd demo
cat > handler.py <<'EOF'
def handler(event):
return {"doubled": event.get("n", 0) * 2}
EOF
zygo serve handler.py --name double # python:3.12-slim is in the store from the run above
zygo exec double '{"n": 21}' # {"doubled": 42}
zygo ps # what is running
serve starts a supervisor in your session if none is running: the
background process that keeps warm functions alive. Then it warms a zygote:
the Python interpreter with handler.py already imported, waiting. Every
exec is a fork() of it, which means a copy made in a moment. So there is
no import cost per request and no state left over between requests.
Chapter 6 explains the idea.
What the warm function costs
zygo serve zygo exec zygo exec zygo exec
├─ build sandbox ├─ fork ├─ fork ├─ fork
├─ start Python, import handler └─ reply └─ reply └─ reply
└─ park the zygote ~1.4 ms ~1.4 ms ~1.4 ms
~150 ms, paid once
On a Raspberry Pi 5, a Python handler that imports nothing is warm in about 150 ms, including starting the supervisor. The median overhead per request is about 1.4 ms, measured on a Lima VM on an Apple M1 Max. Both numbers and their machines are in chapter 25. The same trick works from a one-line file:
echo 'def handler(event): return {"got": event}' > handler.py
zygo serve ./handler.py --name echo
zygo exec echo '{"n": 1}'
zygo bench all # every published number, measured again on this host
A project with three functions
A real project describes its functions in a file called sandbox.toml, so
you do not repeat flags. Here are three functions that each show a different
shape: a Python function with system packages, a Go binary, and a function
that may call one outside API.
# sandbox.toml
[defaults]
image = "python:3.12-slim"
mem = "256M"
[fn.resize]
entry = "./resize.py"
requirements = "./requirements.txt" # a venv, built once inside the image
system = ["libwebp7"] # apt packages, once, as a layer
mem = "512M"
[fn.parse]
image = "alpine:3" # no runtime → warm-exec
mounts = ["./bin/parse:/app/parse:ro"] # a static Go binary
cmd = ["/app/parse"]
[fn.fetch]
entry = "./fetch.py"
network = "egress"
allow = ["api.example.com:443", "*.cdn.example.com:443"]
secrets = ["API_KEY"]
[defaults] applies to every function; each [fn.NAME] table adds to it or
overrides it. Chapter 20 lists every field.
What each function in the file does
| Function | Shape | What is special |
|---|---|---|
resize | Python handler (entry) | requirements becomes a Python venv (a folder of installed packages), built once inside the image. system installs apt packages once, as an extra image layer. It gets 512 MB instead of the default 256 MB. |
parse | warm-exec (cmd) | A Go binary starts fast, so only the sandbox is kept warm and each request runs the program fresh. It uses alpine:3 because it needs no Python. |
fetch | Python handler with network | network = "egress" opens only the names and ports in allow. API_KEY is read from your shell and given to each request as a file, never as an environment variable. |
sandbox.toml
│
├─ [fn.resize] ──▶ python:3.12-slim + libwebp7 layer + /venv ──▶ zygote: fork per request
├─ [fn.parse] ──▶ alpine:3 + your Go binary, mounted ─────────▶ sandbox: exec per request
└─ [fn.fetch] ──▶ python:3.12-slim, network: only the allow list,
/run/secrets/API_KEY per request ───────────▶ zygote: fork per request
Chapter 13 covers the shapes, chapter 14 the network and secrets, and chapter 15 the venv and the apt layer.
Bringing the project up
zygo pull alpine:3 # up never pulls; python:3.12-slim is already there
export API_KEY=… # read from this shell, delivered as a file
zygo up # every [fn.*] warm
zygo exec fetch '{"path": "/v1/ping"}'
zygo logs fetch -f # follow the function's log
zygo down # stop them all
zygo up warms every function in the file. It never pulls an image — a deploy
should not quietly depend on a registry — so pull first, as above; zygo run
is the one command that pulls for you
(chapter 15). Run
up again after an edit, and only the functions that changed are restarted.
Calling the functions over HTTP
zygo api puts an HTTP server in front of the functions. By default it
listens on 127.0.0.1:7700, and every caller needs a bearer token: a secret
string sent in the Authorization header.
export ZYGO_API_TOKEN=$(openssl rand -hex 16)
zygo api # 127.0.0.1:7700
curl -H "Authorization: Bearer $ZYGO_API_TOKEN" \
-d '{"n": 4}' http://127.0.0.1:7700/fn/double
The token is needed on every kind of listener, unix sockets included.
--no-auth turns it off, and Zygo refuses --no-auth anywhere except a unix
socket or a loopback address such as 127.0.0.1.
curl / your app ──HTTP──▶ zygo api ──▶ supervisor ──▶ double (zygote)
Authorization: 127.0.0.1:7700 resize (zygote)
Bearer <token> parse (warm sandbox)
fetch (zygote)
What the API answers
| Route or status | Meaning |
|---|---|
POST /fn/<name> | Run one request with the body as the event. |
POST /fn/<name>/batch | Run a list of events. |
GET /metrics | Numbers in Prometheus text format, for a monitoring system to collect. |
408 | The request ran past its deadline. |
429 | This tenant’s queue is full. The Retry-After header says when to try again. |
Chapter 17 has every route, status code and header, and the Python, Node and Elixir clients.
Sending metrics to OpenTelemetry
Instead of waiting to be scraped at /metrics, Zygo can push the same
numbers to an OpenTelemetry collector. OpenTelemetry (OTel) is a common
standard for sending metrics and traces to monitoring tools.
zygo api --otlp-endpoint http://localhost:4318
OTEL_EXPORTER_OTLP_ENDPOINT does the same as the flag. The numbers go as
OTLP/HTTP JSON once a minute, and OTEL_EXPORTER_OTLP_HEADERS adds a header,
for example for authentication. Only metrics are sent. There are no
per-request traces (spans) yet.
When something is wrong
zygo logs fetch --failed # the requests that failed, with their stderr
zygo shell fetch # a shell inside the warm sandbox
zygo spec explain fetch # every limit and mount, fully resolved
zygo run --dry-run --json python:3.12-slim # the mount plan, without running it
zygo doctor # is the host still able to run sandboxes?
Chapter 22 lists the errors people actually meet, with the fix for each.
Where to go next
| If you want to… | Read |
|---|---|
| see all three ways working in one small app | examples/web-api — a web API whose three endpoints use a warm function, a pool and a fresh sandbox |
use zygo run well | 12. One-shot sandboxes |
| write handlers in more languages | 13. Warm functions |
| set limits, network and secrets | 14. Limits, network and secrets |
| understand images, venvs and apt layers | 15. Images and dependencies |
| run it on a server | 16. Production |
| call it from a program or an AI agent | 17. API, SDK and MCP |
look up a field of sandbox.toml | 20. sandbox.toml |
| see measured speed and memory | 25. Performance |
| compare with Docker, gVisor and Firecracker | 10. Similar projects |
| read the security model | 23. Security |
The examples/ folder has complete projects: a small web
API, a webhook, a CI job, an LLM tool, a Go program and a shell script as
warm-exec functions, a plugin host in Python and in Node, a workflow-engine
worker, an n8n Code-node runner, a Kubernetes deployment, and an agent in
POSIX sh. Its README says what each one shows.
12. One-shot sandboxes: zygo run
zygo run builds a fresh sandbox, runs one program in it, and removes the
sandbox when the program ends. It reads like docker run, but it has safe
defaults and leaves nothing behind. This chapter covers everything about it:
what happens inside, how to give the sandbox your files, and how to tell why
it ended.
The shape of the command
zygo run [FLAGS] IMAGE [COMMAND ARGS...]
zygo run python:3.12-slim python3 -c 'print("hello")'
The image is a normal container image from any registry, such as Docker Hub. Everything after the image is the command, which runs inside the sandbox. With no command, the image’s own entrypoint runs, just as with Docker. Put Zygo’s flags before the image; anything after it belongs to the command.
What happens, step by step
zygo run --mem 128M python:3.12-slim python3 app.py
│
├─ 1. plan read the flags and any sandbox.toml, check the limits,
│ find the image in the store (pull it if missing)
│
├─ 2. place supervisor running? ── yes ─▶ hand the sandbox to it
│ │ no
│ └─▶ not in a delegated cgroup? re-exec in a systemd scope
│
├─ 3. start clone3 into new namespaces ─▶ the child builds its root,
│ joins its cgroup, drops capabilities, adds Landlock + seccomp,
│ and calls execve on your command
│
├─ 4. run your program runs; streams, signals and exit code pass through
│
└─ 5. end the kernel removes the namespaces and the tmpfs,
Zygo removes the cgroup ─▶ nothing is left
These are the three phases that --outcome reports: plan (step 1),
start (steps 2 and 3), and run (steps 4 and 5). Namespaces, cgroups,
capabilities, Landlock and seccomp are explained in chapters
2, 3 and 4, and
chapter 6 shows how
Zygo puts them together.
What the sandbox has
Every sandbox starts closed. You open what you need, one flag at a time.
| It has | Default |
|---|---|
| A root filesystem | The image’s layers, read-only |
A writable /tmp | Sized by --scratch: the smaller of 64 MB and half of --mem |
| A home folder | /tmp, unless the image or you set HOME, so pip and npm caches have a place to write |
| Memory | --mem 256M |
| CPU | --cpu 1.0, one full core |
| Processes | --pids 64 |
| Time | --timeout 30s |
| Open files | --nofile 1024 |
| Network | none at all (--net none) |
| Capabilities | none |
| System calls | the default seccomp allowlist |
What it does not have: your files, your network, your other processes, or any way to reach the image store it was built from.
The first run and the second
The first run of an image pulls it, just as docker run does. After that,
the image is in Zygo’s store under your home folder, and a run takes about
12 ms (median, image already pulled; the hosts are in
chapter 25). The first run on a kernel older than 5.11
is also slower, because Zygo has to flatten the image’s layers once.
Pull policy below explains how to control pulling.
Running your own code: mounts
The sandbox cannot see your filesystem. So a script of yours has to be
mounted in first. A mount makes a file or folder from your machine appear at
a path inside the sandbox. The interpreter, by contrast, comes from the image,
not from your machine — chapter 6
explains why zygo ./venv/bin/python app.py is not a thing.
zygo run --mount ./hello.py:/hello.py:ro python:3.12-slim python3 /hello.py
zygo run --mount ./src:/src:ro python:3.12-slim python3 /src/main.py --flag
printf '{"n": 21}' | zygo run --mount ./src:/src:ro python:3.12-slim python3 /src/double.py
The form is --mount HOST:GUEST[:ro|rw]. HOST is the path on your machine,
GUEST is the path inside the sandbox. A mount is read-only unless you add
:rw. You can mount a single file or a whole folder, and you can repeat
--mount as often as you need.
your machine the sandbox
./src/main.py ── --mount ─────▶ /src/main.py (read-only)
./pkgs/ ── --mount :rw ─▶ /pkgs/ (writable, changes are real)
everything else ── not visible ── (the sandbox cannot name it)
Programs from your host
You can run a program from your own machine the same way: mount it, then name it. It runs against the image’s libraries, not your host’s. So a program that is dynamically linked (it loads shared libraries such as libc when it starts) needs an image with a compatible libc. A static program carries its libraries inside it and runs anywhere.
zygo run alpine:3 pwd # the image's own pwd: /
zygo run --workdir /tmp alpine:3 pwd # /tmp
zygo run --mount /bin/pwd:/opt/pwd:ro python:3.12-slim /opt/pwd # your host's pwd
A glibc /bin/pwd from Ubuntu runs in python:3.12-slim, which also uses
glibc. The same file fails in alpine:3 with “the program does not exist”,
because Alpine uses musl and the loader the program names is not there.
Working folder, user and environment
| Flag | What it sets |
|---|---|
--workdir DIR | The working folder inside the sandbox. |
--user UID | The user id the program runs as inside the sandbox. The default is 1000. |
--env KEY=VALUE | One environment variable. Repeat it for more. |
zygo run --env GREETING=hi --workdir /tmp alpine:3 sh -c 'echo $GREETING from $(pwd)'
Do not pass secrets with --env. Environment variables are easy to leak, for
example into logs or child processes. Warm functions have a safer way,
described in chapter 14.
Limits on the command line
zygo run --mem 128M --pids 16 --timeout 10s alpine:3 /bin/sh
zygo run --cpu 0.5 --scratch 32M --nofile 256 python:3.12-slim python3 app.py
| Flag | Limits | Example |
|---|---|---|
--mem | memory | 512M |
--cpu | CPU time, in cores | 0.5 |
--pids | number of processes (stops a fork bomb: a program that copies itself without end) | 16 |
--timeout | wall-clock time | 10s |
--scratch | size of the writable /tmp | 32M |
--nofile | open files | 256 |
A limit cannot be switched off unless you also pass --allow-unlimited.
Chapter 14 explains each limit.
Isolation, seccomp and network flags
| Flag | What it chooses |
|---|---|
--isolation ns|gvisor|vm | The backend: the kind of wall around the program. ns is the default. See chapter 6 for how each works and chapter 16 for choosing. |
--seccomp default|strict|permissive | Which list of system calls is allowed. See chapter 24. |
--net none|egress|full|host | The network. none is the default. |
--allow HOST:PORT | With --net egress, one name and port the program may reach. Repeatable. |
--allow-host-net | Permits --net host, which removes the network wall. |
--allow-private-net | Permits --allow rules that point at private or link-local addresses. |
zygo run --net egress --allow api.example.com:443 python:3.12-slim python3 /src/call.py
Each flag that makes the sandbox weaker has a name that says so. You cannot remove a protection by accident.
Input, output and signals
Standard input, standard output, standard error and the exit code pass
straight through, so zygo run fits in a shell pipe. Zygo’s own messages,
such as pulling python:3.12-slim, go to standard error. -q or --quiet
hides them, which helps when a program or an AI model reads the output. It
never hides what the program itself writes, and errors are still reported.
Ctrl-C works in two steps. The first Ctrl-C (or SIGTERM, or SIGHUP)
is passed on to the program and every process it started, so a Python
program gets its KeyboardInterrupt. The second signal of any kind sends
SIGKILL to the sandbox’s first process, which ends the whole sandbox.
Ctrl-C #1 ──▶ SIGINT to the program and its process group ("please stop")
Ctrl-C #2 ──▶ SIGKILL to the sandbox's pid 1 ─▶ whole sandbox gone ("stop now")
A signal that the shell told zygo to ignore, for example under nohup, is
not passed on.
A terminal of its own: --tty
Zygo has no daemon, so by default the sandbox inherits your terminal. That
is why zygo run alpine:3 sh feels like a local command: colours, prompts and
job control just work. The cost is that code in the sandbox holds a writable
handle to your real terminal.
-t or --tty gives the sandbox a pseudo-terminal of its own instead: a
fake terminal that Zygo creates. Zygo keeps the other end and copies bytes
between it and your real terminal, so the sandbox never touches the real one.
default: your terminal ◀──────────────────────────▶ sandbox (holds your terminal)
--tty: your terminal ◀──▶ zygo ◀──▶ new pty ◀──▶ sandbox (never sees yours)
zygo run --tty alpine:3 /bin/sh
Why is --tty not the default? Because the default is already safe, and it
is what makes a sandbox feel like a local command. The dangerous use of that
handle, pushing keystrokes into your terminal with TIOCSTI, is blocked by
the seccomp filter either way. --tty goes one step further and removes the
handle completely. A run with --tty always stays in your shell and is never
handed to a supervisor.
Coming from Docker
The sentence is the same as docker run, the defaults are the opposite, and
no container object is left behind for you to remove.
Chapter 5 and chapter 10 compare the
two flag by flag.
One trap: in Zygo, -v means verbose, not volume. If you type
zygo run -v $PWD:/src image in the folder /app, Zygo sees that the
“image” looks like a mount and says so:
`/app:/src` is a mount, not an image
→ `-v` means verbose in Zygo, not volume; use: zygo run --mount /app:/src <image> …
Look before you run: --dry-run
zygo run --dry-run --json python:3.12-slim
--dry-run prints the plan and runs nothing. It shows:
- the resolved settings, after defaults,
sandbox.tomland flags are merged; - the mount plan: every path the sandbox will see, and where it comes from;
- the seccomp profile, where it came from (the flag, the spec or the default), and how many system calls it allows;
- whether Landlock applies on this host;
- the cgroup values that will be written, and how the deadline is enforced.
This is how you review a sandbox’s walls before you trust them. --json
makes the plan machine-readable, so two plans can be compared with diff.
Exit codes
The exit code is the program’s own, with a few exceptions that belong to Zygo.
| Status | Meaning |
|---|---|
| the program’s own | It ran, and this is what it returned. |
| 137 | Zygo or the kernel killed it: the deadline or the memory limit. --outcome says which. |
| 2 | The spec or the flags are wrong. |
| 125 | This host cannot run sandboxes, or the chosen backend is not available. zygo doctor says why. |
| 1 | Any other Zygo error, such as a missing image under --pull never. |
| 111 | macOS only: the Linux VM could not be reached. See chapter 22. |
| 75 | zygo exec only: the function is at its concurrency limit. Retry. |
| 4 | zygo exec only: no function has that name. |
A program can also return 1, 2 or 125 itself. When the difference matters,
use --outcome.
Knowing why a sandbox ended: --outcome
A deadline kill and an out-of-memory kill both exit 137. Both are a
SIGKILL, and the exit status carries nothing more. When the difference
matters, for example in an online judge or a CI step, ask for an outcome
file:
zygo run --outcome /tmp/why.json --mem 64M --timeout 5s python:3.12-slim python3 big.py
cat /tmp/why.json
{"exit_code":137,"timed_out":false,"oom_killed":true,"peak_rss_kb":65780,
"wall_ms":412.7,"plan_ms":3.1,"start_ms":9.8,"started":true,"phase":"run"}
The three times in this example are only an illustration; they depend on the host. The report goes to a file because standard output belongs to the program. Zygo writes the file whole and then renames it into place, so a reader never sees half a file.
The outcome fields
| Field | Meaning |
|---|---|
exit_code | The exit status, as above. |
timed_out | The deadline killed it. This comes from the launcher, which enforced the deadline. |
oom_killed | The kernel killed something for using too much memory. This comes from the kernel’s own counter in the sandbox’s cgroup. |
peak_rss_kb | The most memory it used at once, in KB. |
wall_ms | The program’s own time, from execve to exit, including tearing the sandbox down. |
plan_ms | Time before the sandbox: reading the spec, the image store, checking the host. |
start_ms | Time to build the sandbox, up to and including execve. |
started | Whether the program ran at all. |
phase | run if the program ran; otherwise the phase that failed, plan or start. |
Neither timed_out nor oom_killed is a guess. The three times tell you
which part was slow when one run takes much longer than usual.
When the sandbox never started
The outcome file is also written when the sandbox never started: the
image is not there, the host cannot run sandboxes, or a mount does not exist.
Then it says "started": false and names the phase that failed. A program
that ran has "started": true and "phase": "run".
"started": false, "phase": "plan" ─▶ not set up (missing image, bad spec) ─▶ unavailable
"started": false, "phase": "start" ─▶ the host could not build the sandbox ─▶ unavailable
"started": true, "phase": "run" ─▶ the code ran; exit_code is its own ─▶ its own result
This is the difference between the service is unavailable and the code failed. The exit status cannot carry it, and a caller should not have to read standard error to find it.
Pull policy
--pull says when zygo run downloads the image.
| Value | Behaviour |
|---|---|
missing (default) | Pull only when the image is not in the store, like docker run. |
never | Never pull. A missing image is refused before anything starts: exit 1, and the outcome file says "phase": "plan". |
always | Pull every time, to pick up a tag that has moved to a new image. |
never is for a caller that keeps its own clock, such as a judge with a time
limit. A pull takes minutes and a run takes seconds. Without never, a run
that quietly became a pull looks like a program that was too slow. With it,
the answer comes in a millisecond. Pull images ahead of time with
zygo pull IMAGE.
Python packages: --requirements
zygo run --requirements ./requirements.txt --mount ./src:/src:ro \
python:3.12-slim python3 /src/main.py
--requirements FILE installs the packages in a Python venv (a folder of
installed packages) and mounts it at /venv, with /venv/bin first on
PATH. The venv is built once and cached. The cache key is the image’s
digest plus the bytes of the file, so the next run with the same image and
the same file reuses it. It is the same cache zygo serve uses, so a one-shot
run and a warm function with the same requirements share one venv.
Chapter 15 explains the cache.
Installing packages into a mounted folder
Many products need an “install this customer’s packages” feature. The shape is always the same: one networked one-shot installs into a mount, and then any number of sandboxes without network import from it.
mkdir -p ./pkgs
zygo run --net full --mount ./pkgs:/pkgs:rw python:3.12-slim \
python3 -m pip install --target /pkgs python-dateutil
zygo run --mount ./pkgs:/pkgs:ro --env PYTHONPATH=/pkgs python:3.12-slim \
python3 -c 'import dateutil, six; print(dateutil.__version__)'
step 1 (once): network on ──▶ pip install ──▶ ./pkgs (mounted rw)
step 2 (often): network off ──▶ import from ──▶ ./pkgs (mounted ro)
What the package install costs
On the 2-core VM of an adoption test (a real multi-tenant product moved onto
Zygo), python-dateutil and six installed in 2.8 s through Zygo,
against 3.9 s through Docker.
This used to fail with [Errno 1] Operation not permitted on a RECORD
file. pip install --target copies each file with Python’s shutil.copy2,
which calls listxattr (a call that lists a file’s extended attributes), and
the seccomp profiles did not allow it. The workaround was to point TMPDIR
at the same mount. Every profile now allows the whole extended-attribute
family, so neither the error nor the workaround remains, and
make verify-seccomp-profiles-linux checks that it stays that way.
Using a sandbox.toml: [defaults] and -f
zygo run does not need a spec file; the flags alone are enough. But if a
sandbox.toml exists, zygo run uses it. It looks in the current folder and
then in each parent folder, or reads the file you name with -f.
built-in defaults ──▶ the spec's [defaults] ──▶ your flags (later wins)
([fn.*] tables are ignored by zygo run)
zygo run -f ./ci/sandbox.toml python:3.12-slim python3 -m pytest
So a team can write its limits once in [defaults], and every zygo run in
that project uses them. The [fn.*] tables describe warm functions and are
ignored here. Chapter 20 describes the file.
Handing the run to the supervisor
On Linux, when a supervisor is already running (for example after
zygo serve), zygo run gives the sandbox to it. The supervisor already
sits in a delegated cgroup, so the run skips the systemd scope described
below. Planning and pulling still happen in your process; the supervisor only
builds and runs the sandbox, and your process passes its streams and signals
and waits.
zygo run python:3.12-slim python3 -c pass, usually | |
|---|---|
| In its own systemd scope | 25.7 ms |
| Through a running supervisor | 13.5 ms |
| Measured on | Ubuntu 24.04 VM, kernel 6.8 |
The hand-off does not happen with --dry-run (nothing runs), with --tty
(the terminal must stay in your shell’s session), or with an isolation other
than ns. zygo run -v tells you which path was taken: its timing line ends
in (through the supervisor) when the supervisor ran it.
The systemd scope
On a systemd machine, a login shell lives in a cgroup that you are not
allowed to split up. A sandbox needs a cgroup of its own for its limits, and
Zygo refuses to run a sandbox with no limits. So zygo run, pull, serve,
up and bench first check where they stand. If they are not in a delegated
cgroup, they start again inside a temporary one:
$ zygo run ... (login shell cgroup: not delegated)
└─▶ systemd-run --user --scope -p Delegate=yes zygo run ...
└─▶ zygo run ... (own scope: delegated, can make cgroups)
└─▶ sandbox
This costs about 10 to 15 ms: the scope, a second process, and a cgroup tree built and removed. In a container, a systemd service, or a session that is already delegated, nothing is done. A guard stops Zygo from restarting itself forever: if the new scope is still not usable, you get Zygo’s own error.
Output for scripts
| Flag | What it does |
|---|---|
--json (global) | Machine-readable output, for example with --dry-run. |
-v (global, repeat for more) | More detail from Zygo, including the timing line. |
-q, --quiet | Hide Zygo’s own progress messages. |
--data-root DIR (global) | Keep the image store and state in DIR instead of the default under your home folder. |
--outcome FILE | Write why the sandbox ended, as JSON. |
Every zygo run flag in one table
| Flag | Section |
|---|---|
--mem --cpu --pids --timeout --scratch --nofile | Limits |
--isolation --seccomp --net --allow | Isolation, seccomp and network |
--allow-host-net --allow-private-net --allow-unlimited | Limits and network |
--mount HOST:GUEST[:ro|rw] | Mounts |
--env --user --workdir | Working folder, user and environment |
-t, --tty | A terminal of its own |
--requirements FILE | Python packages |
--pull missing|never|always | Pull policy |
--outcome FILE | Knowing why a sandbox ended |
--dry-run | Look before you run |
-q, --quiet | Output for scripts |
-f, --file | Using a sandbox.toml |
Chapter 19 lists every other command.
13. Warm functions
A warm function is a sandbox that Zygo builds once and keeps ready, so that
each request costs a fork() instead of a new sandbox. This chapter shows how
to serve one, call it, watch it and stop it, and what your code must look like
in Python, Node, TypeScript or any other language.
Chapter 6 explains the idea behind it;
this chapter is about using it.
Warm functions in one picture
A handler is the function you write: it gets one request and returns one
answer. zygo serve builds a sandbox, starts a small helper program inside it
called the agent, and the agent loads your handler. That loaded, waiting
process is the zygote. zygo exec sends a request; the zygote makes a copy
of itself with fork(), and the copy runs your handler once and exits.
zygo serve ./handler.py --name resize (once, about 150 ms)
│
▼
┌────────────────────── warm sandbox "resize" ───────────────────────┐
│ │
│ zygote: python started, imports done, handler loaded │
│ │ │
│ ├── fork ─▶ child: handler(event 1) ─▶ answer ─▶ exit │
│ ├── fork ─▶ child: handler(event 2) ─▶ answer ─▶ exit │
│ └── fork ─▶ child: handler(event 3) ─▶ answer ─▶ exit │
│ │
└────────────────────────────────────────────────────────────────────┘
▲
│
zygo exec resize '{"url": "…"}' (each time, about 1.4 ms)
Why this is the production shape
A one-shot sandbox (zygo run) costs about 12 ms with Python in it: about
3.6 ms is the sandbox itself, the rest is Python starting.
A warm function pays the setup once and then costs about 1.4 ms a
request: that is the median overhead measured on a 2-vCPU Lima VM on an Apple
M1 Max (chapter 25). The gap is easy to miss. zygo run
looks like the natural way to say “run this code once”, so a program that
uses Zygo that way gets one sandbox per event. On that VM, zygo bench cold
says 12.3 ms for that, while zygo bench warm says 1.44 ms and 1,108
requests a second — more than eight times faster, and the gap grows with
every module the handler imports.
A multi-tenant application that starts with run for exactly that reason
should expect to move to warm functions once it counts the events. The
multi-tenant example below shows
what that looks like.
one sandbox per event (zygo run) one warm function (zygo serve + exec)
──────────────────────────────── ──────────────────────────────────────
event ─▶ build sandbox ─▶ run ─▶ clean serve: build sandbox + load code, once
event ─▶ build sandbox ─▶ run ─▶ clean event ─▶ fork ─▶ run ─▶ exit
event ─▶ build sandbox ─▶ run ─▶ clean event ─▶ fork ─▶ run ─▶ exit
12.3 ms each (2-vCPU Lima VM) 1.44 ms each (same VM)
Serving a function
zygo serve takes a handler file and a name. It starts the supervisor (the
background process that owns every warm sandbox) if one is not running yet,
warms the sandbox, and returns. From then on the function is ready.
zygo serve ./handler.py --name resize
zygo serve ./handler.py --name resize --requirements requirements.txt --mem 512M
zygo serve ./report.py --name report --secret STRIPE_KEY --idle-timeout 5m
| Flag | What it does |
|---|---|
--name N | The name you call the function by. |
--image I | The image to warm from. The default comes from the runtime: python:3.12-slim or node:22-slim. |
--requirements FILE | A dependency file, installed once into a shared, cached /venv. |
--concurrency N | How many requests may run at the same time in one zygote. Default 4. |
--idle-timeout D | Pause the zygote after this long with no requests. Default 10m. |
--mode function|stdin | How the handler is called; see mode = "stdin". |
--secret NAME | Deliver the secret NAME, taken from this shell’s environment, as /run/secrets/NAME. Repeat for more. |
-f PATH | Read this sandbox.toml instead of searching upwards for one. |
Every limit and sandbox flag of zygo run works here too: --mem, --cpu,
--pids, --timeout, --net, --allow, --mount and the rest. They
are described in chapter 14. The same
settings can live in a [fn.<name>] table of sandbox.toml
(chapter 20).
Calling a function
zygo exec NAME EVENT sends one request. The event is any JSON value; if
you leave it out, zygo exec reads it from standard input. The handler’s
answer goes to standard output. Whatever the handler itself printed goes to
standard error, so you can pipe the answer into another program and still see
the logs.
zygo exec resize '{"url": "https://example.com/a.png"}'
echo '{"url": "https://example.com/b.png"}' | zygo exec resize
zygo exec resize --timeout 5s '{"url": "https://example.com/c.png"}' > out.json
--timeout gives up on this request after that long; without it, the
function’s own timeout applies. The exit code of zygo exec tells a script
what happened:
| Exit code | Meaning |
|---|---|
| the request’s own | The handler finished; 0 is success. |
137 | The request’s deadline killed it. |
75 | The function is busy: every slot and the whole queue are full. Try again later. |
4 | There is no function with that name. |
125 | There is no supervisor running. |
Many events at once: --batch
--batch reads NDJSON from standard input: one JSON event per line. It
sends them to the function in parallel and prints one JSON answer per line,
in the same order as the input. The exit code is 0 only if every request
succeeded.
zygo exec resize --batch < events.ndjson > answers.ndjson
events.ndjson the function (concurrency 4) answers.ndjson
───────────── ──────────────────────────── ──────────────
line 1 ──────────────▶ fork ─▶ answer 1 ─────────────────▶ line 1
line 2 ──────────────▶ fork ─▶ answer 2 ─────────────────▶ line 2
line 3 ──────────────▶ fork ─▶ answer 3 ─────────────────▶ line 3
... (they run side by side, (always in
and may finish in any order) input order)
When it is full: concurrency and the queue
Each zygote runs up to concurrency requests at once (default 4). More
requests wait in a queue that holds up to four times concurrency. When the
queue is full too, a new request is turned away at once as busy: HTTP 429
from the API, exit code 75 from zygo exec. Turning work away quickly is
better than letting every caller wait for a timeout.
concurrency = 4
new request ─▶ ┌───────────────────────────────┐
│ running: [1] [2] [3] [4] │ full? ─▶ wait in the queue
└───────────────────────────────┘
┌───────────────────────────────┐
│ queue: up to 16 (4 × 4) │ full? ─▶ busy: HTTP 429, exit 75
└───────────────────────────────┘
Looking after warm functions
These commands work on functions that are already served. Chapter 19 has every flag.
| Command | What it does |
|---|---|
zygo ps | Lists warm sandboxes: name, state (warm, paused, cold), memory, requests. |
zygo stop NAME | Stops one function. zygo stop --all stops every one. |
zygo logs NAME | Shows the last 50 log entries: the zygote’s own output and one line per request with its stdout and stderr. |
zygo logs NAME -f | Keeps printing new entries as they arrive. -n 200 starts with more. |
zygo logs NAME --failed | Only requests that failed: a non-zero exit or an error. |
zygo shell NAME | Opens a shell inside the function’s sandbox, for debugging. |
zygo shell NAME -- ls /app | Runs one command there instead of a shell. |
zygo top | A live table of every function’s resources, updated every 2 seconds. |
zygo stats [NAME] | A summary of the metrics, for all functions or one. |
zygo shell needs one warning. It starts a new process and enters the
sandbox’s namespaces (the kernel’s walls around files, processes, network
and host name), so it sees what the handler sees. It holds no capabilities.
But it is not under the seccomp filter, the Landlock rules or the
function’s cgroup, so that a debugging shell is not killed by the memory
limit. The warm zygote is not touched and keeps serving.
Warm, paused, cold
A warm function does not stay in memory forever. After idle_timeout
(default 10 minutes) with no requests, Zygo pauses it: the cgroup is frozen,
so its processes stop using the CPU but stay in memory. The next request wakes
it with one write, in far less time than a warm-up. After cold_after
(default 1 hour) the sandbox is dropped. The function still exists by name,
and the next request pays a full warm-up again: about 150 ms for a Python
handler with no imports, measured on a Raspberry Pi 5.
zygo serve no request for idle_timeout (10m)
(nothing) ─────────────▶ ┌────────┐ ──────────────────────────────▶ ┌──────────┐
│ warm │ │ paused │
│ │ ◀────────────────────────────── │ │
└────────┘ a request wakes it (1 write) └──────────┘
▲ │
│ a request warms it again │ no request for
│ (about 150 ms for Python) │ cold_after (1h)
┌───┴────┐ │
│ cold │ ◀────────────────────────────────────┘
└────────┘ sandbox dropped, name kept
| State | In memory? | Uses CPU? | Cost of the next request |
|---|---|---|---|
| warm | yes | only while serving | a fork, about 1.4 ms |
| paused | yes | no | one write to wake it, then a fork |
| cold | no | no | a full warm-up, then a fork |
Which shape to use
There are three ways to give Zygo code to keep warm. Pick by two questions: is the code the same on every request, and does its runtime start slowly?
is your code the same on every request?
│
yes ───────────┴──────────── no: each request brings a script
│ │
does its runtime start slowly? RUNTIME POOL
(Python, Node with many modules) [runtime.<name>], --runtime
│ one warm interpreter,
yes ────┴──── no (Go, Rust, C, sh) thousands of scripts
│ │
AGENT FUNCTION WARM-EXEC FUNCTION
entry = "h.py" cmd = ["/app/bin"]
fork per request new process per request
~1.4 ms ~1.4 ms + your program's start
A Python handler
Write a module with a function called handler at the top level. It gets the
event — whatever JSON the caller sent, already parsed — and returns something
that can be turned into JSON. It may be a normal function or an async one;
Zygo runs an awaitable to the end. There is no second “context” argument.
Everything at module level runs once, in the zygote, before any request:
put your imports, your model loading and your compiled regular expressions
there.
import json, re # runs once, when the zygote warms
PATTERN = re.compile(r"\d+") # also once
def handler(event): # runs in a fresh fork, per request
numbers = PATTERN.findall(event["text"])
return {"count": len(numbers)}
A larger one, which makes image thumbnails:
from PIL import Image # imported once, in the zygote
import io, base64
MAX = (800, 800) # module-level state is shared, copy-on-write
def handler(event: dict) -> dict:
"""Runs in a fresh fork per request. Writing to globals is safe, but the
next request will not see it."""
img = Image.open(io.BytesIO(base64.b64decode(event["image"])))
img.thumbnail(MAX)
out = io.BytesIO(); img.save(out, "WEBP")
return {"image": base64.b64encode(out.getvalue()).decode(), "size": img.size}
What “warm” means, exactly
The sandbox is built once, and the agent inside it imports your handler. Each
request is a fork() of that agent. A fork shares the parent’s memory
copy-on-write: the pages are shared until one side writes to one, and only
that page is copied. So nothing is copied up front and nothing is imported
again. And because the child is a separate process, nothing a request writes
is visible to the next one. You get the speed of a shared interpreter and
the isolation of a fresh one.
That is the trade Zygo exists to make. A long-running worker is fast but leaks state from one request to the next. A container per request is clean but costs hundreds of milliseconds. A fork is both fast and clean.
What mem bounds in a warm function
mem is one request’s limit. Each request runs in a cgroup of its own
with memory.max = mem, and the warm process (the agent, and for Node the
workers it keeps loaded) sits in a leaf of its own with the same limit
(chapter 3). A request that allocates
past mem is killed by the kernel, together with everything it started and
nothing else: the requests running beside it finish, and the zygote is still
warm for the next one. So a function with concurrency = 4 and
mem = "256M" may use up to five times mem at once, one share for the warm
process and one per request; the tenant’s budget above bounds the sum.
make verify-oom-linux runs three sleeping requests beside one that
allocates 2 GB, for both agents, and checks that only the one dies.
long-running worker container per request fork per request (Zygo)
─────────────────── ───────────────────── ───────────────────────
fast clean fast AND clean
request 2 sees what hundreds of ms each each child starts as a copy
request 1 left behind of the zygote, then is gone
A fork carries three things that a fresh process would not. make fork-sweep-linux measured them across 44 popular PyPI packages. The next
three sections say what Zygo does about each.
When a handler is not safe to fork
A fork copies only the thread that called it. If another thread held a lock at
that moment, the lock stays locked in every child, and the child can hang. So
a process that started threads cannot be forked safely. At warm-up the Python
agent checks for threads that would survive a fork — Python threads and
native ones, such as the four that import duckdb starts. If it finds any, it
falls back to spawning a fresh interpreter per request instead of forking.
That is correct, but much slower, and zygo logs says when it happened.
Thread pools that stop themselves before a fork, as OpenBLAS’s does, do not
trigger it. The fix is to start threads inside the handler, or lazily on first
use.
Random numbers in a fork
A child starts with a copy of its parent’s random-number state, so without
care every request would draw the same “random” numbers. The agent reseeds
Python’s random, numpy’s global generator and torch’s generator in every
child. It cannot reseed a generator that you created at import time, such as
RNG = random.Random() or np.random.default_rng(): every request draws the
same numbers from it. The agent names such a generator in zygo logs at
warm-up. Create it inside the handler instead.
Work done lazily on first use
Every request is the first call in its own process. So a library that sets
itself up on first use pays that cost on every request, and none of it is
kept. Do that work at import time instead, where the zygote pays it once:
build the boto3 client, compile the template, and create the pydantic model
at module level.
A Node handler
Export a function: module.exports = function handler(event) {…} or
module.exports.handler = …. It may return a value or a promise; undefined
becomes null. One difference to know: Node is not safe to fork, so the Node
agent keeps two pre-loaded worker processes ready. Each worker runs one
request and exits, and a new one is started off the request path. The effect
— a clean process per request — is the same, but the cost per request is a
little higher than Python’s fork.
const crypto = require("crypto"); // loaded once, in each pre-loaded worker
module.exports = async function handler(event) {
return { sha256: crypto.createHash("sha256").update(event.text).digest("hex") };
};
Node agent (no fork)
┌────────────────────────────────────────────────────────────────┐
│ agent ── keeps 2 workers loaded and parked │
│ worker A: handler loaded ─▶ request 1 ─▶ exit │
│ worker B: handler loaded ─▶ request 2 ─▶ exit │
│ worker C: started in the background, ready for request 3 │
└────────────────────────────────────────────────────────────────┘
TypeScript handlers
A .ts file is loaded as TypeScript with its types removed, with no build
step and no bundler. The types are stripped as the module loads, in the
worker, which is the same place a .js file is compiled. enum, namespaces
and parameter properties work too. This needs Node 22.13 or later, or the
amaro package in the function’s dependencies.
Chapter 18 has the
details.
[fn.resize]
image = "node:22-slim"
entry = "./resize.ts" # runtime = "node", inferred from the extension
What a request sees
| The event | The JSON the caller sent (null for an empty body). In Python a JSON object arrives as a dict with one extra method, event.progress(msg). |
| Environment | ZYGO_REQUEST_ID; ZYGO_DEADLINE_MS, the request’s time budget in milliseconds; ZYGO_WORKSPACE if a workspace was sent; ZYGO_FUNCTION, the function’s name; plus the function’s env. ZYGO_TENANT holds the same name as ZYGO_FUNCTION; it is an older, misleading name kept so that old handlers do not break. |
| Secrets | Files at /run/secrets/<NAME>, mode 0400, readable only inside this function’s sandbox, and gone once no request of it is running. Never in the environment. In a runtime pool: the calling tenant’s values, and the request has its zygote to itself while they exist. |
| Files | The image, read-only; your mounts; a temporary folder of its own, which TMPDIR names and which is removed afterwards; /venv if there are requirements. /tmp itself is shared by every request in the sandbox: write through tempfile, os.tmpdir() or $TMPDIR, not to a literal /tmp/... path. |
| Working folder | workdir (/app), or the workspace if one was sent: the agent changes into it before your code runs. |
| Memory | A copy of the zygote’s. What you change is yours alone and gone at the end. |
What a request returns
A return value must turn into JSON; in Python, bytes become base64. Output
printed to stdout and stderr is not the result. Each is collected
separately (the last 256 KiB of each) and returned beside it, and sent live,
in pieces, when the caller asks for a stream. If the handler raises an
exception, the request fails with exit code 1, and its error is the traceback
without the agent’s own lines. event.progress("step 2 of 5") sends a
progress line to a caller that is streaming, and does nothing otherwise.
┌──────────── what comes back ────────────┐
handler(event) ─▶│ result the return value, as JSON │
│ stdout what it printed (≤ 256 KiB) │
print(...) ──▶│ stderr what it logged (≤ 256 KiB) │
│ exit_code 0, or 1 if it raised │
│ error the traceback, if it raised │
└─────────────────────────────────────────┘
mode = "stdin"
This is for a Python handler written as a script rather than as a function.
The file is run once per request with the event as JSON on standard input, and
whatever it prints to standard output is parsed as the result. A non-zero exit
is an error. Use it for existing scripts you do not want to change. It is
slower than a handler: each request starts a new Python process for the
script, inside the warm sandbox, so the script’s imports are paid every time.
Because it starts a program, it cannot run under seccomp = "strict", which
forbids that.
[fn.legacy]
entry = "./old_script.py"
mode = "stdin"
Warm-exec functions
For a program that starts fast, set cmd and no entry. The sandbox is kept
warm, and each request starts cmd as a new process inside it. The contract
is the simplest one possible: read one JSON event from stdin, write one
JSON result to stdout, exit 0. Stderr is kept as the log, and a non-zero
exit is a failure. sh -c cat is the smallest program that obeys it. It works
with any language and any image, and needs no agent. It costs a median of
1.4 ms per request, measured on a Lima VM, plus your program’s own
start.
[fn.parse]
image = "alpine:3"
mounts = ["./bin/parse:/app/parse:ro"] # a static binary you built
cmd = ["/app/parse"]
Runtime pools
A pool is a warm interpreter with no code of anyone’s in it. Each request
carries a script — as source, or as the sha256: digest (a fingerprint of
the file’s bytes) of a script stored earlier with PUT /scripts. The forked
child loads it, runs its handler (or the function named by
entry_point), and exits. The script is loaded after the child’s seccomp
filter is on, so even its import-time code is filtered, and a pool’s seccomp
profile defaults to strict. strict has no socket, so a pool whose
scripts call out names seccomp = "default" beside its network; asking
for a network under strict is refused rather than left silently unusable.
This is how a platform with ten thousand user
scripts keeps a handful of zygotes warm instead of ten thousand.
zygo serve --runtime py312 --image python:3.12-slim --agent python
zygo serve --runtime node22 --image node:22-slim --agent node --min-warm 2 --max-warm 8
zygo exec --runtime py312 --script report.py '{"month": "2026-09"}'
zygo exec --runtime py312 --script sha256:9f2c… --entry-point monthly '{}'
one pool, many tenants' scripts
┌────────── pool py312 (min_warm 2, max_warm 8) ──────────────┐
│ zygote python + requirements, NO user code │
│ ├── fork ─▶ child loads script A (tenant acme) ─▶ exit │
│ ├── fork ─▶ child loads script B (tenant beta) ─▶ exit │
│ └── fork ─▶ child loads script A again ─▶ exit │
└─────────────────────────────────────────────────────────────┘
+0.5 ms per request, against a function with its code warmed in
A pool keeps min_warm zygotes ready whatever the load (default 1; 0 counts
as 1) and may grow to max_warm under load (default the larger of min_warm
and 4). --agent is python, node, or the path of your own agent inside
the sandbox. The +0.5 ms was measured with a different script on every
request, a thousand of them: +0.47 ms on the Lima VM and +0.46 ms on Docker
Desktop’s (chapter 25). A pool
with a cmd instead of an agent is a warm-exec pool: the script’s path
is passed as the last argument of cmd on each request.
No listener in a pool. A pool’s requests belong to different tenants and
share one network namespace, so a script that opened a TCP port on loopback
would be offering a channel to every other request in the pool. Two layers
refuse it: strict, the pool default, removes the socket calls, and under it
Landlock refuses bind in a shared namespace (kernel 6.7+). A function has
no such rule: its namespace is one tenant’s, and it may listen on its own
loopback (chapter 14).
Secrets in a pool. A pool can name secrets (secrets = ["STRIPE_KEY"]
in [runtime.<name>], or serve_runtime(..., secrets=[...])), but it holds
no values: the zygotes are shared. On each request the calling tenant’s
values are read from the tenant secret store and written as
/run/secrets/<NAME> for that one request, exactly as for a function — and
while they exist, the request has its zygote to itself, so no other
tenant’s child is forked beside the files. A tenant that lacks one of the
names is refused before anything runs. Chapter
14 has the rules;
zygo stop <name> stops a pool as it does a function.
A multi-tenant consumer on the warm path
Think of a low-code platform, a workflow engine or a plugin host. It has hundreds of scripts written by its users, the tenants, and a script changes whenever somebody presses Save. Each project has its own mounts and its own egress allowlist (the list of hosts it may reach), and each run has its own secrets. The one-shot mapping is one sandbox per event. The warm mapping is one warm zygote per script version, forked per run, and it looks like this:
import hashlib
import zygo_sdk as zygo
client = zygo.connect() # zygo api --allow-deploy, in the VM on a Mac
def run(project, script_source, event, secrets):
# A version is a function. The name carries the digest, so an edit is a
# new function and the old one is reaped by --idle-timeout, not by you.
digest = hashlib.sha256(script_source.encode()).hexdigest()[:16]
name = f"{project.id}-{digest}"
client.serve(
name,
{
"entry": project.script_path(digest), # the version, on disk
"mounts": [f"{project.data_dir}:/data:rw"], # per project
"network": "egress",
"allow": project.allowlist, # per project
"secrets": list(secrets), # names; values per run
"idle_timeout": "10m", # reaped when idle
"cold_after": "1h",
"mem": "256M", "timeout": "30s",
},
if_changed=True, # a no-op when it is warm
)
return client.fn(name)(event) # one fork
project acme, script v1 ─▶ function "acme-3f9a…" ─▶ warm zygote ─▶ fork per run
project acme, script v2 ─▶ function "acme-c21e…" ─▶ warm zygote ─▶ fork per run
(v1 is no longer called: paused after 10m, dropped after 1h)
project beta, script v1 ─▶ function "beta-77d0…" ─▶ warm zygote ─▶ fork per run
What each line buys
entryand the mounts are paths on the host Zygo runs on. A function’s code is named by path, never sent, so this shape is for a worker that shares a disk with Zygo. A caller on another machine sends each script with its request instead, to a runtime pool (chapter 17).if_changed=Truemakes theservefree when the version is already warm. The caller does not have to track state: it always asks, and the supervisor does nothing when nothing changed.- Mounts and the allowlist are per function, so two projects’ versions are two zygotes that share an interpreter’s memory pages and nothing else.
- Secrets are per run. The names are declared once; the values arrive with the request and exist as files only while the request runs.
idle_timeoutandcold_afterare the eviction policy. A version nobody has called for ten minutes is paused (still in memory, one write to wake). After an hour it is dropped, and the next call pays a warm-up — about 150 ms for a Python handler, plus its imports.
Four hundred projects do not mean four hundred warm zygotes. They mean as many
as were called in the last ten minutes, which is the number that matters, and
zygo ps shows it. ADR 0005
has the memory per warm script, measured on a 4 GB VM, and the eviction policy
written down. examples/workflow-engine/ is
this shape end to end — a worker draining a job queue, one warm function per
script version, and an LRU (least recently used) list over warm scripts — in
Python and Node.
Other languages
There are two ways, and you pick by whether starting your runtime is slow enough to be worth doing only once.
Warm-exec is the simple one: give a function a cmd and no runtime. Each
request is a fresh process in the held sandbox, with the event on standard
input and JSON expected on standard output. That costs about 1.4 ms and needs
no code from you beyond the program. Go, Rust, C and bash all belong here;
examples/warm-exec/ has Go and shell examples.
An agent is for a runtime that is slow to start. It warms once and gives
each request a process of its own. Two agents ship with Zygo, and the
runtime is chosen from the entry file’s extension:
entry ends in | runtime | How each request runs |
|---|---|---|
.py | python | a fork of the warmed interpreter |
.js, .mjs, .cjs, .ts | node | one of the pre-loaded, parked workers |
.go | go | not built yet: the name is accepted, but no Go agent ships, so serve fails with “no warm agent for go”. Use warm-exec for Go. |
| anything | { agent = "/path" } | your own agent, at that path in the sandbox |
[fn.resize]
entry = "./resize.py" # runtime = "python": a fork of the warmed interpreter
[fn.summarise]
entry = "./summarise.js" # runtime = "node": a pool of pre-loaded workers
[fn.custom]
entry = "./handler.rb"
runtime = { agent = "/app/zygo-agent" }
Node has no fork() in the Unix sense, so its agent keeps workers loaded and
parked instead — one request each, replaced off the request path. Everything
above that is the same: the same wire protocol, the same per-request cgroup,
the same deadline, the same secrets. Deno and Bun have no agent and are not
waiting for one (ADR 0003); they start in
a few milliseconds, so a warm-exec pool serves them.
Your own agent
Any language can have a real warm path by shipping an agent: a program that
talks to the supervisor over a socket, using a small documented protocol
(spec/protocol.md). Messages are length-prefixed
JSON: READY when warm, EXEC per request, FORKED and GO around the
fork, CHUNK for streamed output, RESULT, DONE, CANCEL, PING/PONG
and SHUTDOWN. Name it with runtime = { agent = "/path/in/sandbox" }.
zygo agent test runs the conformance suite against it before you trust it:
zygo agent test /bin/sh -- examples/agents/sh/agent.sh examples/agents/sh/handler.sh
examples/agents/ has a complete agent in POSIX sh
of about 130 lines that passes the same checks the shipped ones do.
Chapter 18 is the full guide.
Handler rules, in short
- Do slow things at module level; they run once.
- Do not start threads at import.
- Create random generators inside the handler, not at import.
- Return JSON; print only for logs.
- Read secrets from
/run/secrets/, never from the environment. - Write only to
/tmp, the workspace or a:rwmount; the rest is read-only. - Assume nothing survives the request — because nothing does.
14. Limits, networking and secrets
A sandbox is only as good as the walls you give it. This chapter shows how
you set those walls in sandbox.toml: how much a function may use, what it
may reach on the network, and how a password or API key gets to it without
leaking. Every wall has a safe value even if you set nothing.
The spec file in one minute
A spec file is sandbox.toml: one file that describes every function in a
project. [defaults] holds values for all functions, and each [fn.<name>]
section describes one function and may change any of them.
Chapter 20 lists every field; this chapter explains the
ones that set limits, the network and secrets.
[defaults]
image = "python:3.12-slim"
isolation = "ns" # ns | gvisor | vm
mem = "256M"
cpu = 1.0
pids = 64
timeout = "30s"
network = "none"
[fn.resize]
entry = "./resize.py" # defines handler(event)
requirements = "./requirements.txt"
system = ["libwebp7"] # apt packages, installed once as a layer
mem = "512M"
mounts = ["./cache:/cache:rw"]
[fn.parse]
image = "alpine:3" # no runtime → warm-exec
mounts = ["./bin/parse:/app/parse:ro"]
cmd = ["/app/parse"]
[fn.fetch]
entry = "./fetch.py"
network = "egress" # nothing else is reachable
allow = ["api.stripe.com:443", "*.example.com:443", "203.0.113.0/24:5432"]
connections = 32
bandwidth = "2M"
secrets = ["STRIPE_KEY"]
Which value wins
A value can come from four places. The highest one that sets a field wins.
highest ┌───────────────────────────────┐
│ a CLI flag (or an API call) │ --mem 512M
├───────────────────────────────┤
│ [fn.<name>] │ mem = "512M"
├───────────────────────────────┤
│ [defaults] │ mem = "256M"
├───────────────────────────────┤
lowest │ Zygo's built-in default │ 256M
└───────────────────────────────┘
Lists and tables — allow, mounts, env, secrets, system — replace
the one below them. They are never added together. So an allow list in
[fn.fetch] is the whole list for that function, and the one in [defaults]
no longer counts. Two commands show you the result:
zygo spec explain resize # every field of `resize`, after the merge
zygo spec validate # check the whole file, run nothing
Every limit has a value
A limit is a ceiling the kernel enforces, most of them through the
function’s cgroup (chapter 3). Every limit has a default,
and you cannot turn one off by leaving it out. The only way to remove one is
a flag named --allow-unlimited. This is on purpose: a sandbox with no memory
limit is not a sandbox, and people usually end up with one by forgetting, not
by deciding.
| Field | What it bounds | Default | What happens when it is hit |
|---|---|---|---|
mem | memory | 256M | the kernel kills the request; exit 137, oom_killed |
cpu | CPU time, in cores | 1.0 | the request is slowed down, not killed |
pids | processes and threads | 64 | the next fork fails |
timeout | wall-clock time | 30s | the whole request is killed; exit 137, timed_out |
scratch | the writable /tmp | the smaller of 64M and half of mem | writes fail: “No space left on device” |
nofile | open files | 1024 | the next open fails: “Too many open files” |
connections | TCP connections at once | 256 | the next connection is refused |
bandwidth | bytes a second sent | unlimited, with a warning | traffic is slowed down |
io_read, io_write | disk bytes a second | unlimited, with a warning | disk reads or writes are slowed down |
The sections below take them one at a time.
Memory: mem
mem is the most memory the request may use, counted by the kernel for every
process it starts. At 90% of it the kernel starts to push back and reclaim
memory; at 100% it kills the request. There is no swap, so memory cannot
quietly spill to disk and slow the host down. The smallest allowed value is
8M. The whole process tree dies together, and the warm zygote and the
other requests keep running.
0 ─────────────────────────────── 90% ───────────── 100% (mem)
normal use memory.high: memory.max:
kernel reclaims, request killed,
request slows exit 137, oom_killed
CPU: cpu
cpu is how many CPU cores the request may use, as a number: 0.5 is half a
core, 2 is two. The kernel checks it every 100 ms. A request that tries to
use more is simply made to wait for the rest of that period. So a busy loop
slows only itself, never the host, and it is not killed for it. This is also
why a function driven past its quota shows a slow tail of tens of
milliseconds; chapter 25 explains that number.
Processes: pids
pids is how many processes and threads the request may have at once. It is
the defence against a fork bomb: a program that copies itself again and
again until the machine can do nothing else. When the limit is reached, the
next fork or new thread simply fails, and the program gets an error. 0 is
not allowed.
Time: timeout
timeout is the wall-clock time a request may run, from start to end. When
it runs out, Zygo kills the request through its cgroup, so every process the
handler started dies with it, not just the one Zygo can see. The request
exits with code 137, and the outcome says timed_out. timeout = 0 means
“no limit” and needs --allow-unlimited; zygo up never accepts it.
Scratch space: scratch
A sandbox’s root file system is read-only. The one place it may write is
/tmp, which lives in memory, and scratch is its size. Because it is
memory, it counts against mem, so scratch must be smaller than mem, and
Zygo warns you when it is more than half. The default is the smaller of
64M and half of mem. scratch is also the biggest single file a process
may write, and /tmp holds 10 000 files at most. When it is full, writes
fail with “No space left on device”.
Open files, connections, bandwidth and disk
nofile (default 1024) is how many files, sockets and pipes one process may
have open; past it, open fails with “Too many open files”. connections
(default 256) is how many TCP connections a networked function may hold at
once; the firewall inside the sandbox answers the next one with a reset. It
cannot be 0. bandwidth is how many bytes a second the function may send,
and on hosts with an ifb device also receive. io_read and io_write limit
disk reads and writes a second. These three have no default, so Zygo warns
you when a served function leaves them unset.
Thread pools follow cpu
Many number-crunching libraries — OpenBLAS, OpenMP, MKL, numexpr, polars,
Rayon — start one thread per CPU on the machine. In a sandbox with
cpu = 2 on a big host, those threads then fight over two cores. On a 5-CPU
host, eight numpy requests at once under cpu = 2 ran 24–38 a second with
the host’s count and 155–173 a second with one thread each. So Zygo sets
these variables to cpu, rounded up:
OMP_NUM_THREADS OPENBLAS_NUM_THREADS MKL_NUM_THREADS NUMEXPR_NUM_THREADS
VECLIB_MAXIMUM_THREADS RAYON_NUM_THREADS POLARS_MAX_THREADS
PYTHON_CPU_COUNT GOMAXPROCS
If the image or the spec sets any of them, that value wins.
Knowing which limit ended a request
A request that Zygo or the kernel stopped exits with 137, both for time and
for memory, because both are a SIGKILL. When you need to know which, Zygo
tells you: zygo run --outcome FILE writes timed_out, oom_killed, peak
memory and wall time to a file, and a warm request reports the same fields.
Chapter 12 shows the one-shot case in full.
Networking: four modes
A sandbox starts with no network at all. network opens it, one step at a
time:
network = "none" # the default
| Mode | What the sandbox can reach |
|---|---|
none | nothing: an empty network namespace with only loopback. A function may talk to itself over TCP there |
egress | exactly what allow names, plus DNS for those names |
full | the public internet (bridge, Docker’s word, is accepted and printed back as full) |
host | everything the host can reach; no network namespace. Needs --allow-host-net |
Egress means traffic going out. No mode lets anything connect in to a sandbox: Zygo never accepts connections for your code.
How egress works
egress and full give the sandbox’s network namespace to
pasta. pasta is a small program that moves the
sandbox’s packets out through the host’s normal sockets, running as your own
user. Inside the namespace, Zygo installs an nftables firewall — the
kernel’s packet filter — that lets only allowed traffic out. No root is
needed anywhere. Under egress, Zygo also runs its own tiny DNS resolver
inside the sandbox, and that resolver is what fills the firewall.
┌─ sandbox network namespace ─────────────────────────────────────────┐
│ │
│ handler │
│ │ 1. "where is api.stripe.com?" │
│ ▼ │
│ ┌──────────────────────┐ 2. on the allow list? │
│ │ Zygo's DNS resolver │ no → "no such name" (NXDOMAIN) │
│ │ (on loopback) │ yes → look it up, then … │
│ └──────────┬───────────┘ │
│ │ 3. … add its addresses to the firewall FIRST, │
│ ▼ then answer the handler │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ nftables firewall │ │
│ │ loopback, replies to allowed connections → pass │ │
│ │ more than `connections` open → reset │ │
│ │ 10.x, 192.168.x, 169.254.169.254 … → reject │ │
│ │ addresses the resolver added, on that port → pass │ │
│ │ anything else → reject │ │
│ └──────────┬───────────────────────────────────────────┘ │
│ │ 4. connect to api.stripe.com:443 │
│ │ 5. out through tap0, the sandbox's network card │
└─────────────┼───────────────────────────────────────────────────────┘
▼
┌──────────────┐
│ pasta │──▶ normal sockets on the host ──▶ the internet
│ (your user) │
└──────────────┘
Because the answer only comes back after the firewall is updated, a wildcard
is checked against the name the handler really asked for. A service that
changes its address keeps working, because each lookup adds the new one. The
handler cannot use a resolver of its own to get around the list: under
egress the only resolver it can reach is Zygo’s. The host’s search domains
never enter the sandbox. A blocked connection is rejected at once, not
silently dropped, so a program fails fast instead of waiting for a timeout.
A network needs a profile that can open a socket
seccomp = "strict" removes socket and connect
(chapter 24). Under it an egress or full
sandbox has a namespace, a firewall and an allowlist, and every connection
still fails with EPERM before any of them is asked. So the combination is
refused when the sandbox is declared, with the way out in the message. A
runtime pool is strict unless it says otherwise, so a pool that calls out
names seccomp = "default" next to its network.
The allowlist
allow is the list of places an egress function may reach. It is only
valid with network = "egress". An egress function with an empty list can
reach nothing, and Zygo warns you.
allow = ["api.stripe.com:443", "*.example.com:443", "203.0.113.0/24:5432"]
| Form | Example | Matches |
|---|---|---|
host:port | api.stripe.com:443 | that name, that port |
host | api.stripe.com | that name, every port |
*.domain:port | *.example.com:443 | every name under example.com, but not example.com itself |
CIDR:port | 203.0.113.0/24:5432 | that range of addresses, that port |
| IPv6 | [2001:db8::1]:443, 2001:db8::/32 | use brackets when a port follows |
A CIDR is a way to write a range of addresses: 203.0.113.0/24 means every
address that starts with 203.0.113..
Private ranges and the cloud metadata address
Private addresses are the ones used inside a home or company network, such
as 10.x.x.x or 192.168.x.x. Link-local ones include 169.254.169.254,
where cloud servers answer questions like “what are my credentials?”. That
address is the first thing a compromised handler tries. So in every mode
with a namespace — none, egress and full — these ranges stay closed:
10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 127.0.0.0/8 169.254.0.0/16
100.64.0.0/10 0.0.0.0/8 224.0.0.0/4 (multicast) 240.0.0.0/4 (reserved)
::1 :: fe80::/10 fc00::/7 ff00::/8 (multicast)
Multicast is on the list because a multicast group is a way to talk to the neighbours without naming any of them.
Only --allow-private-net opens them. An allow rule inside one of these
ranges — a CIDR, or a single address such as 192.168.1.70:8765 — is refused
unless you pass that flag. Before 0.1.4 a single address was read as a host
name: serve accepted it, and the firewall then refused every connection to
it as the private address it is, with nothing to say why.
Over the HTTP API a request body can never set it. Whoever starts the API
can: zygo api --allow-deploy --allow-private-net lets what deploy callers
serve name private addresses in allow
(chapter 17).
The host’s own loopback
127.0.0.0/8 is on that list, but the sandbox has a loopback of its own, and
the firewall lets it through: Zygo’s resolver lives there. So the question
is whether the sandbox’s 127.0.0.1 can lead to the host’s. pasta can do
exactly that, and does by default: it forwards a connection to the
sandbox’s loopback on to the same port on the host’s loopback, and it answers
for the gateway address itself and hands those connections to the host too.
Zygo turns both off (--tcp-ns none --udp-ns none --no-map-gw). A Postgres,
a Redis or zygo api bound to 127.0.0.1 on the host is out of reach from a
sandbox, whatever the allowlist says and whatever the kernel’s Landlock can
or cannot do.
sandbox host
┌────────────────────────┐ ┌──────────────────────────────┐
│ connect 127.0.0.1:5432 │──▶ own │ 127.0.0.1:5432 postgres │ ◀── not reachable
│ │ loopback │ 127.0.0.1:7700 zygo api │ ◀── not reachable
│ connect <gateway>:5432 │──▶ the │ │
│ │ router │ 10.0.0.5:5432 postgres │ ◀── allow 10.0.0.5:5432,
└────────────────────────┘ └──────────────────────────────┘ --allow-private-net
A function that must reach a service on its own host is given that service
on a second address the host has — a Docker bridge’s 172.17.0.1, or one on
a dummy interface — with an allow rule naming it and --allow-private-net
typed by a person. Not the host’s main address: pasta gives that same
address to the sandbox’s own network card, so a sandbox dialling it reaches
itself and is refused. A service on loopback only stays loopback only. Before 0.1.4
this was not so: an allow rule for any name on port 5432 also opened the
host’s own 127.0.0.1:5432, and on a kernel without Landlock’s network
rules (below 6.7) every port the host had bound on loopback was open.
Listening inside a sandbox
A function, and a zygo run sandbox, may open a TCP listener on its own
loopback. Nothing from outside reaches it: pasta forwards no port in, and
under none there is no interface but loopback. So a program that talks to
itself over 127.0.0.1 — a Jupyter kernel, a Ray or Dask worker, a test
suite with a local server, n8n’s runner with its health-check port — works in
a sandbox the way it works on a laptop.
A runtime pool may not. Its requests belong to different tenants and share
one network namespace, so a listener on loopback there would be a channel from
one tenant’s request to another’s. strict, the pool default, removes the
socket calls; under it, Landlock refuses bind in a shared namespace on
kernels from 6.7 (chapter 13).
function (one tenant's namespace) runtime pool (shared between tenants)
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ listen 127.0.0.1:8888 ✓ │ │ listen 127.0.0.1:8888 ✗ │
│ connect to it ✓ │ │ strict: no socket at all │
│ from outside: nothing gets in│ │ Landlock: bind refused │
└──────────────────────────────┘ └──────────────────────────────┘
One corner under egress: the connect half of talking to yourself is
subject to the allowlist’s port rule, which applies to loopback too. A
function that listens on 5681 and connects to it names the port —
allow = ["127.0.0.1:5681"], which needs --allow-private-net — or runs
under none or full, where no port rule stands in the way. Until 0.1.3 no
sandbox could listen on TCP at all on a kernel from 6.7, and a sealed one
could not connect to itself either; ADR 0008
records why that changed.
If pasta or nft is missing
A networked sandbox needs both pasta and nft on the host. If either is
missing, the sandbox does not start. Zygo never falls back to starting it
with an open network. zygo doctor tells you what is missing, and
chapter 22 covers the common problems.
Secrets: why not environment variables
A secret is a value that must not leak: an API key, a database password, a token. The usual way to pass one is an environment variable, but that is a poor fit for a sandbox. The zygote would hold it for its whole life, every forked request would inherit it, and a handler that prints its environment would print it. Zygo takes a different path: a secret is a file, and it exists only while one request runs.
Declaring a secret
You name the secret in the spec, and give its value in the shell that runs
zygo serve or zygo up:
[fn.fetch]
secrets = ["STRIPE_KEY"]
export STRIPE_KEY=sk_live_...
zygo up
The value is read from your shell, not from the supervisor’s environment. If a name has no value, the command refuses and says which name is missing. The handler reads the secret as a file:
def handler(event):
key = open("/run/secrets/STRIPE_KEY").read().strip()
A secret lives for one request
For each request, the supervisor writes the file /run/secrets/<NAME> from
outside the sandbox. It is created with mode 0400 — readable only by its
owner — from the first moment, not changed to that mode afterwards, so there
is no instant when anyone else could read it. Inside the sandbox, only this
function’s requests can read it — this one, and any other request of the same
function running at the same moment, which has the same value anyway. The
file is removed when the last of them ends.
supervisor (holds the value)
│
│ request 7 arrives
├──▶ write /run/secrets/STRIPE_KEY (0400, this function's requests only)
│ │
│ ▼
│ ┌─────────────────────────────┐
│ │ request 7: handler(event) │ reads the file, uses the key
│ └─────────────────────────────┘
│ │ request 7 ends
├──▶ file removed
│
│ request 8 arrives ──▶ written again, removed again
never in: the environment · the zygote's memory · the control socket
A request that is taken over can read its own function’s secret while it runs, and never a secret that another function uses. This matters most for AI agent tools: a model that writes the code cannot print a secret it never had in its environment.
env or secrets?
env | secrets | |
|---|---|---|
| Where it appears | environment variables | the file /run/secrets/<NAME> |
| Who can see it | the zygote and every request | one request, while it runs |
| Where the value comes from | the spec file | your shell, or the tenant store |
| Use it for | settings: LOG_LEVEL, a region | keys, tokens, passwords |
A name cannot be in both lists. Never put a secret in env.
The tenant secret store
Reading secrets from a shell works for one person at a terminal. It does not work for a platform whose customers each bring their own keys. A tenant is one such customer, with its own functions and tokens (chapter 17). For them Zygo keeps an encrypted secret store, one set of secrets per tenant, saved on disk under Zygo’s data folder.
zygo secrets keygen # print a new key (32 bytes, hex)
export ZYGO_SECRETS_KEY=... # before the supervisor starts
zygo secrets set acme STRIPE_KEY # prompts, echo off
echo "$KEY" | zygo secrets set acme STRIPE_KEY --stdin
zygo secrets ls acme # names only, never values
zygo secrets rm acme STRIPE_KEY
Over the HTTP API the same is PUT /tenants/<id>/secrets/<name>. Functions
you serve from the CLI belong to the tenant named default.
How the store fills in values
When a function is served, Zygo first takes the values the client sent — for the CLI, from your shell. Then it fills in, from the tenant’s store, any name the client did not send. The client wins where both have a value. Only the names the spec lists are delivered; anything extra is dropped.
spec: secrets = ["STRIPE_KEY", "DB_PASSWORD"]
sent by the client: STRIPE_KEY=sk_test_… ─┐
├─▶ STRIPE_KEY = sk_test_… (client wins)
tenant store (acme): STRIPE_KEY=sk_live_… ─┤ DB_PASSWORD = … (from store)
DB_PASSWORD=… ─┘
a name with no value anywhere → the serve is refused, naming it
Secrets in a runtime pool
A runtime pool (chapter 13) is shared by every tenant, so it holds no secret values. It names them instead:
[runtime.py312]
image = "python:3.12-slim"
agent = "python"
secrets = ["STRIPE_KEY"] # names a request may receive; no values here
Each request is given the calling tenant’s values — the tenant in the
token or the X-Zygo-Tenant header; the default tenant for zygo exec —
read from the store at that moment. Only the store: a pool never reads your
shell, and a pool that names secrets on a host with no store key is refused
when it is served, not on its first request. The files are written from
outside, exist for that one request, and are removed when it ends.
tenant acme calls py312 tenant beta calls py312
│ │
├──▶ store: acme's STRIPE_KEY ├──▶ store: beta's STRIPE_KEY
├──▶ /run/secrets/STRIPE_KEY ├──▶ /run/secrets/STRIPE_KEY
│ on a zygote acme has alone │ on a different zygote
└──▶ removed when acme's request ends
Two rules follow from “one directory per sandbox”:
- A request with secrets has its zygote to itself while the files exist.
It takes an idle zygote; if every zygote is busy, the pool grows by one
(up to
max_warm), and atmax_warmthe caller getsbusy, which the SDKs retry. Ordinary requests skip a zygote that is taken this way. Requests without secrets share zygotes as before. - A missing name fails the request before anything runs. A tenant that
has no secret under one of the pool’s names gets
400(bad_spec) naming the secret and the route that stores it, never a value.
The store’s key
The key comes from ZYGO_SECRETS_KEY, or from a file named by
ZYGO_SECRETS_KEY_FILE. It must be 32 bytes, written as hex or base64.
A passphrase is refused: turning a password into a key safely needs extra
machinery, and a store that quietly accepted hunter2 would be worse than one
that says no. Zygo never saves the key. If you lose it, every stored secret
is unreadable for good. Without a key, the supervisor still serves every
function with no stored secrets; only the store refuses, and says which
variable to set.
What the store protects, and what it does not
Each value is encrypted with ChaCha20-Poly1305, a modern cipher, with the tenant and the name bound to it. So a value cannot be moved to another tenant or another name by renaming files. A value may be at most 64 KiB; anything bigger is a file, not a secret. Values cannot be read back, only listed by name. The encryption protects the bytes on disk. It does not protect them from a process that can read the supervisor’s memory, and it is not a hardware security module; if you need that, use your cloud’s key service.
Loosening, by name
Three things are refused unless you type a flag that says exactly what it does:
| Flag | What it allows |
|---|---|
--allow-host-net | network = "host" |
--allow-private-net | private and link-local addresses, and allow rules inside them |
--allow-unlimited | timeout = 0 |
The flags exist on zygo run, zygo serve and zygo mcp. zygo up has
none of them. A spec that needs one has to be served on purpose, one
function at a time, with the flag typed by a person.
15. Images and dependencies
A sandbox needs files to run: an interpreter, libraries, and your own packages. This chapter explains where those files come from — images, which Zygo pulls from normal registries, and the layers it builds on top of them — and how to keep the store tidy. There is no Dockerfile and no build step for you to run.
The big picture
A function’s file system is an image from a registry, plus up to three layers Zygo builds for you. The image is never changed. Each extra layer is built once and then shared.
python:3.12-slim from the registry; never changed
│
├── + bytecode layer .pyc files for the standard library built on first pull
├── + system layer apt packages: system = ["libwebp7"] key: image + list
└── + /venv pip packages: requirements = "…" key: image + file
│
▼
what the function sees as / (read-only, except /tmp)
shared by every function that names the same image and the same lists
Images, layers, tags and digests
An OCI image is the standard package format for containers, the same one
Docker uses. It is a stack of layers: each layer is an archive of files,
and stacking them gives one file tree. A registry is a server that stores
images, such as Docker Hub or ghcr.io. A tag is a friendly name, like
python:3.12-slim, that the owner can move to a new version at any time. A
digest is the image’s fingerprint, like sha256:4f1b…, and it never
changes: the same digest always means exactly the same bytes.
Pulling an image
To pull is to download an image into Zygo’s local store. Any OCI image from any registry works.
zygo pull python:3.12-slim # into the local store
zygo pull --platform linux/amd64 alpine:3 # another CPU type than this host's
zygo images # reference, digest, layers, size, when pulled
zygo images --json # the same, for a program: match `reference` exactly
--platform takes os/arch. Without it, Zygo pulls the version built for
the host it runs on.
When Zygo pulls for you
zygo run pulls an image the first time you use it, like docker run. Two
flags change that. --pull never refuses a missing image instead; the run
exits 1, and --outcome records phase: plan. --pull always pulls again,
for a tag that may have moved. zygo serve and zygo up never pull. A
deploy should not quietly depend on a registry being up, so they stop and ask
you to pull first.
| Command | Image missing | Image present |
|---|---|---|
zygo run | pulls it | uses it |
zygo run --pull never | refuses, exit 1 | uses it |
zygo run --pull always | pulls it | pulls it again |
zygo serve, zygo up | refuses: “pull it first” | uses it |
The store
Zygo unpacks each layer once, into a content-addressed store: every
layer is saved under its own digest, so the same layer is never stored twice,
even when many images share it. A sandbox does not get a copy. The layers are
mounted read-only into it, stacked with overlayfs
(chapter 4). The
store lives in Zygo’s data folder, ~/.local/share/zygo by default
(chapter 21 says how to move it).
store (on disk, once) sandboxes (no copies)
───────────────────── ─────────────────────
layers/sha256:aa… ─┐
layers/sha256:bb… ─┼── read-only mounts ─▶ sandbox 1 sandbox 2 sandbox 3
layers/sha256:cc… ─┘ (each stacks the same three layers)
+ the compressed download of each layer, kept beside it
Private registries: zygo login
A private registry needs a user name and a password or token. zygo login
stores them:
zygo login ghcr.io -u you # prompts, echo off
echo "$TOKEN" | zygo login ghcr.io -u you --password-stdin # for CI
There is no --password flag, on purpose. A command-line argument is
visible to every process on the machine through ps, and it lands in your
shell history. Zygo checks the login against the registry before it saves
it, so a typo fails now, not at the next deploy. The credential goes into
Zygo’s own auth.json in its data folder, readable only by you (mode 0600).
Docker’s logins are read too
If you already logged in with Docker, Zygo can use that. It reads
~/.docker/config.json, or the folder named by $DOCKER_CONFIG, and never
writes to it. When both have a login for the same registry, Zygo’s own
auth.json wins.
a pull from ghcr.io needs a login
│
├─ 1. <data>/auth.json (written by `zygo login`) ← wins
└─ 2. ~/.docker/config.json (or $DOCKER_CONFIG; read only)
Dependencies without touching the image
What you would write in a Dockerfile goes in sandbox.toml instead. There are
two kinds of dependency, and neither changes the image:
requirements = "./requirements.txt" # Python packages, into a venv
system = ["libwebp7"] # apt packages, into a layer
Python packages: requirements
A venv (virtual environment) is a folder that holds a set of Python
packages apart from the system’s. Zygo builds one from your
requirements.txt, inside a sandbox, with the image’s own pip. That is
the only way to be sure the packages match the Python that will run them. The
venv is mounted read-only at /venv, with /venv/bin first on PATH. On the
Lima VM, the first build of requests took about 2.5 seconds, and every
later run found the built venv in a millisecond or two
(chapter 25 has the numbers and the machine).
System packages: system
Some Python packages need a C library from the operating system, such as
libwebp7 for WebP images. system lists Debian (apt) packages, optionally
with a version: "libpq5=16.4-1". Zygo installs them inside a writable copy
of the image, compares the result with the original, and saves the
difference as a new OCI layer of its own. There is no Dockerfile, and nothing
else is rebuilt.
image layers (read-only) ──▶ writable copy ──▶ apt-get install libwebp7
│
diff: only the files apt added or changed
▼
a new layer, "<image>+system.<key>"
stacked on the image for this function
How the caches are keyed
Both kinds are built once and shared. Each is saved under a key: the image’s digest plus the list or the file’s bytes. Every function that names the same image and the same list uses the same build. Edit the list and you get a new key, so a new build. A different image is also a new key, because a package built for one Python often fails with a confusing error on another.
| Built | Keyed on | Shared by |
|---|---|---|
| the venv | image digest + the bytes of requirements.txt | every function and every run with that pair |
| the system layer | image digest + the system list | every function with that pair |
| the bytecode layer | image digest | everything that uses the image |
One-shot runs share the same cache
zygo run can use a requirements file too:
zygo run --requirements ./requirements.txt python:3.12-slim python3 -m pytest
The first job with a given image and file builds the venv. Every job after that, and every warm function with the same pair, reuses it.
The Python bytecode layer
Python turns each .py file it imports into bytecode — a faster form it
can run — and normally saves it as a .pyc file for next time. The official
python:*-slim images ship no .pyc files at all: python:3.12-slim has
1097 .py files in its standard library and none compiled. A sandbox’s root
is read-only, so Python cannot save them either. So every run compiled every
module again; import re alone took 34 ms.
How the bytecode layer works
The first time Zygo pulls or runs such an image, it compiles the standard
library once, inside a sandbox, into a layer of its own. For
python:3.12-slim that took about 3.2 s and made 18.6 MB. The
layer is stacked on the image, so the .pyc files sit next to the sources.
They are marked unchecked: a layer never changes, so there is nothing to
check them against. On the Lima VM used for testing, a script that imports ten common modules
(re, json, urllib.request and more) went from 165 ms to 35 ms
(chapter 25 has the table).
without the layer with the layer
───────────────── ──────────────
import re import re
└─ read re.py, compile it (every run) └─ read re.cpython-312.pyc (ready)
└─ cannot save .pyc: root is read-only
When there is no bytecode layer
An image that already has bytecode, or has no Python, is used as it is. If
the build fails, Zygo prints a warning and uses the original image; a run
never fails because of it. ZYGO_BYTECODE=0 turns the layer off.
Nix: not built
sandbox.toml accepts a nix field, so the file format is ready for it, but
Zygo does not build Nix packages yet. Serving a function that sets nix
fails with a clear error. Use system or requirements for now.
zygo.lock: the same image next time
A tag can move: python:3.12-slim next month is not the same bytes as today.
zygo up writes zygo.lock next to the spec. It records the digest each
image resolved to, the versions apt chose for each system package, and
the hash of each requirements file. Commit it to version control.
zygo up
│
├─ spec edited for this function? yes ─▶ serve it, rewrite its lock entry
│ no
├─ image digest same as zygo.lock? yes ─▶ serve it
│ no
└─ refuse this function, print both digests
→ zygo up --relock accepts the new image
Editing the spec re-locks that function without asking, because you just asked for the change. Chapter 20 has the rules in the reference.
Removing an image: zygo image rm
zygo image rm python:3.11-slim
This removes the image, the system-package images derived from it, and then anything that only they kept alive. It is refused while a warm function uses the image, because that function’s root file system is those layers, mounted. Stop the function first.
Pruning the store
Pruning deletes what is no longer needed. Start with --dry-run, which
only reports.
zygo image prune --dry-run
zygo image prune # only what nothing can reach any more
zygo image prune --unused-for 30d --blobs
| Command | What it deletes |
|---|---|
prune | layers of removed images; venvs, flattened roots and derived layers whose image is gone |
--unused-for 30d | also venvs and flattened roots not used for 30 days, even if their image is still here |
--blobs | also the compressed copy kept next to every unpacked layer |
--dry-run | nothing: it prints what it would delete |
A flattened root is all of an image’s layers copied into one folder, which
Zygo makes on kernels that cannot stack layers for a normal user. Every use of
a cache is recorded, so a venv used yesterday is safe even at
--unused-for 7d. --blobs roughly halves the store. The cost is a new
download if an unpacked layer is ever lost.
16. Deploying and running in production
This chapter is about the day after the first demo: putting a project’s functions live, keeping them healthy, and changing them without dropping a request. It also covers running Zygo inside a container and on Kubernetes, choosing an isolation backend, and the things Zygo will not do for you.
From a spec file to warm functions
Deploying here means one command, zygo up. It reads sandbox.toml, asks
the supervisor to warm every [fn.*] section, and writes zygo.lock. The
supervisor is the long-lived Zygo process that keeps the warm functions
(chapter 6).
sandbox.toml ──▶ zygo up ──▶ supervisor ──┬──▶ fn.resize ✓ warm
+ your shell's ├──▶ fn.parse · unchanged
secret values └──▶ fn.fetch ✗ failed, reason printed
│
└──▶ zygo.lock (image digests, apt versions; commit it)
zygo up # every [fn.*] warm
zygo down # stops what this spec declares, and nothing else
What zygo up prints
Functions come up in the order the file declares them, and each one gets a
line of its own. ✓ means started or replaced, · means unchanged and left
alone, and ✗ means it failed, with the reason. One failure does not stop
the others: a file with ten functions where the sixth cannot start still
brings up the other nine, and says which one failed. If any function failed,
zygo up exits with 1. zygo up --json prints one document with every
failure and its reason. The lines look like this (the numbers are only an
example):
✓ resize — python, 38 MB, ready in 412 ms
· parse — unchanged (exec, 3 MB)
✗ fetch — fn.fetch.secrets: no value for STRIPE_KEY; set it in the environment …
Run it again: only what changed restarts
Running up after an edit is not a second deploy. For each function, Zygo
compares four things with what is already running: the resolved spec, the
secret values, the bytes of the handler file and the bytes of the
requirements file. If all four are the same, the function is unchanged
and left alone — warm memory, request counters and all. If it was paused or
cold since the last deploy, up brings it back to warm. If anything
differs, it is replaced.
Blue/green replacement
A changed function is replaced blue/green: the new version is started next to the old one, and traffic moves only when the new one is ready. Requests the old sandbox already accepted finish on it. Requests waiting in its queue go to the new one. No request is dropped, and none sees a half-started function.
time ──────────────────────────────────────────────────────────▶
old (blue) ████████████████████████▓▓▓▓▓ finishes what it accepted, then stops
new (green) ░░░░░░ warming ░░░░████████████████████████████████
▲
switch: the new one is warm;
queued requests go to it
zygo.lock in a deploy
up records the image digest, apt versions and requirements hash for each
function in zygo.lock. Commit it. If a tag has moved under a spec nobody
edited, up refuses that function and prints both digests, so a deploy never
quietly runs different code; zygo up --relock accepts the move.
Chapter 15
explains it with a diagram. Remember too that up never pulls: pull the
images first.
zygo up has no escape flags
--allow-host-net, --allow-private-net and --allow-unlimited do not exist
on zygo up. A deploy from a file should never loosen a wall just because
the file says so. A function that really needs one must be served by hand
with zygo serve and the flag typed by a person
(chapter 14). The one
flag of the three on zygo api is --allow-private-net, for an embedder
whose pools call back to a service of its own; it is typed by whoever
starts the API, never sent in a request.
The supervisor
You rarely start the supervisor yourself. zygo serve, zygo up,
zygo token, zygo secrets and zygo api start it when none is running,
and give up if it is not ready within 10 seconds. It runs as your own user,
never as root. When it gets SIGTERM — the normal “please stop” signal from
systemd, Docker or Kubernetes — it stops taking new requests and gives the
running ones up to 25 seconds to finish. That is less than the 30 seconds
most process managers wait before they force a kill.
zygo supervisor status # what it is, and where its socket lives
zygo supervisor stop # drain, exit; the next serve or up starts a new one
zygo supervisor run # run it in the foreground, to see why it will not start
zygo supervisor is a hidden command: it does not appear in zygo --help,
because you seldom need it.
Running zygo api under systemd
On a Linux host the natural home for zygo api is a systemd unit: it starts
at boot, restarts if it dies, and its logs go to the journal. One line in
that unit matters more than the others. When any process inside a unit’s
cgroup is killed by the kernel’s out-of-memory killer, systemd’s default
(OOMPolicy=stop) stops the whole unit. A sandbox that goes over its
mem limit is killed exactly that way, inside its own cgroup, which sits
under the unit’s. So with the default, one request allocating too much
memory takes down the API, the supervisor and every pool with it, and every
later request is refused until somebody restarts the unit.
OOMPolicy=continue tells systemd the kill was handled and the unit goes on.
n8n-zygo-api.service ◀── systemd watches memory.events here
└─ zygo api
└─ zygo.slice/
└─ tenants/acme/resize/
└─ req-0192 ◀── the kernel kills the hog here
A user unit, in ~/.config/systemd/user/zygo-api.service:
[Unit]
Description=Zygo API
[Service]
ExecStart=/usr/local/bin/zygo api --listen 127.0.0.1:7700
Environment=ZYGO_API_TOKEN=change-me
Restart=on-failure
# A sandbox over its memory limit is killed alone; the unit goes on.
OOMPolicy=continue
# The unit owns its cgroup subtree, which is where every sandbox goes.
Delegate=yes
[Install]
WantedBy=default.target
systemctl --user daemon-reload
systemctl --user enable --now zygo-api
loginctl enable-linger $USER # keep it running when you log out
The same two settings on a transient unit, which is how a script or another service often starts it:
systemd-run --user --unit=zygo-api -p OOMPolicy=continue -p Delegate=yes -- zygo api
zygo doctor reports the unit’s policy as systemd OOM policy, and
zygo api prints a warning at start when the unit it is in would stop.
Chapter 22
has the journal lines this looks like when it has already happened.
Watching it
zygo ps # what is warm, and its counters
zygo top # ps on a timer, plus rates
zygo stats resize # latencies over the log window
zygo logs resize -f # the zygote's output and every request
zygo logs resize --failed -n 20
zygo stats keeps two kinds of number apart and labels each: counters since
the function warmed up, and latencies over the log window. It refuses to
report a 99th percentile from fewer than a hundred requests, rather than
invent one. Over the API, GET /metrics gives counters in Prometheus
format (chapter 17).
Idle functions
A warm function holds memory. So Zygo lets unused ones step down, in two
stages. After idle_timeout (default ten minutes) with no request, the
function is paused: frozen in place, still in memory, and thawed by the
next request in a few milliseconds. After cold_after (default one hour) it
is cold: the sandbox is dropped, only the spec is kept, and the next
request pays the full warm-up again.
idle_timeout = "10m"
cold_after = "1h"
request ──▶ WARM ──(idle_timeout: 10m)──▶ PAUSED ──(cold_after: 1h)──▶ COLD
▲ │ │
└────── next request: ~ms ─────┘ │
└────── next request: pays the whole warm-up again ─────────┘
zygo ps shows the state of each function. To bring one back before a real
request arrives, call POST /fn/<name>/warm over the API, or
client.warm(name) in the SDKs.
Capacity
Zygo’s capacity is a budget for one machine. Each function runs
concurrency requests at once (default 4). Up to four times that many more
wait in a queue, for at most five seconds. Past that, Zygo answers busy:
HTTP 429 with the numbers, or exit code 75 from zygo exec. This is
backpressure — a polite “not now” — and not a failure. The request never
ran, so retrying it is safe and correct.
concurrency = 4
running [■][■][■][■] 4 at once
queue [·][·][·][·][·][·][·][·]…[·] up to 16 more, 5 s at most
more ──▶ 429 busy (exit 75): never ran, retry later
Upgrading Zygo
A warm function cannot be carried across to a new Zygo binary. An upgrade is always: drain, exit, start the new version, warm up again. ADR 0004 is the study that explains why. To drain over the API:
curl -X POST -H "Authorization: Bearer $ZYGO_API_TOKEN" \
"http://127.0.0.1:7700/drain?grace_ms=60000"
This stops taking new requests, finishes the running ones, and exits. The
answer carries the number still running: in_flight: 0 is a clean drain,
and anything else means the grace time ran out. The cost is a warm-up per
function afterwards: about 150–185 ms for a Python handler, the supervisor’s
own start included, measured on a Raspberry Pi 5
(chapter 25); heavy imports add to it. No
request is dropped, as long as something else is ready to take them — which
is what two replicas and min_warm are for.
replica A: ███ serving ███ drain ▓▓ exit │ start new ░ warm ░ ███ serving ███
replica B: ███████████████ serving ███████████████████████████ drain ▓▓ …
▲ B takes all the traffic while A upgrades
Debugging a live function
zygo shell resize
zygo shell resize -- cat /proc/1/cgroup
zygo shell starts a new process inside the function’s namespaces. The warm
zygote is not touched: it keeps its memory and keeps serving. The shell sees
the sandbox’s files, processes, network and host name, and holds no
capabilities. It is on purpose not under the seccomp filter, the Landlock
rules or the tenant’s cgroup: a debug shell that the memory limit kills is
no use to anyone.
Calling it from a program
zygo api # listens on 127.0.0.1:7700, bearer-token auth
import zygo_sdk as zygo
client = zygo.connect()
out = client.fn("resize")({"url": "..."}).result # what the handler returned
The API starts call-only: a token can call the functions somebody
declared in a spec file, and nothing else. --allow-deploy adds serving,
stopping and one-shot runs, which together amount to a shell, not an API.
ZYGO_API_TOKEN is the operator’s token. For a platform with customers,
zygo token mint --tenant acme prints a token that registers scripts and
calls functions for that customer only; the tenant comes from the token,
never from anything the caller sends. Chapter 17 covers
the API and SDKs in full.
Giving it to an agent
An AI agent can use Zygo through MCP, the Model Context Protocol, a standard way for agents to call tools. The whole installation is one entry in the agent’s configuration:
{ "mcpServers": { "zygo": { "command": "zygo", "args": ["mcp"] } } }
Chapter 17 describes the tools it offers.
Running Zygo inside a container
Zygo builds sandboxes, so a container it runs in must let it. It needs no
privileges and no capabilities, and --privileged is not the answer. The
container image asks for three things. A host with AppArmor needs a fourth,
and sandboxes with a network need a fifth. zygo doctor names each one that
is missing.
docker run --user 0:0 \
--security-opt seccomp=unconfined \
--security-opt systempaths=unconfined \
--security-opt apparmor=unconfined \
--cgroupns=host --cgroup-parent=/zygo \
-v /sys/fs/cgroup/zygo:/sys/fs/cgroup/zygo:rw \
--device /dev/net/tun \
-v zygo-data:/var/lib/zygo \
-p 7700:7700 -e ZYGO_API_TOKEN=... \
ghcr.io/mhmtskrc2/zygo
--user 0:0 is there because the cgroup folder belongs to root. As the
image’s own user, 65532, Zygo could not write it. No sandbox runs as that
root: each one is in a user namespace of its own.
With Docker’s systemd cgroup driver — the default on Ubuntu and Debian —
the parent must be a slice: --cgroup-parent=zygo.slice and
/sys/fs/cgroup/zygo.slice.
The three things, and two more
┌─ the container Zygo runs in ──────────────────────────────────────────┐
│ │
│ 1. seccomp=unconfined ─▶ may call unshare(CLONE_NEWUSER) │
│ 2. systempaths=unconfined ─▶ /proc not masked, so a fresh /proc │
│ can be mounted in a user namespace │
│ 3. its own cgroup subtree ─▶ somewhere to put each sandbox │
│ 4. apparmor=unconfined ─▶ (AppArmor hosts) mounts not refused │
│ 5. /dev/net/tun ─▶ (egress / full) pasta's network card │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ sandbox │ │ sandbox │ │ sandbox │ ◀── the real boundary │
│ └──────────┘ └──────────┘ └──────────┘ │
└───────────────────────────────────────────────────────────────────────┘
| Need | Why | In Kubernetes |
|---|---|---|
A seccomp profile that allows unshare(CLONE_NEWUSER) | Docker’s default profile denies it, and it is the first thing a sandbox does | securityContext.seccompProfile: {type: Unconfined} |
An unmasked /proc | runtimes cover parts of /proc (kcore, acpi …); the kernel then refuses a new proc mount inside a user namespace, because the old one is not fully visible. Sandboxes die on “mounting /proc failed: Operation not permitted” | privileged: true gives one. securityContext.procMount: Unmasked is accepted only with hostUsers: false |
| A writable cgroup v2 subtree of its own | every sandbox goes in a cgroup, and the container’s /sys/fs/cgroup is read-only | no field exists; see below |
| No AppArmor profile, on AppArmor hosts | Docker’s docker-default profile denies mount; zygo doctor reports “the mount tree could not be made private” | securityContext.appArmorProfile: {type: Unconfined} |
/dev/net/tun, for egress and full | pasta gives a sandbox its network card through it, and runtimes leave the device node out | a hostPath of type CharDevice |
Give it its own cgroup, not the host’s
The usual advice is to mount all of /sys/fs/cgroup read-write. That works,
but it hands the container the host’s whole cgroup tree: it could then
change limits on any cgroup on the machine, including other containers’.
That is worse than the privilege you were trying to avoid. Give Zygo a
subtree of its own instead, as the command above does with /zygo. Sealed
sandboxes — network = "none", the default — do not need /dev/net/tun.
The host’s AppArmor still applies
A host’s own AppArmor rules still reach into a container. Ubuntu’s passt
profile attaches to the path /usr/bin/pasta and refuses the pid file Zygo
asks for. An image that installs pasta somewhere else on PATH is not
affected, and the Zygo image puts nothing at /usr/bin/pasta for exactly
this reason (chapter 22 has the error and the fix).
Why this is safe enough
A container set up this way is no easier to escape than the host it runs on.
That is the point: the wall Zygo enforces is the one it builds inside — the
sandboxes — not the container around it. The test scripts
tests/linux/verify_supervisor.sh and tests/linux/verify_api.sh run in
exactly this shape in CI, unprivileged, on every change. make verify-oci builds the image and
runs a sandbox inside it the same way.
The container image
make oci-image # build it from the binary make already checked
make verify-oci # build it and run a sandbox inside it, unprivileged
The published image is ghcr.io/mhmtskrc2/zygo:<version>, for linux/amd64 and
linux/arm64. It is signed with cosign and
listed in the release’s SHA256SUMS. It is Alpine plus a few programs:
| In the image | Why |
|---|---|
zygo | the static musl binary, the one tests/linux/check_dist.sh checked for size |
pasta, nft, tc | what network = "egress" needs; without them egress is refused with a reason, never quietly opened |
newuidmap, newgidmap | how a non-root user maps a range of user ids; without them every tenant maps to one host uid and that separation is lost |
| user 65532 | nonroot, the number distroless images use, with a 65536-wide range of sub-ids |
| no Python, no Node | each sandbox runs in an image of its own; what a handler can import comes from the image it names |
The data folder is /var/lib/zygo (ZYGO_DATA_HOME), a volume. The image
has a health check on /healthz.
No Python in the Zygo image
That last row surprises people. The Zygo image does not need Python, because
your function never runs in it: it runs in python:3.12-slim or whatever
image the spec names, pulled into Zygo’s store. If you want those images
already on the node, so a cold start is not a trip to the registry, build a
worker image: Dockerfile.worker
runs zygo pull at build time and is a few lines long. It trades a bigger
image for no waiting; pull only the images you really serve.
The image is call-only by default
The image’s default command is api --listen 0.0.0.0:7700, with no
--allow-deploy. A token can call the functions somebody declared and
nothing else. Deploy rights over HTTP are a shell, and a default that hands
them out is one nobody reads the flag for. Pass --allow-deploy when the
thing in front of the API is your own control plane. The worker image does.
Verifying a published image
cosign verify ghcr.io/mhmtskrc2/zygo:0.1.5 \
--certificate-identity-regexp '^https://github\.com/.*/\.github/workflows/release\.yml@refs/tags/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
This is keyless signing: the signature is tied to the GitHub workflow that built the image and the tag it was built from. There is no private key to keep safe, and none to leak.
Zygo on Kubernetes
examples/kubernetes/ has a working manifest.
A CI job named kubernetes applies exactly this file to a fresh kind
cluster on every change and runs a sandbox in it, so the file is tested, not
just an illustration.
kubectl apply -f examples/kubernetes/deployment.yaml
kubectl -n zygo port-forward svc/zygo 7700:7700
namespace zygo
┌─────────────────────────────────────────────────────────────────────┐
│ Secret zygo: api-token, secrets-key │
│ Service zygo :7700 ─────────┬──────────────────────┐ │
│ ▼ ▼ │
│ ┌─ pod 1 ─────────────────────────┐ ┌─ pod 2 ─────────────────┐ │
│ │ zygo api --allow-deploy │ │ (same) │ │
│ │ securityContext: see below │ │ │ │
│ │ /var/lib/zygo emptyDir 20Gi │ │ │ │
│ │ /run/zygo emptyDir, memory │ │ │ │
│ │ readiness: /healthz │ │ │ │
│ │ liveness: zygo doctor --json │ │ │ │
│ │ preStop: POST /drain │ │ │ │
│ └─────────────────────────────────┘ └─────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
replicas: 2 · maxUnavailable: 0 · maxSurge: 1 · no hostPath anywhere
The Secret holds two values: the operator’s API token, from which every customer token is minted, and the key that seals per-tenant secrets (chapter 14). The pod asks for 1 CPU and 2 GiB, and sets a memory limit of 8 GiB but no CPU limit: each tenant’s CPU is already capped by a cgroup Zygo sets per request, and a pod-wide CPU ceiling would also slow the supervisor itself.
What securityContext is for, and privileged: true
A pod needs the same things as the container above. One has a field of its
own, two come with privileged: true, and one is a node setting:
seccompProfile: Unconfined | the default profile denies unshare(CLONE_NEWUSER) |
an unmasked /proc | a masked /proc stops a fresh proc mount in a user namespace; privileged: true leaves it unmasked |
| a writable cgroup v2 subtree | no field says this; privileged: true with runAsUser: 0 is the way to get one |
| unprivileged user namespaces | a setting on the node (kernel.unprivileged_userns_clone, AppArmor on Ubuntu), not the pod |
privileged: true is there for the third row, together with runAsUser: 0:
the pod’s cgroup belongs to root, and privileged gives capabilities to root
only. It also brings an unmasked
/proc, /dev/net/tun and no AppArmor profile, which a pod without it would
have to ask for as the table above describes. The field for the first,
procMount: Unmasked, is refused by Kubernetes unless the pod also sets
hostUsers: false, so the example does not use it. The cost is smaller than it
looks, because the wall Zygo enforces is the sandbox it builds inside the pod
— namespaces, seccomp, Landlock, a cgroup per request — not the pod itself.
Still, run it on nodes of its own, and read chapter 23.
The day Kubernetes can say “give this pod its own cgroup subtree”, that line
goes, and nothing else changes.
Rolling out without dropping a request
Three settings together: maxUnavailable: 0, so no pod stops before its
replacement is ready; a preStop hook that calls POST /drain; and a
terminationGracePeriodSeconds (120 in the example) longer than your longest
request. Kubernetes does these in the right order: it takes the pod out of the
Service before preStop runs, so the drain only finishes work already
accepted. /healthz answers 503 from the moment a drain starts, which also
takes the pod out of any load balancer that watches readiness.
kubectl rollout
│
├─ start new pod ─▶ startupProbe /healthz passes (warm) ─▶ ready
├─ old pod removed from the Service's endpoints
├─ preStop: POST /drain?grace_ms=60000
│ stop admitting · finish in-flight · reply {in_flight: 0} · exit
└─ old pod gone (terminationGracePeriodSeconds: 120)
Log the drain’s answer: in_flight: 0 is clean, and anything else means the
grace time ran out.
Three probes, three questions
| Probe | Checks | Why |
|---|---|---|
startupProbe | GET /healthz, every 2 s, up to 30 times | a warm pool takes as long as its interpreter; until this passes, the others cannot fail the pod |
readinessProbe | GET /healthz | “should this pod get work?” It says no while draining, without killing the pod |
livenessProbe | zygo doctor --json | “is this pod broken?” A liveness check on /healthz would restart a pod that is draining on purpose; doctor asks whether the host can still build sandboxes |
The image store on Kubernetes
The store is an emptyDir, because it is a cache: a pod that moves to
another node pulls again what it needs. The things that are not a cache —
tenants, tokens, sealed secrets — belong in your control plane’s database,
with the API as the way in. Two alternatives are both fine: a PVC (a
persistent volume), if re-pulling costs you more than a volume to manage, or
a worker image with the images baked in, which costs nothing at run time. No
hostPath appears anywhere, so pods can be scheduled freely.
Trying it on kind
kind.yaml creates a one-node kind
cluster (Kubernetes in Docker) that can run the manifest. It needs cgroup v2
on the host. A real cluster does not need this file, but it needs cgroup v2
too.
What the Kubernetes example leaves out
- An Ingress. The API belongs to your control plane, not the internet. Put a service of your own in front of it.
- Autoscaling. A warm pool costs memory, not CPU. The signal that
matters is how many requests are waiting, and
GET /metricsdoes not publish that yet: it has requests, failures, memory and state per function (chapter 17). Until it does, scale on memory (zygo_function_rss_bytes) and on the429answers your callers see. - A PodSecurityPolicy or Gatekeeper rule. A cluster that enforces one will need an exception for this namespace, and that exception is yours to write.
Choosing an isolation backend
isolation = "ns" # ns | gvisor | vm
A backend is the kind of wall between the sandbox and the host. You change it with one field; nothing else in the spec changes.
ns your code ─▶ seccomp, Landlock, namespaces ─▶ host kernel
gvisor your code ─▶ gVisor (a kernel in user space) ─▶ host kernel
vm your code ─▶ guest kernel ─▶ KVM (hardware) ─▶ host kernel
─────────────────────────────────────────────────────────▶
cheaper, and warm functions stronger wall
ns | gvisor | vm | |
|---|---|---|---|
| Wall | namespaces, cgroups, seccomp, Landlock; the host’s kernel | a user-space kernel between you and the host’s | a guest kernel under KVM |
| One-shot runs | yes | yes | yes |
| Warm functions | yes, the only one | refused | refused |
| Networking | yes | refused | refused |
| Setup | none | zygo backend install gvisor | a --features vm build and a guest kernel |
The three backends in detail
ns uses one kernel, shared with the host, with every lock the kernel
offers turned on. Every number in this book is measured on it.
gvisor puts a kernel written in user space between the sandbox and
yours: a smaller attack surface, at a cost on every syscall. Warm functions
and networked sandboxes on it are refused with a reason, never weakened.
vm is a hardware wall: libkrun and KVM, with a guest kernel of its own.
On the Raspberry Pi 5 a one-shot run took about 420 ms, against about 73 ms
for ns on the same machine. The guest writes to a private layer, bounded by
scratch, and nothing it writes reaches the shared image or the next
sandbox. It has no networking and no warm functions yet.
Installing a backend
zygo backend list # what this host can actually use
zygo backend install gvisor
zygo run --isolation gvisor python:3.12-slim python3 -c 'import platform; print(platform.release())'
zygo backend install gvisor downloads gVisor’s runsc from its official
release bucket on storage.googleapis.com and checks its sha512 before
unpacking it. The vm backend needs a Zygo binary built with
--features vm (or make vm-build) and a guest kernel file at
<data>/backends/krun/Image. There is no published kernel to download yet:
zygo backend install vm tells you to build one with make vm-kernel and
where to copy it. zygo doctor reports it once it is in place.
Which backend to pick
Use ns for code you chose, or code you half trust. For code you did not
choose, use the strongest wall you can get. Read
chapter 23 before you trust any of them with something
that matters — in particular the part about where the wall is weaker than it
looks. ADR 0002 explains why warm
functions stay on ns.
What Zygo will not do
This list is plain on purpose: a tool that is vague about its limits is worse than one that lacks a feature.
- Run on macOS or Windows natively. Sandboxes are a Linux feature. On a Mac, Zygo manages a Linux VM for you.
- Accept connections. No mode of Zygo serves your traffic. A function is called through the CLI, the SDKs or Zygo’s own HTTP API; you put your own ingress in front of that.
- Scale past one machine. Capacity is a budget per host, and a
429past it. - Replace Docker. Zygo uses OCI images and none of Docker’s runtime. If
you need
docker compose, long-running services or published ports, you need Docker. - Analyse the code it runs. Zygo confines hostile code. It does not tell you the code was hostile: there is no audit mode, no network log and no verdict.
- Hide the kernel. The
nsbackend shares one kernel with the host, and this book says so everywhere.
17. The HTTP API, the SDKs and MCP
The CLI is one way in. Programs use the HTTP API — directly, or through the Python, Node and Elixir clients — and agent hosts use the MCP server. All of them end at the same supervisor, the long-lived Zygo process that owns the warm sandboxes.
your program ──▶ Python/Node/Elixir ─┐
curl ────────────────────────────────┤ HTTP ┌──────────┐ unix socket ┌────────────┐
└───────▶│ zygo api │────────────▶│ │
└──────────┘ │ │
CLI (zygo exec, zygo up, …) ──────────────────────────────────────────▶│ supervisor │
│ │
agent host ──▶ zygo mcp (stdin/stdout) ───────────────────────────────▶│ │
└────────────┘
This chapter has three parts. First the HTTP API itself: how to start it, who may call what, the routes and the answers. Then the three SDKs, and the features they reach: tenants, secrets, files, streaming, cancelling, runtime pools and dependency sets. Last, the MCP server for agent hosts.
Part one: the HTTP API
zygo api is a small web server. It turns HTTP requests into messages for the
supervisor, and turns the supervisor’s answers back into HTTP. It does not run
sandboxes itself. Every boundary a sandbox has is built by the zygo binary
and enforced by the kernel, so nothing a caller sends over HTTP can do more
than the rules below allow.
Starting the API
zygo api runs the API in the foreground; run it under systemd or in a
container for production. It listens on 127.0.0.1:7700, or where [api] listen or --listen says — an IP:PORT or unix:///path (created with mode
0600, so only your user can open it). By default every caller needs a bearer
token — a secret string sent in the Authorization header — on every kind of
listener, unix sockets included. --no-auth (or [api] auth = "none") turns
that off, and is refused anywhere but a unix socket or a loopback address. So
a unix socket or loopback only permits turning auth off; it never skips it by
itself. zygo api --openapi prints the OpenAPI 3.1 document for this build;
the same file is committed as spec/openapi.json.
Who is calling: tokens and tenants
ZYGO_API_TOKEN (bootstrap) ───────┐
├──▶ OPERATOR: the host's owner. May act for
operator token (zygo token mint) ─┘ any tenant with the X-Zygo-Tenant header.
tenant token (zygo token mint --tenant acme) ──▶ TENANT acme: sees and calls
only its own things.
A tenant is one customer of whoever embeds Zygo. A token proves who
is calling, and the caller cannot choose it. The bootstrap token comes from
ZYGO_API_TOKEN; more are minted with zygo token mint (secrets look like
zygo_ plus 64 hex characters). Only a hash is stored, so a lost token
cannot be shown again — revoke it and mint another. A revoke works from the
next request. An operator acting for a tenant sends X-Zygo-Tenant: acme; a
tenant token that sends a different tenant gets 403. The sections
Tenants and Tokens below show both from the SDKs.
The deploy gate
Calling a function that exists is one thing; creating sandboxes is another,
because whoever can create one can run any image with any mount as your user.
That is a shell, not an API. So zygo api starts call-only: a token
reaches the functions somebody declared in a spec file that was reviewed, and
nothing else. The routes that create, change or destroy things need
deploy rights. An operator token minted with zygo token mint always has
them, because minting it already needed them. The bootstrap token has them
only when zygo api was started with --allow-deploy. A tenant token never
does, whatever the flag says: one customer does not get to name an image
because another is trusted.
request with a valid token
│
▼
route needs deploy rights? ──no──▶ allowed (a tenant sees only its own things)
│ yes
▼
tenant token? ──yes──▶ 403
│ no
▼
operator token minted with `zygo token mint` ──▶ allowed
bootstrap ZYGO_API_TOKEN ──▶ allowed only if `zygo api --allow-deploy`, else 403
Which routes are gated
These need deploy rights: PUT and DELETE /fn/{name}, POST /run,
POST and DELETE /runtimes, PATCH /tenants/{id}/limits, PUT and
DELETE /tenants/{id}/secrets/{name}, DELETE /tenants/{id},
DELETE /scripts/{digest}, DELETE /blobs/{digest}, DELETE /deps/{id},
POST /drain, and every token route. POST /tenants and GET /tenants are
for the operator only, but do not need deploy rights.
These are not gated, and any valid token may use them: PUT /scripts,
GET /scripts/{digest}, PUT /blobs, POST /deps, GET /runtimes,
POST /runtimes/{name}/call and DELETE /requests/{id}. Registering code
that still needs a pool to run, and calling a pool somebody else declared, is
exactly what a call-only token is for.
Why each one is gated
Serving a function, a one-shot run and creating a pool all name an image and
mounts, which is running code as the user Zygo runs as. Deleting a script, a
blob or a dependency set is gated because each store is shared by digest or
id: forgetting one forgets it for every tenant that sent the same bytes.
Limits, secrets and tokens are the operator’s relationship with a customer,
and draining stops the host serving anybody. Without the rights, the SDK
raises AuthError; the message names the flag for an operator, and says what
a tenant token is for instead.
What stays off for everybody
A request body can never remove a guarantee. Host networking and unlimited
limits are refused over HTTP whatever the caller and whatever the body says.
Set those where the sandbox is declared, in sandbox.toml (see
chapter 20).
Private-range egress has one more place: the command that starts the API.
zygo api --allow-deploy --allow-private-net lets what deploy callers serve
— PUT /fn/{name}, POST /runtimes, POST /run — name private and
link-local addresses in allow. It opens nothing by itself: a rule still
names the address and the port, and everything else in those ranges stays
shut. It is for an embedder whose scripts must call back to a service of its
own, which chapter 14 says to put on an address the host really has. Without
--allow-deploy it does nothing, because only a deploy call declares a
sandbox, and GET /version reports it as private_net for callers who can
use it.
zygo api --allow-deploy zygo api --allow-deploy --allow-private-net
POST /runtimes allow=["10.0.0.5:4010"] POST /runtimes allow=["10.0.0.5:4010"]
→ 400 bad_spec: targets a private range → served; 10.0.0.5:4010 and nothing else
Request and response headers
| Header | Direction | Meaning |
|---|---|---|
Authorization: Bearer … | in | The token. |
X-Zygo-Tenant | in | Which tenant an operator is acting for. |
X-Zygo-Timeout-Ms | in | How long the caller will wait; up to 24 hours. The default wait is 60 s. |
X-Zygo-Request-Key | in | The caller’s own name for a request (1–128 printable characters), to cancel it by. |
X-Zygo-Request-Id | out | Zygo’s id for the request. |
Retry-After | out | On 429 (1 s), and on 503 while dependencies build (5 s). |
The routes
“any” means any valid token; “op” means operator; “deploy” means deploy rights. Bodies are JSON unless marked raw; the limit is 16 MiB.
| Route | Who | What it does |
|---|---|---|
GET /healthz | nobody needs a token | ok, degraded (a pool below min_warm) or stopping (503). |
GET /version | any | Zygo version, API version, and whether you have deploy rights. |
GET /metrics | any | Prometheus text (below); scoped to the token. |
POST /drain?grace_ms= | deploy | Stop taking requests, finish the running ones, exit. |
GET /fn | any | Functions you can see: state, image, memory, request counts. |
PUT /fn/{name} | deploy | Serve or replace a function. Body: {layer, base_dir, secrets?, if_changed?}. |
DELETE /fn/{name} | deploy | Stop it. |
POST /fn/{name} | any | Call it. Body: the event. ?stream=1 for live output; ?out=1 to get /work back as a tar; ?workspace=sha256:… to send one in. |
POST /fn/{name}/batch | any | Call it with an array of events (up to 1024); an array of answers comes back. |
GET /fn/{name}/logs | any | Recent log entries: ?after=, ?limit=, ?failed=. |
GET /fn/{name}/stats | any | One function’s status. |
POST /fn/{name}/warm | any | Wake a paused or cold function now. |
GET /runtimes | any | Pools and their load. |
POST /runtimes | deploy | Start a pool: {name, layer, base_dir?, deps?}. layer.secrets names the secrets a call may receive; values are the calling tenant’s. |
DELETE /runtimes/{name} | deploy | Stop a pool. |
POST /runtimes/{name}/call | any | Run a script in a pool: {script, event?, entry_point?, workspace?}. |
PUT /scripts | any | Store a script (raw body); returns its sha256. |
GET / DELETE /scripts/{digest} | any / deploy | Check or remove one. |
PUT /blobs | any | Store a tar (raw body), for workspaces. |
GET / DELETE /blobs/{digest} | any / deploy | Check or remove one. |
POST /deps | any | Build a dependency set from lock files: {image, files}. 202 while building. |
GET /deps, GET /deps/{id} | any | Their state, and the build log. |
DELETE /deps/{id} | deploy | Remove one no pool uses. |
POST /tenants · GET /tenants | op | Create or list tenants. 400 on a host whose user has no subordinate uid range, unless the supervisor runs with ZYGO_ALLOW_SHARED_UID=1 (chapter 23). |
GET /tenants/{id} | that tenant, or op | One tenant: scripts, limits. |
DELETE /tenants/{id} | deploy | Remove it, its scripts and its functions. |
PATCH /tenants/{id}/limits | deploy | Narrow its limits: mem, cpu, pids, timeout, scratch, network, allow. |
GET /tenants/{id}/secrets | that tenant, or op | Secret names — never values. |
PUT / DELETE /tenants/{id}/secrets/{name} | deploy | Set (raw body) or remove one. |
POST /tokens · POST /tenants/{id}/tokens | deploy | Mint an operator or tenant token. |
GET /tokens · DELETE /tokens/{id} | deploy | List or revoke. |
DELETE /requests/{id} | any | Cancel your own request, by id or by key. |
POST /run | deploy | A one-shot sandbox: {layer, stdin?}; returns exit code, output, and why it ended. |
A layer in a body is one function’s table from sandbox.toml, sent as
JSON: the same fields [fn.NAME] takes — image, entry or cmd, mem,
network, allow, secrets, and so on — with every field optional, exactly
as chapter 20 lists them. It is called a layer because
it is merged over [defaults] the way a [fn.NAME] table is. base_dir is
the folder on the server that its relative paths (entry, mounts,
requirements) are relative to.
A batch runs at most 16 of its events at the same time. ?workspace= on a
function call takes only a blob digest, never an inline tar (see
Files in and out).
Status codes
| Code | Meaning |
|---|---|
| 200 / 201 / 202 | Done / created / still building. |
| 400 | A bad spec, header or body. |
| 401 | No token, or a wrong or revoked one. |
| 403 | Your token may not do this: not the operator, no deploy rights, or the wrong tenant. |
| 404 · 405 | No such thing · wrong method. |
| 408 | The function’s own timeout killed the request. On POST /run, only when the API’s own outer deadline fired (see below). |
| 413 | Body over 16 MiB, or a batch over 1024. |
| 422 | A tenant limit above what could ever apply. |
| 429 | Busy: every slot and the queue are full. Retry after the header. |
| 499 | The request was cancelled. |
| 500 | The handler raised: the body has error, stdout, stderr, exit_code. |
| 503 | Warming failed, dependencies still building, or the API is stopping. |
| 504 | The request stopped answering heartbeats: stuck. |
Errors always have the shape {"error": "…", "code": "…"}.
A successful call
{
"result": {"count": 3},
"request_id": "req-01f3…",
"stdout": "",
"stderr": "",
"metrics": {"wall_ms": 4.1, "cpu_ms": 2.3, "peak_rss_kb": 18420}
}
A streamed call (?stream=1) answers application/x-ndjson: one line per
event, {"stream": "stdout" | "stderr" | "progress", "data": …}, then a last
line with the body above and a status. Watching a request
explains it.
The answer of a one-shot run
POST /run answers with more than an exit code, because the exit code alone
cannot carry what a caller needs:
{"exit_code": 137, "timed_out": false, "oom_killed": true,
"peak_rss_kb": 65536, "wall_ms": 412.7, "stdout": "", "stderr": "",
"started": true, "phase": "run"}
A deadline kill and an out-of-memory kill are both SIGKILL, so both show
exit code 137. timed_out comes from the launcher, which enforced the
deadline; oom_killed comes from the kernel’s own counter in the sandbox’s
cgroup (the kernel group that holds its limits). Neither is a guess. A caller
deciding between “too slow” and “too much memory” — an online judge, a CI
step — has nothing else to go on.
started, and when POST /run says 408
started is whether the program ran at all. false, with phase naming
what failed (plan or start), is Zygo failing to build the sandbox. A
caller reports that as unavailable, not as the code’s failure, and ok is
false for it. An API one release behind does not send the field, and only
answered once the program had run, so the SDKs default it to true.
The answer is a 200 whenever the sandbox ran, whatever ended it: a
non-zero exit, its own timeout, an out-of-memory kill. The body says which. It
is a 408 only when the API’s own outer deadline fired, the child was killed,
and what it would have said is unknown — Zygo failing to finish, not a sandbox
doing its job.
Metrics, OTLP and usage events
/metrics gives Prometheus series: zygo_api_requests_total,
zygo_api_errors_total, and per function zygo_function_requests_total,
zygo_function_failures_total, zygo_function_rss_bytes and
zygo_function_state. --otlp-endpoint URL pushes the same numbers, plus
per-tenant requests, outcomes, CPU and wall time, to an OpenTelemetry
collector. --usage-webhook URL POSTs batches of usage events for billing —
{tenant, function, script, request_id, wall_ms, cpu_ms, peak_rss_kb, outcome, finished_ms} — at least once, so key on request_id. Both count only
requests that went through this API process.
Who sees what on /metrics. It needs a token, and the answer depends on
the token, the same way GET /fn does. A tenant token gets
zygo_api_requests_total and zygo_api_errors_total, which carry no names,
and the per-function series for its own functions only. An operator token
gets every function on the host. There are still no per-tenant series; the
OTLP push and the usage webhook are where per-tenant numbers live.
Usage, for billing has the detail on the usage events.
Part two: the SDKs
There are three clients over the HTTP API: Python, Node and Elixir. Python and Node have no dependencies: the standard library has an HTTP client in each language, and a unix socket is a few lines on top of it. Erlang’s HTTP client keeps its connections to itself, so the Elixir client has two small dependencies instead (see The Elixir client). An SDK for a runtime whose point is a small, auditable boundary should not arrive with a dependency tree of its own. None is a second implementation of Zygo, and nothing in any client can widen a sandbox.
your process ──HTTP──▶ zygo api ──control socket──▶ supervisor ──▶ warm sandboxes
On one machine the HTTP hop can be a unix socket at mode 0600, so there is
no port. Across machines it is TCP. Either way a bearer token is needed,
unless the API was started with auth turned off on a unix socket or loopback
address.
The Python client
pip install zygo-sdk # or: pip install -e sdk/python; then `import zygo_sdk`
The module is zygo_sdk, not zygo: a different, older project owns the
name zygo on PyPI, and two packages that install the same module name
overwrite each other. import zygo_sdk as zygo keeps the short name in your
code.
No dependencies. zygo.connect() finds the API from its url argument, then
ZYGO_API_URL, then http://127.0.0.1:7700, and its token from token= or
ZYGO_API_TOKEN.
import zygo_sdk as zygo
client = zygo.connect()
res = client.fn("resize")({"url": "…"}) # a call; ~1.4 ms of overhead
print(res.result) # what the handler returned
res = client.call("resize", {"url": "…"}, timeout=5) # the same call, with a timeout
print(res.result, res.stdout, res.metrics.wall_ms) # a Result: value, output, timings
for ev in client.stream("resize", {"url": "…"}): # live output
print(ev.kind, ev.data)
The client covers every route but GET /metrics, which is for a scraper:
call, batch, stream, cancel, logs, stats, warm, serve,
stop, run, functions; pools with serve_runtime, run_script,
stream_script, put_script; put_deps, put_blob; tenants, secrets,
limits and tokens; for_tenant(id); health, version, drain. What the clients can do lists
every method.
The async Python client
zygo.aio is an asynchronous client, which is what an agent framework
needs:
import asyncio
import zygo_sdk as zygo
async def main(events):
async with zygo.aio.connect() as client:
return await asyncio.gather(*(client.call("resize", e) for e in events))
asyncio.run(main(events))
It is the whole API, not a subset: every method of the plain client, with the
same name and arguments, as a coroutine — tenants, tokens, secrets, limits,
blobs, drain, for_tenant, and workspace/out on call and run_script
included. A test holds the two surfaces equal. zygo.aio is reachable after
import zygo_sdk as zygo; import zygo_sdk.aio works too. Cancelling the
task cancels the request on the server.
The Node client
npm install zygo-sdk
No dependencies, Node 18 or newer, ES modules. The same methods in
camelCase — runScript, serveRuntime, putSecret — with timeouts in
seconds and an AbortSignal that cancels the request for you.
import { connect } from 'zygo-sdk';
const client = connect(); // ZYGO_API_URL, ZYGO_API_TOKEN
const resize = client.fn('resize');
const out = await resize({ url: 'https://example.com/a.png' });
console.log(out.result, out.metrics.wallMs);
The TypeScript types
TypeScript types ship beside the JavaScript in index.d.ts. They are written
by hand rather than compiled, so the package has no build step and what you
read in the repository is what runs. A test (sdk/node/test/types.test.js)
reads the declaration file against the module as it runs — every export,
every Client method, every option a call reads — so a method added without
a declaration fails the suite rather than shipping untyped.
The Elixir client
# mix.exs
{:zygo_sdk, "~> 0.1"}
Elixir 1.18 or newer, for the JSON module in the standard library. The same
methods as Python, with the same snake_case names, as functions in the Zygo
module that take the client first. Every call to the API returns
{:ok, value} or {:error, %Zygo.Error{}}, and has a ! twin that returns
the value or raises. The two streams yield an error as an element instead,
and their ! twins raise it. Durations — timeout:, backoff:, retry_after — are milliseconds,
as is usual in Elixir.
client = Zygo.connect() # ZYGO_API_URL, ZYGO_API_TOKEN
{:ok, out} = Zygo.call(client, "resize", %{"url" => "…"}, timeout: 5_000)
IO.inspect({out.result, out.stdout, out.metrics.wall_ms})
There is no fn(name) handle: fn is a keyword in Elixir, and
&Zygo.call(client, "resize", &1) is the same thing in one line.
Processes, and the pool
The client is a plain struct, so any process may use it. Its connections live
in a pool process, which Zygo.connect/2 starts linked to the caller. A
client that should live as long as your application goes under your own
supervisor instead, and is fetched by name:
children = [{Zygo, name: MyApp.Zygo, url: "unix:///run/zygo/api.sock", retries: 3}]
Zygo.functions!(Zygo.client(MyApp.Zygo))
caller process ──checkout──▶ ┌── Zygo pool ─────────────┐
(sends, reads the answer) │ idle connection · 12 s │
◀──────────────connection─── │ idle connection · 3 s │
caller process ──checkin───▶ │ (up to pool_size: 32) │
└──────────────────────────┘
A caller borrows one connection for one request and gives it back. The socket
moves with it, so a caller that crashes mid-request takes its socket down
rather than leaving a half-read answer in the pool. More callers than
pool_size wait for a connection to come free. The pool is
NimblePool, and the connections are
Mint, which is HTTP as a data structure
rather than a process and opens a unix socket. Those two are the package’s
only dependencies.
Errors, and streams
The other clients raise one class per kind of failure. The Elixir client
has one exception, Zygo.Error, and its kind field says which failure it
was: :busy, :handler, :timeout and so on (see Errors in the
clients). A case on the kind is how a caller
branches:
case Zygo.call(client, "resize", event) do
{:ok, out} -> out.result
{:error, %Zygo.Error{kind: :busy, retry_after: ms}} -> {:later, ms}
{:error, %Zygo.Error{kind: :handler, stderr: stderr}} -> {:bug, stderr}
end
Zygo.stream/4 returns a lazy Stream, and nothing is sent until it is read.
Its items are {:stdout, text}, {:stderr, text} and {:progress, text},
then one last item: {:result, %Zygo.Result{}}, or {:error, %Zygo.Error{}}.
A refusal before anything ran is that error alone. Zygo.stream!/4 raises the
error instead, after the output before it.
What the clients can do
Who is the token the call is made with: a tenant token is one customer’s, an operator token is the host’s. Deploy marks the calls that need deploy rights (see The deploy gate).
| Python | Node | Elixir | Who | Deploy | |
|---|---|---|---|---|---|
| Call a warm function | client.call(name, event) | client.call(name, event) | Zygo.call(client, name, event) | either | no |
| A callable for one function | client.fn(name) | client.fn(name) | &Zygo.call(client, name, &1) | either | no |
| Several events at once | client.batch(name, events) | client.batch(name, events) | Zygo.batch(client, name, events) | either | no |
| List functions | client.functions() | client.functions() | Zygo.functions(client) | either | no |
| Counters | client.stats(name) | client.stats(name) | Zygo.stats(client, name) | either | no |
| Warm one now | client.warm(name) | client.warm(name) | Zygo.warm(client, name) | either | no |
| Recent log | client.logs(name) | client.logs(name) | Zygo.logs(client, name) | either | no |
| Version | client.version() | client.version() | Zygo.version(client) | either | no |
| Health | client.health() | client.health() | Zygo.health(client) | anyone | no |
| Drain the host | client.drain(grace) | client.drain(grace) | Zygo.drain(client, grace: ms) | operator | yes |
| Register a script | client.put_script(source) | client.putScript(source) | Zygo.put_script(client, source) | either | no |
| Build a dependency set | client.put_deps(image, files) | client.putDeps(image, files) | Zygo.put_deps(client, image, files) | either | no |
| How a build went | client.deps(id) | client.deps(id) | Zygo.deps(client, id) | own, or operator | no |
| Stop a running request | client.cancel(id) | client.cancel(id) | Zygo.cancel(client, id) | own, or operator | no |
| Limit a tenant | client.set_limits(id, **keys) | client.setLimits(id, keys) | Zygo.set_limits(client, id, keys) | operator | yes |
| A tenant’s secret names | client.secrets(id) | client.secrets(id) | Zygo.secrets(client, id) | own, or operator | no |
| Set one | client.put_secret(id, name, v) | client.putSecret(id, name, v) | Zygo.put_secret(client, id, name, v) | operator | yes |
| Forget one | client.delete_secret(id, name) | client.deleteSecret(id, name) | Zygo.delete_secret(client, id, name) | operator | yes |
| Store a blob | client.put_blob(tar) | client.putBlob(tar) | Zygo.put_blob(client, tar) | either | no |
| Look one up | client.blob(digest) | client.blob(digest) | Zygo.blob(client, digest) | either | no |
| Forget one | client.delete_blob(digest) | client.deleteBlob(digest) | Zygo.delete_blob(client, digest) | operator | yes |
| Watch a call’s output | client.stream(name, event) | client.stream(name, event) | Zygo.stream(client, name, event) | either | no |
| The same, for a pool | client.stream_script(rt, script) | client.streamScript(rt, script) | Zygo.stream_script(client, rt, script) | either | no |
| Look a script up | client.script(digest) | client.script(digest) | Zygo.script(client, digest) | either | no |
| Run a script in a pool | client.run_script(runtime, script) | client.runScript(runtime, script) | Zygo.run_script(client, runtime, script) | either | no |
| List runtime pools | client.runtimes() | client.runtimes() | Zygo.runtimes(client) | either | no |
| Read a tenant | client.tenant(id) | client.tenant(id) | Zygo.tenant(client, id) | own, or operator | no |
| Act for a tenant | client.for_tenant(id) | client.forTenant(id) | Zygo.for_tenant(client, id) | operator | no |
| List tenants | client.tenants() | client.tenants() | Zygo.tenants(client) | operator | no |
| Create a tenant | client.create_tenant(id) | client.createTenant(id) | Zygo.create_tenant(client, id) | operator | no |
| Serve a function | client.serve(name, layer) | client.serve(name, layer) | Zygo.serve(client, name, layer) | operator | yes |
| Stop one | client.stop(name) | client.stop(name) | Zygo.stop(client, name) | operator | yes |
| One-shot sandbox | client.run(image, cmd) | client.run(image, cmd) | Zygo.run(client, image, cmd) | operator | yes |
| Forget a script | client.delete_script(digest) | client.deleteScript(digest) | Zygo.delete_script(client, digest) | operator | yes |
| Forget a dependency set | client.delete_deps(id) | client.deleteDeps(id) | Zygo.delete_deps(client, id) | operator | yes |
| Delete a tenant | client.delete_tenant(id) | client.deleteTenant(id) | Zygo.delete_tenant(client, id) | operator | yes |
| Serve a runtime pool | client.serve_runtime(name, layer, secrets=[…]) | client.serveRuntime(name, layer, { secrets }) | Zygo.serve_runtime(client, name, layer, secrets: […]) | operator | yes |
| Stop one | client.stop_runtime(name) | client.stopRuntime(name) | Zygo.stop_runtime(client, name) | operator | yes |
| Mint a token | client.mint_token(tenant) | client.mintToken(tenant) | Zygo.mint_token(client, tenant) | operator | yes |
| List tokens | client.tokens() | client.tokens() | Zygo.tokens(client) | operator | yes |
| Revoke one | client.revoke_token(id) | client.revokeToken(id) | Zygo.revoke_token(client, id) | operator | yes |
A listing is scoped to the caller: functions() and runtimes() through a
tenant token show that tenant’s names, and nobody else’s. (/metrics is
scoped the same way; see Metrics, OTLP and usage
events.)
Connecting
All three clients find the address in the same order: the argument, then
ZYGO_API_URL, then http://127.0.0.1:7700. These forms are accepted:
unix:///run/user/1000/zygo/api.sock a local API over a unix socket
http://127.0.0.1:7700 the default
https://zygo.internal:8443 across a network
box:9000 bare host and port
The token comes from ZYGO_API_TOKEN unless one is passed. That is the same
variable the server reads, so a shell that can start the API can talk to it.
A unix socket still needs the token, unless the API was started with
--no-auth. Every client pools connections and is safe to share between
threads, tasks or processes. That matters: one connection would line
concurrent callers up behind a single socket, and the warm path is measured
in milliseconds.
The API hangs up on a connection that has waited 30 seconds for its next
request. A pooled connection older than 20 seconds is therefore dropped, not
reused; and if a reused one turns out to be closed — the send fails, or the
socket ends where the status line should begin — the request goes once more
on a fresh connection. That is safe because nothing came back, so the
request never ran. A connection that fails after the first byte of an
answer, or a new connection that fails at all, is reported as a
TransportError (in Elixir, kind: :transport), never retried.
Retries
Every client can resend a refused request. retries (default 0) is how many
times; backoff (default 1 s) is the base wait. Only Busy and
Unavailable qualify — both mean the request never ran, which is what makes
sending it again safe. A handler that raised, a request the deadline killed,
a missing function: sent once, never again. Each wait is the longer of the
server’s Retry-After and backoff doubled per attempt (1 s, 2 s, 4 s …),
and the last refusal is the error you see.
client = zygo.connect(retries=3) # Busy or Unavailable → wait, send again
const client = connect(undefined, { retries: 3, backoff: 0.5 });
client = Zygo.connect(retries: 3, backoff: 500) # milliseconds
Tenants
An embedder — a product that runs its customers’ code on Zygo — has customers. A tenant is one of them, and it is what lets the API answer “whose script is this?”:
client.create_tenant("acme") # POST /tenants, idempotent
acme = client.for_tenant("acme") # a view; the same connection
script = acme.put_script(source) # registered against acme
acme.run_script("py312", script.sha256, event) # and only acme may run it
await client.createTenant('acme');
const acme = client.forTenant('acme');
Zygo.create_tenant!(client, "acme")
acme = Zygo.for_tenant(client, "acme") # a new struct; the same pool
Listing or creating tenants is the operator’s: a customer that could list the other customers is a leak, whatever the limits say. A tenant may read its own record, which is how a client finds out what it registered.
What a tenant gets
- Its own scripts. A digest (the SHA-256 hash that names a script) is not a key to it — anyone holding the bytes can compute one. So a tenant naming a digest it did not register is told the script does not exist. That is the same answer an unregistered digest gets, on purpose: “it exists but is not yours” is a fact about another customer.
- Its own cgroup.
tenants/<tenant>/<function>/…, so everything one customer runs is in one place, can be killed in one write, and counted in one read. Limiting a tenant narrows it. - Deletion that means it.
client.delete_tenant(id)stops their functions and pools and removes the scripts nothing else refers to. It answers with both lists, because neither can be rebuilt afterwards. It also takes the tenant’s tokens.
Tokens
A tenant is only worth having if the server can tell whose request this is without being told. That is what a token is: the one part of a request the caller cannot choose.
import os
import zygo_sdk as zygo
operator = zygo.connect(url, token=os.environ["ZYGO_API_TOKEN"])
minted = operator.mint_token("acme") # POST /tenants/acme/tokens
print(minted.secret) # the only time this exists
acme = zygo.connect(url, token=minted.secret)
acme.put_script(source) # registered against acme, no header
const minted = await operator.mintToken('acme');
const acme = connect(url, { token: minted.secret });
There are two kinds, on purpose, and no finer ladder of scopes. An
operator token (mint_token(), no tenant) belongs to whoever runs this
Zygo: tenants, functions, pools, and more tokens. A tenant token
(mint_token(id)) belongs to one customer. It registers scripts for itself,
calls the pools and functions the operator declared, reads its own record —
and cannot see that any other tenant exists.
What follows from that
- The secret exists once. The server keeps only a SHA-256 of it, so
client.tokens()can list every token on the host without being a way to steal one. Nothing can print a secret again: lose one, revoke it, mint another. X-Zygo-Tenantis the operator’s.for_tenant(id)says which of your customers you are acting for. A tenant token already names its tenant, and a header that disagrees with it is refused, not ignored.- Revoking is immediate. The next request with a revoked token is a 401. The record stays, marked, so an id in a log line still points at something.
- Deleting a tenant takes their tokens along with the scripts only they referred to.
ZYGO_API_TOKEN is the bootstrap operator token: the same variable an
existing deployment already sets, with the rights it already had. On the host,
zygo token mint, zygo token ls and zygo token revoke <id> do the same
three things without an HTTP round trip.
Health and draining
GET /healthz needs no token, so a load balancer can probe it. It answers one
of three things:
| Status | Code | Means |
|---|---|---|
ok | 200 | every pool is at its floor |
degraded | 200 | a pool is below min_warm; requests work, the first pay a cold start |
stopping | 503 | the supervisor is draining |
degraded is a 200 on purpose. A host that can serve should be served to.
A probe that took hosts out of rotation for being slow would take every host
out at once after a restart. stopping is the one answer that is not a 200,
because a balancer that keeps sending to a draining host is the reason
draining fails.
Draining
client.drain(grace=30) # {"drained": true, "in_flight": 0}
Draining stops taking new requests, lets the running ones finish, answers,
and then exits. The default grace is 30 seconds. in_flight: 0 is a clean
drain; anything else means the grace ran out. A deploy script needs to know
which of those happened. SIGTERM does the same, so a container stop or a
systemctl restart needs no call at all. The grace there is 25 seconds,
chosen against the 30 that systemd and Docker give a process before
SIGKILL: a drain that outlived its own kill would never finish.
Chapter 16 covers running Zygo in production.
Usage, for billing
Every finished request produces one usage event, from the supervisor —
whether it came over HTTP, from zygo exec at a terminal, or from an MCP
tool:
{"tenant": "acme", "function": "py312", "script": "sha256:…",
"request_id": "00000042", "wall_ms": 812.4, "cpu_ms": 740.1,
"peak_rss_kb": 48200, "outcome": "ok", "finished_ms": 1790000000000}
outcome is one word — ok, error, timeout, cancelled, stuck — so a
dashboard groups by it instead of working it out again from four true/false
fields. A caller who cancelled their own request reads cancelled, even if
the deadline happened to pass while the kill landed.
Three ways to collect it
| Where | How | Which requests |
|---|---|---|
The supervisor’s log, target zygo::usage | nothing to set up | all of them: HTTP, zygo exec, MCP |
| OTLP | zygo api --otlp-endpoint URL: zygo.tenant.requests, .outcomes, .cpu, .wall, one series per tenant | only those that went through this API process |
| A webhook | zygo api --usage-webhook URL: batches of up to 256, posted as {"events": [...]} | only those that went through this API process |
The OTLP series and the webhook are counted in the memory of the zygo api
process. Requests from zygo exec or from MCP never pass through it, so they
appear only in the supervisor’s log. If you bill from the webhook, make
sure every billable request goes through the API.
The webhook is at least once
A batch that fails goes back on the front of the queue, in order, and is
retried. So a receiver may see an event twice and should key on request_id.
The queue holds at most 10 000 events. When a webhook has been down long
enough to fill it, the oldest events are dropped and the count is logged. The
alternative is the API process growing until it takes the serving path down
to protect the billing path, which is the wrong way round. The events are in
the supervisor’s log either way.
Limiting a tenant
A pool is declared once by the operator and called by every customer. One customer should not be able to take the whole of it:
client.set_limits("acme", mem="256M", cpu=0.5, pids=64, timeout="30s")
The keys are mem, cpu, pids, timeout, scratch, network and
allow. It is a PATCH, so the keys you pass are set and the rest are left
alone. timeout lands on the supervisor’s deadline rather than the cgroup,
because a cgroup cannot enforce a wall clock. The rest are cgroup settings.
Limits only narrow
A tenant’s limits are applied as the minimum of themselves and whatever the function or pool was declared with. They are written on the request’s own cgroup before the handler is let go. So the worst a wrong value can do is give a customer less than they were promised, never more. No value and no key can widen anything, which is what makes the route safe to expose.
A value above every ceiling the tenant can reach — their own functions,
and every pool on the host — is refused with 422 naming the key. It could
never take effect, and storing it would let you believe you had tightened
something you had not. Above one ceiling and below another is fine: it
narrows the larger and does nothing to the smaller. Chapter
14 explains the limits themselves.
Secrets
A function’s secrets are delivered as files at /run/secrets/<name>,
written from outside the sandbox and removed when the last request in flight
finishes. An operator at a terminal supplies them from their own environment.
An embedder’s customers cannot: they have their own keys and nobody to
restart a supervisor. So Zygo can store secrets per tenant.
export ZYGO_SECRETS_KEY=$(zygo secrets keygen) # before the supervisor starts
zygo secrets set acme STRIPE_KEY # reads it with echo off
client.put_secret("acme", "STRIPE_KEY", value) # PUT, needs deploy rights
client.secrets("acme") # ['STRIPE_KEY'] — names only
A stored secret fills in what the shell did not supply, per tenant, when a
function is served. Where both have a value, the shell wins: zygo serve at a
terminal is somebody saying what they want now. A tenant cannot set its own
secrets; the operator holds that relationship.
No way to read a value back
This is not a missing route. There is no answer shape that could carry a
value, because a store that answered with values would make every route that
reaches it a way to read every customer’s keys. Values are sealed with
ChaCha20-Poly1305 (an authenticated cipher) under a key Zygo never stores.
Each is bound to its own tenant/name, so it cannot be moved to another by
anything that can only rename files. A passphrase is refused rather than
stretched: turning one into a key needs a password key function, and a store
that accepted hunter2 and stretched it badly would be worse than one that
said no.
What that protects, and what not
It protects the bytes at rest: a backup, a stray tar, anything that can
read one user’s files. It does not protect them from a process that can read
the supervisor’s memory, and it is not a hardware root of trust. An operator
who needs those has a KMS (a key management service).
Secrets for a runtime pool
A pool’s sandbox is shared by several tenants, and /run/secrets is one
directory in it — so a pool holds no values. It names them, and each call is
given the calling tenant’s values from the store, for that call only:
client.serve_runtime("py312", {"image": "python:3.12-slim", "agent": "python"},
secrets=["STRIPE_KEY"]) # names, not values
client.put_secret("acme", "STRIPE_KEY", value) # acme's value
client.for_tenant("acme").run_script("py312", digest) # reads acme's, as a file
await client.serveRuntime('py312', { image: 'python:3.12-slim', agent: 'python' },
{ secrets: ['STRIPE_KEY'] });
The script reads /run/secrets/STRIPE_KEY as a function would. While the
files exist the call has its zygote to itself, so no other tenant’s child is
forked beside them; if every zygote is busy the pool grows, and at
max_warm the call gets busy, which the SDKs retry. A call from a tenant
that has no secret under one of the names is refused with 400 before
anything runs, naming the secret; a pool that names secrets on a host with no
store key is refused when it is served. The serve_runtime argument is a
list of names — the difference from serve, whose secrets are values.
Chapter 14 has
the rules.
Files in and out
A handler that converts a document needs the document, and the caller needs
what comes back. Neither belongs in a JSON event. So a request can carry a
workspace: a tar archive (one file that packs many files) that Zygo
unpacks into the request’s own directory. With out, the directory comes
back as a tar with the answer.
caller one request's sandbox
────── ─────────────────────
tar of files ──inline or blob──▶ unpacked into a fresh directory under /work
the handler starts in it: reads in.pdf,
writes out.png
answer + tar ◀──── out=1 ────── the directory, packed on the way out
then removed, whatever happened
tar = make_tar({"in.pdf": pdf_bytes})
out = client.run_script("convert", script, {"to": "png"},
workspace={"inline": base64.b64encode(tar).decode()},
out=True)
open("result.tar", "wb").write(out.workspace) # already decoded
const out = await client.runScript('convert', script, { to: 'png' }, {
workspace: { inline: tar.toString('base64') },
out: true,
});
Inside the handler
The handler is started in its own directory and told where it is:
def handler(event):
with open("in.pdf", "rb") as f: # the caller's files are just here
...
open("out.png", "wb").write(rendered) # and this comes back with `out=1`
return {"pages": 3}
Send a fixture once: blobs
An embedder often sends the same fixture across a thousand calls. Store it once as a blob and name it by digest:
blob = client.put_blob(tar) # PUT /blobs, idempotent
client.run_script("convert", script, event, workspace={"blob": blob.sha256})
A warm function’s body is the event itself, with nowhere to put a workspace.
So there it goes in the query string: client.call(name, event, workspace=blob.sha256, out=True), which is ?workspace=sha256:…&out=1. It
takes a blob only, because an inline tar in a URL would be a megabyte of
base64 in a request line. The async Python client takes both as well.
What keeps one request’s files from another’s
Not a mount namespace, and the reason was measured rather than assumed. A
forked child runs at an unprivileged uid with no CAP_SYS_ADMIN in the
sandbox’s user namespace, so unshare(CLONE_NEWNS) fails with EPERM. That
holds even with the most permissive seccomp profile, so it is the namespace
and not a filter. One path cannot mean a different directory to each request.
What is there instead is listed plainly, because the first two are weaker than
a namespace would be:
/workcannot be listed (mode0311), so a request cannot see its neighbours’ names. The test suite checks this by trying.- The directory’s name is 128 random bits, not the request id, which is a counter.
- It is removed when the request ends, whatever the request did, so the window is one request long. This is also checked.
Archives are unpacked, not trusted
Every archive is unpacked by Zygo under strict rules. Only files and
directories are allowed: no symlinks, no hard links, because that is how
an archive writes outside the directory it was unpacked into. No .., no
absolute paths. File modes are Zygo’s, not the archive’s. Entries and bytes
are capped, and counted as they are written, not read from a header an
archive is free to lie in.
The way out is careful too. ?out=1 packs what the handler left, and the
handler may leave a process running that keeps changing it. So Zygo never
follows a path into the folder. It opens each folder it has already opened,
one name at a time, and never follows a link. A name that has become a link,
a pipe or a socket since the folder was read is skipped. A workspace folder
swapped for a link is not packed at all. Only files and folders come back,
and never more bytes than the cap.
Watching a request
A call that takes a minute has something to say before it finishes:
for event in client.stream("render", {"pages": 400}):
if event.is_result:
print(event.result.result)
else:
print(event.kind, event.data, end="") # stdout, stderr, progress
for await (const event of client.stream('render', { pages: 400 })) {
if (event.kind === 'result') console.log(event.result.result);
else process.stdout.write(event.data);
}
Each item is a piece of the request’s output, and the last one is the result: exactly what the non-streaming call would have returned, or raised. A handler that printed and then failed produced both. So the output is delivered first, and the exception comes when you iterate past the result.
Three kinds of item
stdout and stderr are what the request’s process wrote, kept apart as
everywhere else. progress is its own kind, not a line of stdout. A long
request has two things to say — what it printed, and how far it has got — and
a caller should not have to parse a handler’s log messages to find the second.
The handler calls event.progress(...). It exists whether or not anybody is
listening, so a handler does not break depending on who called it:
def handler(event):
for n, page in enumerate(event["pages"]):
event.progress(f"{n} of {len(event['pages'])}")
return {"done": True}
The result still carries the whole of stdout and stderr, bounded as
always. A caller that streamed and one that did not see the same text;
streaming only changes when.
On the wire
POST /fn/render?stream=1 Content-Type: application/x-ndjson
──────────────────────────────────────────────────────────────────────────
{"stream": "stdout", "data": "loading\n"}
{"stream": "progress", "data": "1 of 400"}
{"stream": "stderr", "data": "warning: …\n"}
…
{"status": 200, "result": {…}, "request_id": "…", "stdout": "…", …} ◀── last line
Streaming is per request, not per function. Sending each print() as it
happens costs a system call per print() on a path measured in milliseconds.
So a caller that wants to watch pays for it, and everybody else keeps the fast
shape. The answer is newline-delimited JSON (one JSON object per line) rather
than server-sent events: every language can read a line and parse JSON. The
streaming connection is held for the whole request and is not pooled.
Abandoning the iterator closes it, which does not cancel the request —
pass a key and use cancel for that.
Cancelling a request
A request that is running can be stopped:
out = client.call("render", event, key="job-4711") # name it on the way in
...
client.cancel("job-4711") # from anywhere
const controller = new AbortController();
const call = client.call('render', event, { signal: controller.signal });
controller.abort(); // cancels it on the server too
Asynchronous Python needs no key at all:
task = asyncio.ensure_future(client.call("render", event))
task.cancel() # sends the cancel before CancelledError propagates
The caller of the cancelled request gets Cancelled (HTTP 499), which is on
purpose not Timeout. A timeout says the work is too slow or the limit
too tight; a cancel says the answer stopped being wanted. Both arrive as exit
137 from the kernel, and only the side that sent the signal can tell them
apart, so the supervisor records which it was.
caller A ── POST /fn/render (X-Zygo-Request-Key: job-4711) ──▶ running …
caller B ── DELETE /requests/job-4711 ──▶ supervisor
└──▶ writes cgroup.kill on that
request's own cgroup
caller A ◀── 499 Cancelled
A key, not the request id
The id is assigned by the host and arrives with the answer, which is too
late to stop the call it belongs to. X-Zygo-Request-Id comes back on every
response and in the body as request_id; it joins a log line to its request.
The key is what a caller uses to name a request it is still waiting for.
Reusing a key is allowed, and one cancel then stops every call under it.
What actually stops the work
The supervisor writes cgroup.kill on the request’s own cgroup, from outside
the sandbox. That reaches everything the handler started, and does not depend
on the tenant’s code being in a state where a signal helps. The agent inside
is told, so it can mark the answer, but an agent that ignores the message
changes nothing. A cancel that arrives before the request was let go is the
best case: nothing of the handler has run, and started: false in the answer
says so. Cancelling something already finished, or another tenant’s request,
is the same NotFound. Request ids are a counter, not a secret, so ownership
is what keeps a cancel honest.
Runtime pools
A warm function is one script in one zygote (the parked, ready process that Zygo forks per request; see chapter 13). A runtime pool is the other shape: an image, a dependency set and an agent, with no code in it. The script arrives with the call.
WARM FUNCTION RUNTIME POOL
┌───────────────────────────┐ ┌───────────────────────────┐
│ image + deps + agent │ │ image + deps + agent │
│ + one handler, imported │ │ no code │
└───────────────────────────┘ └───────────────────────────┘
event ──▶ handler(event) script digest + event ──▶ the forked
child loads that script, runs it
client.serve_runtime("py312", { # POST /runtimes
"image": "python:3.12-slim",
"agent": "python",
"min_warm": 2, "max_warm": 8,
"mem": "512M", "timeout": "60s",
})
script = client.put_script(source) # once, PUT /scripts
out = client.run_script("py312", script.sha256, {"month": "2026-09"})
print(out.result, out.metrics.wall_ms)
await client.serveRuntime('py312', { image: 'python:3.12-slim', agent: 'python' });
const { sha256 } = await client.putScript(source);
const out = await client.runScript('py312', sha256, { month: '2026-09' });
Zygo.serve_runtime!(client, "py312", %{"image" => "python:3.12-slim", "agent" => "python"})
%Zygo.Script{sha256: sha256} = Zygo.put_script!(client, source)
out = Zygo.run_script!(client, "py312", sha256, %{"month" => "2026-09"})
How a script gets in
client.runtimes() lists the pools with their zygote counts, and
client.stop_runtime(name) stops one (so does zygo stop <name>). A pool
can name secrets; each call then gets the calling tenant’s values — see
Secrets for a runtime pool. run_script also takes the source
directly, for a one-off not worth registering. The request never reaches the
zygote: the supervisor writes the script into the sandbox, and the forked
child loads it after the child’s seccomp filter (its list of allowed system
calls) is installed. That is what makes it safe for two tenants to share a
pool. It is checked, not just claimed: tests/linux/verify_api.sh asks a
script where it was loaded from and whether it can list what else is in flight.
A pool with no agent
A pool usually holds an agent — an interpreter, warm, forking per request. It
does not have to. Give it a cmd and no agent and you get the
warm-exec shape. Zygo holds the sandbox, writes each request’s script into
it, and runs cmd with that path as its last argument and the event on
stdin.
client.serve_runtime("sh", {"image": "alpine:3", "cmd": ["/bin/sh"]})
script = client.put_script(open("wordcount.sh").read())
out = client.run_script("sh", script.sha256, {"text": "warm exec in sh"})
That runs sh /run/script/<digest> inside the sandbox. It is the right shape
for bash, for a static binary that takes a script as an argument, and for
any language that starts in under a millisecond. There is nothing for an
agent to save there, and the warm protocol would only be one more moving
part.
What warm-exec does not have
There is no protocol to carry them, so warm-exec has no streaming, no
progress(), no workspaces, and no per-tenant limits narrowed per request.
The sandbox itself is the same: same namespaces, same seccomp profile, same
cgroup per request, same deadline.
examples/warm-exec/ has both shapes side by
side.
Dependency sets
A pool’s requirements names a file on the Zygo host, which is the one
thing an embedder does not have. POST /deps is the other half: send the lock
file itself, and get back an id a pool can be built on.
deps = client.put_deps("python:3.12-slim", { # POST /deps
"requirements.txt": open("requirements.txt").read(),
})
deps.id # 'deps_3f1c…' — its name from now on
deps.state # 'building'
while client.deps(deps.id).building: # GET /deps/<id>
time.sleep(2)
client.serve_runtime(
"py312",
{"image": "python:3.12-slim", "agent": "python"},
deps=deps.id,
)
const deps = await client.putDeps('node:22-slim', {
'package.json': manifest,
'package-lock.json': lockfile, // `npm ci` needs it, so this does too
});
await client.serveRuntime('node22', { image: 'node:22-slim', agent: 'node' },
{ deps: deps.id });
Python takes a requirements.txt and gets a venv (a private folder of
installed packages). Node takes a package.json and its lock file and
gets npm ci. Either way the result is mounted read-only at /venv and the
environment points at it — PATH for Python, NODE_PATH for Node — so a
script just imports what the lock file named.
Chapter 15 covers dependencies in general.
POST /deps ──▶ 202 building ──┬──▶ ready ──▶ serve_runtime(…, deps=id)
│ └─▶ mounted read-only at /venv
└──▶ failed ──▶ deps(id).log has the reason
Five things worth knowing
- It answers before the build finishes. A
pip installtakes minutes, and an HTTP request that waited would time out in every proxy on the way. Polldeps(id), or send theserve_runtimeand retry theUnavailableit raises, which carries the host’sRetry-Afterasretry_after— a client opened withretries=does that itself. - A pool named against a build still running is refused, not queued.
Nothing is started. A zygote warmed without the dependencies it was promised
would serve requests that fail at
import. - The id is a hash of the files and the image. The same lock file on the
same image is one build however many customers send it, and all of them see
it in
deps(). A different image is a different id, because a wheel built for one interpreter fails in another. - The build reaches the package registries and nothing else. Installing a
package runs its code — a
setup.py, an npm lifecycle script — and the lock file came from whoever holds a token. The build sandbox hasnetwork = "egress"with only the registries allowed. A host that cannot enforce that (nopasst, nonftables) refuses the build rather than falling back to host networking. - A failed build keeps its log, on the same object as the state:
client.deps(id).logis the resolver’s own words.
client.delete_deps(id) forgets one, and is refused while a pool is built on
it. The pool holds a read-only mount of that directory, and removing it under
a warm zygote would make its imports fail one at a time.
The script store
An embedder’s scripts live in the embedder’s database, not on the Zygo host.
PUT /scripts is how one gets to a sandbox without a file on the host or a
line in sandbox.toml:
script = client.put_script(source) # PUT /scripts, body is the script
script.sha256 # 'sha256:71e2b5…' — its name from now on
script.existed # the store already had exactly these bytes
The body is the script itself, not JSON around it: a script is a file, and wrapping its bytes only to unwrap them again helps nobody. The name is the SHA-256 of those bytes. That makes the call idempotent (safe to repeat) in the strongest sense: the same script from two tenants is one file on disk, and neither can put different bytes under a digest the other is running.
Looking up and forgetting
client.script(digest) says whether the host holds it and how big it is. It
never returns the bytes: a digest is not a key, so a store that answered with
the script would make every tenant’s code readable by anyone who could guess
it. client.delete_script(digest) forgets one, and needs deploy rights,
because forgetting it forgets it for every tenant that sent the same bytes.
Why PUT /scripts is not gated
Registering a script does not make it runnable on its own. A request still has to name something to run it in, which is a runtime pool the operator declared. So any authenticated caller may register a script, tenant tokens included. A tenant registering its own code runs nothing by doing so, and the digest is theirs from then on.
Errors in the clients
Each kind of failure is its own type, because each one means something different about what to do next.
| HTTP | Python and Node error | Elixir kind | Means | What to do |
|---|---|---|---|---|
| 429 | Busy, with retry_after | :busy | the pool is full; the request never ran | retry after retry_after |
| 408 | Timeout | :timeout | the deadline killed the request | the work is too slow, or the limit too tight |
| 499 | Cancelled | :cancelled | somebody stopped the request | nothing: this is what was asked for |
| 504 | Stuck | :stuck | the sandbox went quiet with budget left | look at the function, not its timeout |
| 500 from a handler | HandlerError, with stdout, stderr, exit_code | :handler | the handler raised | fix the function |
| 404 | NotFound | :not_found | no function (or script, or request) by that name | serve it, or check the name |
| 401, 403 | AuthError | :auth | wrong token, or a deploy call without deploy rights | check the token or the flag |
| 400 | SpecError | :spec | the sandbox as described cannot be resolved | fix the request |
| no connection | TransportError | :transport | the API could not be reached | nothing ran |
| 503 | Unavailable, with code and retry_after | :unavailable | dependencies still building (deps_building), a zygote that failed to warm (warm_failed), or the API stopping; the request never ran | retry after retry_after, or let retries do it |
| anything else | ZygoError | :other | e.g. 413, 422; the message has the status | read the message |
Unavailable is the 503: the host cannot do this yet, and nothing about
the request needs changing. Unlike Busy it is not backpressure — the wait
is for something the host is doing. health() raises it once the API is
stopping. A 422 (a limit above every ceiling) is still a plain ZygoError.
Every error type is a subclass of ZygoError, so catching it catches
everything. In Elixir every one is a Zygo.Error, returned as {:error, _}
or raised by the ! functions, with the same fields as the class it stands
for — retry_after, code, stdout, stderr, exit_code, request_id.
Busy or HandlerError: the one that matters
Busy means the request was refused before anything happened, so retrying is
correct. A handler that raised will raise again. Timeout is not a guess
either. The supervisor records that it killed the request, because a
deadline kill and an out-of-memory kill both arrive as exit 137. In all, exit
137 has four readings — a deadline, an out-of-memory kill, a cancel, and a
sandbox that went quiet — and the supervisor is the only side that knows
which, so it says.
Long requests
A function’s timeout may be hours, and a caller may wait up to a day
(X-Zygo-Timeout-Ms, at most 24 hours; the default wait is 60 s). Two things
make that safe rather than a way to hold a slot for ever:
- A heartbeat. The agent says every second or two that a request is still
alive. A request the supervisor has heard nothing about for a minute is
killed and raises
Stuck, whatever its budget said. So a wedged request costs a minute, not its whole timeout, and “your code is slow” stays different from “the sandbox stopped answering”. - The idle policy leaves working zygotes alone. A zygote nobody has called
for
idle_timeoutis frozen. One with a request in flight is not, whatever the clock says, because freezing it would stop the request. A request that runs for an hour under a two-secondidle_timeoutstill finishes.
A batch is the exception to raising: each element is a result or an
error, returned rather than raised, because one refused event must not hide
the answers to the others.
A worked example
examples/plugin-host/ is a plugin host in
about a hundred lines, built on this API alone: no sandbox.toml, no file on
the Zygo machine, no shelling out to zygo. It onboards customers and gives
each their own token, limits and secrets. It declares one runtime they all
share, installs their code by digest, runs it with files in and out, streams
the long ones, stops them, and offboards. make verify-plugin-host runs it
against a real kernel. That includes the two checks this whole layer exists
for: one customer cannot run another’s plugin by naming its digest, and a
customer is held to their own memory limit rather than the runtime’s.
The same host from Node is
examples/plugin-host-node/: a
node:http server that onboards customers with an operator token, registers
and runs their scripts through forTenant, streams one route as NDJSON, and
turns HandlerError, Timeout, Cancelled, NotFound, Busy and
Unavailable into its own status codes. node --test runs it against a fake
Zygo; make verify-plugin-host-node against a real one.
The OpenAPI document
zygo api --openapi > openapi.json
This prints OpenAPI 3.1 for the build that printed it;
spec/openapi.json in the repository is the
committed copy. info.version is Zygo’s release. x-zygo-api is the surface
version — the one a client checks. It moves only when an operation is removed
or renamed. Adding a route does not move it, because an older client does not
call a route it has never heard of.
What it covers, and what it does not. It lists every route with its
path parameters, who may call it (x-zygo-who), whether it needs deploy
rights (x-zygo-deploy), the bearer scheme (none for GET /healthz) and
the shape of an error. It does not describe request bodies, query
parameters, headers, or any status but 200. Those are in the tables at
the top of this chapter. A client generated from the document gets one
method per route and no types for what goes in, so write the bodies from
this chapter, or use one of the SDKs.
The document is hand-written, and three tests read the router’s own source
to keep it true: one fails when a route is missing from the document, one
when the document names a route that is gone, and one when a route answered
without a token is not marked so. Each SDK has a test of its own, over every
operation but GET /metrics, which is for a scraper rather than a client. A
route added without a client method is a test failure rather than something
an embedder finds later.
Versioning
GET /version reports:
| Field | Meaning |
|---|---|
version | The Zygo release. |
api | The HTTP surface. Bumped only on an incompatible change to a route, so it stays put across releases that change what happens behind them. This is what a client checks. |
control | The CLI-to-supervisor protocol, which no SDK speaks. Reported because a mismatch there explains an API that is up but answering errors. |
deploy | Whether this caller has deploy rights — the truth about your own token, not just the flag. |
private_net | Whether what this caller deploys may allow private addresses: zygo api --allow-private-net, and deploy rights. |
All three packages are 0.1.5 and follow the repository’s 0.x policy:
the shape may change with a release note. After 1.0 it will not change
without a major version.
Running against a real Zygo
export ZYGO_API_TOKEN=$(head -c 32 /dev/urandom | base64)
zygo up # warm what sandbox.toml declares
zygo api --allow-deploy & # 127.0.0.1:7700
python -c "import zygo_sdk as zygo; print(zygo.connect().functions())"
elixir -e 'Mix.install([{:zygo_sdk, "~> 0.1"}]); IO.inspect(Zygo.functions!(Zygo.connect()))'
For one customer rather than the whole host:
zygo token mint --tenant acme # prints the secret, once
The SDK test suites do not need any of that. All three run against a
stand-in API and check the client: the transport, the error mapping, the
connection pool. A test that needs a real sandbox belongs in the Rust suites,
against a real kernel. The Elixir suite also has one read-only check against
a real API, which runs only when asked: ZYGO_LIVE=1 mix test --only live in
sdk/elixir, against the API that ZYGO_API_URL names.
make test-sdk
Part three: the MCP server
zygo mcp gives an agent host a code interpreter whose boundary is a file
somebody reviewed. It speaks the Model Context Protocol (MCP), the standard
way an AI agent host talks to tool servers, over standard input and output.
That is what Claude Code, Claude Desktop, Cursor and the rest start as a child
process. There is no port, no token and no network: the transport is a pipe
between two processes running as the same user.
{ "mcpServers": { "zygo": { "command": "zygo", "args": ["mcp"] } } }
That is the whole installation. On macOS it is forwarded into the Linux VM like every other sandbox command, and the VM hop is paid once, when the host starts the server, rather than once per tool call.
Two hosts have a command for it instead of a file:
# Claude Code, for this project; add --scope user for every project
claude mcp add zygo -- zygo mcp --mem 512M --timeout 60s
# Codex: in ~/.codex/config.toml
# [mcp_servers.zygo]
# command = "zygo"
# args = ["mcp", "--mem", "512M", "--timeout", "60s"]
The flags after mcp are the ceiling the model works under — the next section
explains why they belong to whoever installs the server.
┌────────────┐ stdin/stdout, JSON-RPC ┌──────────┐ ┌────────────────────────────┐
│ agent host │◀────────────────────────▶│ zygo mcp │─────▶│ run_code: a fresh one-shot │
└────────────┘ └────┬─────┘ │ sandbox for every call │
│ └────────────────────────────┘
│ list_functions, call_function,
│ function_logs
▼
an existing supervisor (warm functions)
The rule that shapes it
A model reads untrusted text — a web page, a file, an error message — and that text can ask it for things. So the tools expose a program and nothing else: no image, no mounts, no network mode, no limits. Those are set once, on the command line, by the person who installed the server.
zygo mcp --mem 512M --timeout 60s --workspace ./agent-scratch
zygo mcp --net egress --allow api.github.com:443
zygo mcp -f ./sandbox.toml # [defaults] becomes the ceiling
A model that needs more than this does not get to ask for it. Somebody
declares a function in sandbox.toml — with its dependencies, its egress
allow list and its secrets — and the model calls that by name. The boundary is
then in a file that was reviewed, which is where it belongs. A test enforces
this: no_tool_can_widen_the_sandbox fails if any tool schema ever grows an
image, mount, network, mem or similar field. Adding one looks harmless
on its own, which is exactly why it is checked.
The flags
| Flag | Default | What it sets |
|---|---|---|
--workspace DIR | a scratch folder, removed on exit | the host folder mounted at /work |
--python-image | python:3.12-slim | the image for language: python |
--node-image | node:22-slim | the image for language: node |
--sh-image | alpine:3 | the image for language: sh |
--mem, --cpu, --pids, --timeout, … | the resolver’s defaults | the limits, as for zygo run |
--net, --allow, and the other sandbox flags | no network | the sandbox, as for zygo run |
-f FILE | — | a spec file; its [defaults] becomes the ceiling |
The tools
| Tool | Parameters | What it does |
|---|---|---|
run_code | language (python, node, sh), code, stdin? | Runs the code in a fresh sandbox, with /work as its folder; returns output and, in words, why it failed. |
list_functions | — | The warm functions and their state. |
call_function | name, event? | Calls one by name with a JSON event (fixed 60 s limit). |
function_logs | name, limit? (1–200), failed? | Its recent log, or only the failures. |
There are no tools for pools, scripts or tenants. call_function runs with no
tenant, as the host’s own call. A limit outside 1–200 is clamped into that
range rather than refused. The server’s instructions tell the model to
prefer a warm function over run_code where one exists: a millisecond against
tens of them, with dependencies already imported.
How run_code runs a program
The code is written to a file, /zygo/main.py, /zygo/main.js or
/zygo/main.sh, in a folder mounted read-only. It is then run with
python3, node or /bin/sh. So the program can be of any length,
tracebacks name a real file, and the code cannot rewrite itself mid-run.
stdin, if given, is fed to the program and then closed. The outer deadline
is the sandbox’s timeout (30 s by default) plus 300 s, which leaves room for
an image pull on the first run; the sandbox’s own timeout is still enforced on
the whole process tree.
/work persists between calls
/work is a writable directory and the program’s working directory. It
persists between calls, so a model can write a file in one call and read it in
the next. Everything else written is thrown away when the call ends. Without
--workspace it is a scratch directory removed when the server exits; naming
one makes it real, and is how an agent is given a project to work on.
Failures in words
When a sandbox is killed, the tool result says why in words: “ran out of memory” and “exceeded its time limit” are different sentences. Both are exit 137, and a model told only “exit 137” cannot know which of its two problems to fix.
What a sandbox gets
Whatever zygo run gives. On the ns backend that is: no capabilities, a
read-only root with pivot_root and a masked /proc, the default seccomp
allowlist of about 215 system calls (190 of them exist on aarch64), Landlock
where the kernel has it, required memory, CPU
and process limits, and no network at all unless the command line said
otherwise. Chapter 23 says where that boundary is weaker
than it looks. run_code is not sandboxed from the model — running the
model’s code is the point. It is sandboxed from the machine.
Concurrency and failures
The server speaks JSON-RPC 2.0, a simple format where each request is a JSON
object with a method name and an id. It knows four methods: initialize,
ping, tools/list and tools/call. Notifications (messages with no id) are
ignored. Each request is handled on a thread of its own, so a run_code that
takes thirty seconds does not block a list_functions behind it. Answers are
written one line at a time, so two cannot mix.
| Problem | Answer |
|---|---|
| A line that is not JSON | JSON-RPC error -32700 (parse error) |
| An unknown method | -32601 |
| Bad parameters for a method | -32602 |
| A tool that ran and failed | a normal result marked isError |
A tool that fails answers with a result marked isError, not a JSON-RPC
error. The difference matters: an error at the protocol layer is handled by
the host and never reaches the model, and the model is the one that could fix
a traceback. The three tools that read warm functions connect to an existing
supervisor and do not start one. There is nothing to read or call unless
somebody has already served something.
Protocol revisions
The server speaks 2025-06-18 (its preferred one), 2025-03-26 and
2024-11-05. It answers initialize with the revision the client asked for
when it is one of those, and with its preferred one otherwise. So the client
decides whether it can live with the answer, rather than the server agreeing
to a dialect it does not know.
Trying it without a host
The transport is a pipe, so a shell is enough:
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
| zygo mcp
Two lines of JSON come back, one per request. Standard output belongs to the protocol, so anything a human should read — including the workspace path — goes to standard error, which is where a host shows a server’s log. Chapter 18 goes further with agents.
What is not built
To say it plainly in one place:
- Streaming, progress, workspaces and per-request tenant limits in
warm-exec pools (a pool with
cmdand no agent). - Per-tenant series in
/metrics, and billing counts forzygo execand MCP requests outside the supervisor log. - Pool, script and tenant tools in MCP.
- A warm function’s code sent by value.
PUT /fn/{name}names itsentry,requirementsand mounts as paths underbase_dir, on the host the API runs on. One warm zygote per script version (chapter 13) is therefore for a caller that shares a disk with Zygo. A caller on another machine sends its code with each request, to a runtime pool. - Request bodies, query parameters and status codes in the OpenAPI document (above).
18. Writing an agent
An agent is the small program inside a warm sandbox that loads a handler
once and gives each request a process of its own. Zygo ships one for Python
and one for Node. This chapter is for people who want to write one for
another language: it explains the wire protocol in plain words, how to test an
agent with zygo agent test, and the mistakes that cost agents their speed.
What an agent does
A warm function keeps a sandbox up and pays for each request with a
fork() — a system call that makes a copy of a running process — rather than
with a new container (chapter 13). The agent is the
part that does the forking. It talks to the supervisor, Zygo’s process on
the host that owns every warm sandbox, over a socket (a two-way channel
between two processes). The protocol is language independent: an agent in
any language that can open a socket and fork can serve warm functions at the
same speed. Anything that speaks it gets resource limits, request deadlines,
idle pausing, metrics, secrets and the vm transport without knowing they
exist.
host │ inside the sandbox
│
┌──────────────┐ socket (fd 3) │ ┌──────────────────────────────┐
│ supervisor │◀────────────────────┼─▶│ agent (the zygote) │
│ │ frames: READY, │ │ runtime started, handler │
│ cgroups, │ EXEC, FORKED, │ │ loaded, never runs a request │
│ deadlines, │ GO, DONE, ... │ └──────┬───────────────────────┘
│ secrets │ │ │ fork, per request
└──────────────┘ │ ┌──────▼───────────────────────┐
│ │ child: runs handler(event) │
│ │ once, sends RESULT, exits │
│ └──────────────────────────────┘
Do you need one?
You need an agent only when starting your runtime is slow enough to be worth
doing once. Without an agent Zygo uses warm-exec: the sandbox is held open
and each request is a fresh process running your cmd, with the event on
standard input and JSON expected on standard output. That works for every
image and every language, and needs no code from you at all. It costs a median
of about 1.4 ms a request, measured on a Lima VM on an Apple M1 Max
(chapter 25). An agent is what you write when starting
your runtime and importing your libraries costs more than that: a Python
interpreter with its imports, a JVM, a Node process with a large dependency
tree.
[fn.parse]
image = "alpine:3"
mounts = ["./bin/parse:/app/parse:ro"]
cmd = ["/app/parse"] # the event on stdin, JSON on stdout
If your language starts in a millisecond, use cmd and stop reading.
How messages travel
Every message is one frame: a 4-byte length, then that many bytes of JSON.
The length is an unsigned 32-bit number in big-endian order (most
significant byte first) — struct.pack(">I", n) in Python,
Buffer.writeUInt32BE in Node. The body is UTF-8 JSON: an object with a
type field that names the message.
┌────────────────────┬──────────────────────────────────────────────┐
│ length: 4 bytes │ body: `length` bytes of UTF-8 JSON │
│ uint32, big-endian │ {"type":"EXEC","id":"01f3","event":{...}} │
└────────────────────┴──────────────────────────────────────────────┘
| Backend | Transport |
|---|---|
ns, gvisor | an AF_UNIX stream socket (a local socket that is a file), mode 0600 |
vm | vsock, a socket between a virtual machine and its host |
A frame may be at most 32 MiB. A larger payload belongs in a file, not on the control socket. This cap protects the supervisor: the agent runs untrusted code, and a frame that claims to be 4 GiB must not make anyone allocate 4 GiB. So check the announced length before allocating. A connection closed between frames is a clean shutdown; one closed in the middle of a frame is an error, so an agent that dies mid-write does not look like an orderly exit.
The messages
There are a handful of messages. The core set is enough for a working agent; the others are optional additions that an agent may ignore.
| Message | Direction | What it is for | Since |
|---|---|---|---|
READY | agent → supervisor | “Warm-up is done.” Sent once, with proto, the agent’s pid, imports_ms, rss_kb and a runtime name. | 1 |
EXEC | supervisor → agent | One request: an id, the event, timeout_ms, and optional env_overrides. | 1 |
FORKED | agent → supervisor | “The child for this request exists, with this pid, and is waiting.” | 1 |
GO | supervisor → agent | “The child is in its cgroup now; it may start.” | 1 |
RESULT | child → agent | The handler’s answer: exit_code, result or error, stdout, stderr, and metrics. | 1 |
DONE | agent → supervisor | The RESULT, passed on without dropping a field. | 1 |
PING / PONG | both ways | “Are you alive?” “Yes.” An agent that stops answering is restarted. | 1 |
SHUTDOWN | supervisor → agent | Finish the requests in flight within grace_ms, then exit. | 1 |
ERROR | either way | A protocol failure, with a code (see Errors). | 1 |
EXEC.script | supervisor → agent | The code to run arrives with the request (runtime pools). | 1.1 |
CANCEL | supervisor → agent | “Somebody asked to stop this request.” | 1.2 |
CHUNK | child → agent → supervisor | A piece of output, sent while the request runs. | 1.3 |
PING with id | agent → supervisor | A heartbeat: “this request is still running.” | 1.4 |
EXEC.workspace | supervisor → agent | The folder with this request’s files. | 1.5 |
READY may also carry child_filter, which says how the agent honours the
strict child filter: seccomp, a name for an equivalent, or none. It is
for diagnostics only. spec/protocol.md is the
full, normative text, with every field.
One request, step by step
The agent says READY. The supervisor sends EXEC. The agent forks and
answers FORKED with the child’s pid. The supervisor moves that pid into a
new cgroup for this request and sends GO. The child runs the handler, sends
RESULT to the agent and exits, and the agent passes it on as DONE.
supervisor agent child
│ │ │
│◀──────── READY ─────────│ │
│ │ │
│───────── EXEC ─────────▶│ │
│ │───────── fork() ─────────▶│
│◀──────── FORKED ────────│ │ (waiting)
│ [move pid to cgroup] │ │
│────────── GO ──────────▶│──────────────────────────▶│
│ │ │ handler(event)
│ │◀──────── RESULT ──────────│
│ │ │ _exit(0)
│◀──────── DONE ──────────│
│ [remove cgroup] │
Why FORKED and GO exist
The FORKED/GO handshake is the part worth understanding. A cgroup is
the kernel’s way to limit and count the memory and CPU of a group of
processes (chapter 3). A new child starts in the agent’s
cgroup. The handshake lets the supervisor move it into the request’s own
cgroup before the request runs. That is what makes a per-request memory
limit possible, and a deadline that kills the whole process tree. So the child
must do nothing before GO: anything it allocated would be billed to the
agent and escape the request’s limits.
Secrets need nothing from you
Secrets are deliberately not in EXEC. The supervisor writes each one as a
file, /run/secrets/<NAME>, from outside the sandbox, between FORKED and
GO, and removes it when the last request in flight finishes. The agent never
sees a value, so it cannot leak one: it is not in EXEC, not in the zygote’s
memory, and not on this connection. An agent must not try to handle secrets.
The files are there before GO and are the child’s to read.
Results, errors and a child that dies
RESULT needs only type, id and exit_code; everything else is optional.
error, with a human-readable traceback, is present when the handler raised,
and result is then meaningless. Metric fields such as peak_rss_kb,
wall_ms and cpu_ms sit at the top level, not nested. If the child dies
without a RESULT — killed for memory, killed at its deadline, or crashed in
a C extension — the agent makes up a DONE with a non-zero exit_code and an
error that describes the death. Silence is never an answer: the supervisor
has a request waiting on it.
{"type":"RESULT","id":"01f3","exit_code":0,"result":{"status":200},
"stdout":"…","stderr":"","peak_rss_kb":41200,"wall_ms":12.3,"cpu_ms":9.1}
Errors
ERROR is a failure of the protocol, not of the handler. A handler that raised
is a DONE with a non-zero exit_code. A child that could not run the
request at all may answer ERROR instead of RESULT, and the agent passes
that on with the request’s id instead of a DONE. Either way there is
exactly one answer per EXEC.
| Code | Meaning |
|---|---|
bad_message | The frame could not be parsed, or is not valid right now. |
unsupported_version | The proto is not supported. |
handler_load | The handler could not be imported (the agent is unusable), or a script’s bytes do not match its digest. |
spawn_failed | fork(), or the spawn fallback, failed. |
timeout | The request ran past timeout_ms. |
overloaded | Too many requests in flight. |
bad_result | The result could not be turned into JSON. |
internal | Anything else; see message. |
Streaming output: CHUNK (1.3)
An EXEC with "stream": true asks to watch the request. The child then sends
CHUNK frames as it writes, each with a stream (stdout, stderr or
progress) and the data. Streaming is asked for per request, not per
function: a chunk per print() is a system call per print(), so only a
caller that wants to watch pays for it. An EXEC that did not ask carries no
stream field at all, and must produce no CHUNK.
supervisor agent child
│──── EXEC{stream} ──────▶│ │
│◀──────── FORKED ────────│ │
│────────── GO ──────────▶│──────────────────────────▶│ handler(event)
│ │◀──────── CHUNK ───────────│ print(…)
│◀──────── CHUNK ─────────│ │
│ │◀──────── RESULT ──────────│
│◀──────── DONE ──────────│
Three rules make streaming honest. The agent passes chunks on without
buffering; one that collected them would deliver the bytes at the same moment
as RESULT. A chunk is not cut at line ends: half a line written before the
handler blocks should arrive. And RESULT still carries the whole of stdout
and stderr, so a caller that streamed and one that did not see the same
text. progress is not a line of stdout: the reference agents give the
handler a progress() call on the event, present whether or not anyone is
listening. There is no sequence number; frames of one request travel in order
on one connection.
Cancelling a request: CANCEL (1.2)
CANCEL says someone asked for a request to stop. It is not what stops
it. The supervisor kills the request by writing cgroup.kill on the
request’s cgroup from outside the sandbox. That takes the child and everything
it started, and does not need the handler to be somewhere a signal helps. The
frame is for the answer: a cancel, a deadline and an out-of-memory kill
all look like signal 9 and exit 137. An agent that gets CANCEL sets
"cancelled": true on that request’s DONE, so the caller can tell its own
cancel from a limit it needs to raise.
supervisor agent child
│───────── EXEC ─────────▶│───────── fork() ─────────▶│
│◀──────── FORKED ────────│ │ (waiting)
│────────── GO ──────────▶│──────────────────────────▶│ handler(event)
│ │ │
│──────── CANCEL ────────▶│ (marks the request) │
│ [write cgroup.kill] │ X
│◀──── DONE{cancelled} ───│
An agent that implements CANCEL must not treat an unknown id as an error:
the request may have finished just before the frame arrived. An agent that
does not know the message answers ERROR / bad_message and carries on; the
kill still lands, and the supervisor fills in cancelled itself. A CANCEL
that arrives before GO is the best case: Zygo kills the child and never
sends GO, so not one line of the handler ran. That is why
DELETE /requests/<id> can report whether the work had started.
Heartbeats: PING with an id (1.4)
While a request runs, the agent may send PING with that request’s id,
without waiting for an answer. Zygo’s timeout can be as long as a day. Without
a heartbeat, a request stuck in the first minute of a six-hour budget would
hold its slot for the rest of it. A supervisor that hears nothing about a
request for its grace period (a minute, in Zygo) kills it as stuck, which
is a different answer from “too slow”. The reference agents send one every two
seconds. A CHUNK counts as a sign of life too. Do not skip the heartbeat
because the child looks idle: an agent cannot tell a child that is computing
from one that is blocked.
{"type":"PING","seq":0,"id":"01f3"}
Scripts that arrive with the request (1.1)
For a runtime pool (chapter 13), the
agent starts with no handler, and each EXEC carries a script. This lets one
warm zygote serve thousands of scripts; a warm zygote costs about 11 MB of
memory, so ten thousand of them would need about 109 GiB.
| Field | Meaning |
|---|---|
path | Where the supervisor put the file in the sandbox before GO: mode 0400, in a read-only folder that cannot be listed. |
source | The script itself, on the wire. |
digest | sha256:… of the contents. The child checks it before loading. |
entry_point | What to call. Default handler. |
At least one of path and source is set, and the supervisor sends path
whenever it can. The reason is memory: a source passes through the agent,
and every later child — perhaps another tenant’s — forks from the agent’s
memory. With path, only the child ever holds the bytes. Paths end in the
digest, so two tenants with identical bytes share one file, and nobody can
swap in different bytes under a digest someone else is running.
EXEC{script: path + digest}
│
▼
child (after GO) ─▶ install the child filter ─▶ read the bytes ─▶ hash them
│
digest matches? ── no ──▶ ERROR / handler_load ◀─┘
│
yes ─▶ load the script ─▶ call entry_point(event)
Rules for scripts
- The child loads the script, after
GO. Never the agent: a zygote that imported a tenant’s script would pass it on to the next request, which may be someone else’s. - After the child filter too (see The
strictchild filter). A script’s top-level code is request code. An agent that loads it first and filters afterwards gives astrictpool nothing. - The digest is not advisory. Hash the bytes you are about to load — not
the file read a second time, which gives a tenant a moment to change it —
and refuse a mismatch with
ERROR/handler_loadbefore any of it runs. The same rule coverssource. - What a script prints while it loads belongs to the request, so it goes
in that request’s
stdout, not the agent’s. - The load is paid per request. That is the trade, and it is why
entrystill exists: warm a hot function with its handler, and let the long tail arrive as scripts.
Implementing script is optional. An agent that ignores the field serves the
handler it was warmed with, and zygo agent test reports it as “functions
only”.
A workspace per request (1.5)
EXEC may carry a workspace: the folder with the files this request’s
caller sent, and where the handler leaves what it wants back. An agent puts it
in ZYGO_WORKSPACE and makes it the child’s working folder before any
handler code. It is not a fixed path, and cannot be. A forked child has no
right to create the mount namespace that would make one path mean a different
folder to each request: measured on Linux 6.12, unshare(CLONE_NEWNS) fails
with EPERM for the child, whatever the seccomp profile. So three weaker
things keep requests apart: the parent folder is mode 0311 and cannot be
listed, the name is 128 random bits, and the folder is removed when the
request ends. An agent must not look around the parent, which holds other
requests’ folders, including other tenants’.
Every request also needs a temporary folder of its own, workspace or not.
/tmp is one tmpfs for the whole sandbox, so without one a file request 1
leaves in /tmp is there for request 2 — for another tenant, in a pool. Both
reference agents make /work/tmp-<128 random bits> (mode 0700) for each
child, point TMPDIR, TMP and TEMP at it, and remove it when the child
is gone. The protocol asks the same of any agent.
Versions
proto goes up only for a breaking change. Adding an optional field or
message is not breaking, and an agent must ignore fields it does not know. So
every addition since the first version still announces proto: 1. A
supervisor that sees an unknown proto refuses the agent rather than
guessing.
| Version | Adds | An agent that does not know it |
|---|---|---|
| 1.1 | EXEC.script | ignores it and serves its own handler |
| 1.2 | CANCEL, DONE.cancelled | answers bad_message; the kill still lands |
| 1.3 | EXEC.stream, CHUNK | answers the same DONE as before |
| 1.4 | PING with id | is bounded by the request’s deadline |
| 1.5 | EXEC.workspace | runs the handler where it was; the request fails to find its files |
The contract in short
- One frame is a 4-byte big-endian length and that many bytes of UTF-8 JSON.
- You get a connected socket at file descriptor 3.
- Send
READYwhen your warm-up is done. AnswerPINGwithPONG. - On
EXEC: create a process, sendFORKEDwith its pid, and let it do nothing untilGOarrives. - Answer every
EXECwith exactly oneDONEor oneERRORwith the sameid. - Concurrency is optional. An agent that serves one request at a time answers
the second with
ERROR/overloaded, which is conforming. - Never run a request in the agent’s own process. Its memory stays as it was just after a clean import, so request n cannot see what request n−1 did.
- Return stdout and stderr in separate fields, beside the exit code and the measurements.
- A frame that is whole but is not a valid message gets
ERROR/bad_message, and the agent carries on. (A length above 32 MiB is different: the stream cannot be recovered, so close the connection.)
The strict child filter
Seccomp is the kernel feature that limits which system calls a process may
make (chapter 4). Under seccomp = "strict" the
supervisor sets ZYGO_CHILD_SECCOMP: base64 of a raw seccomp-bpf program, in
the host’s byte order, that the child installs with
prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog) after
PR_SET_NO_NEW_PRIVS. The child does this after GO and before any handler
code. It removes execve and process creation from the child without taking
them from the agent. A value the agent cannot decode is a start-up ERROR.
There are exactly two conforming answers: install the filter, or fail the
request. Running the request with only the sandbox’s filter is not allowed,
because the function’s author asked for a tighter wall and would not get it.
A language that cannot reach prctl can still conform. The Node agent ships a
forty-line C shared object whose constructor installs the program; without
it, it falls back to Node’s own permission model (no child processes, no
native addons, no WASI) and says which in READY. The sh agent refuses
every request under strict instead. zygo agent test checks this, and the
sh and Node example agents both failed it silently until the check existed.
Checking an agent: zygo agent test
zygo agent test runs a conformance suite against your agent over a real
socket. It starts the agent with the control socket at descriptor 3 — where a
sandboxed agent finds it too — and passes everything after -- to the agent
as its arguments. It runs on the host, not in a sandbox: what is under test is
the conversation, and a sandbox would add failures that are Zygo’s rather than
yours. It exits with 1 if any check failed, and 0 otherwise. With --json
it prints the report as JSON.
zygo agent test BINARY [--script FILE] [--script-spawn FILE] [--pool-script FILE] -- [ARGS…]
# the reference Python agent
zygo agent test python3 -- agents/python/zygo_agent.py --fd 3 \
examples/agents/conformance/handler.py
# the reference Node agent
zygo agent test node -- agents/node/zygo_agent.js \
examples/agents/conformance/handler.js
# the sh one
zygo agent test /bin/sh -- examples/agents/sh/agent.sh examples/agents/sh/handler.sh
| Option | What it adds |
|---|---|
--script FILE | The protocol 1.1 check: a script, in the agent’s own language, sent inside EXEC. |
--script-spawn FILE | A script whose top-level code starts a program. Checks that the child filter is on before the script’s first line. |
--pool-script FILE | The agent holds no handler: send this file as every request’s script (the runtime-pool shape). |
-- ARGS… | Arguments for the agent itself. |
The test handler
The handler you start the agent with must follow a small contract, or there is
nothing to check about the answers.
examples/agents/conformance/ has one for
each language.
- Return the event it was given, unchanged.
- If
event.stdoutis a string, write it to stdout. - If
event.stderris a string, write it to stderr. - If
event.spawnis a string, start a program that prints it. That is what thestrictchild filter takes away, so the suite must be able to try. - If
event.sleep_msis a number, sleep that long, so that the cancel check has a request to arrive during.
What it checks
The suite stops early if READY never comes, because nothing else means
anything without a warmed agent. It also stops at the first missed deadline,
and marks the remaining checks as skipped rather than failed many times over.
| # | Check | Required? |
|---|---|---|
| 1 | The agent announces itself with READY (proto 1, a pid, a runtime name). | yes |
| 2 | PING is answered by PONG with the same seq. | yes |
| 3 | EXEC is answered by FORKED, naming a process that is not the agent. | yes |
| 4 | The child does nothing until GO. | yes |
| 5 | The event reaches the handler and its result comes back. | yes |
| 6 | stdout and stderr come back in separate fields. | yes |
| 7 | Two requests in flight are both answered, each with its own id. | yes |
| 8 | A frame that is not a message gets ERROR, not a crash. | yes |
| 9 | A script in EXEC is loaded by the child (1.1). | needs --script |
| 10 | ZYGO_CHILD_SECCOMP is installed in the child, or the request is refused. A second copy of the agent is started with the variable set, and the handler is asked to spawn. | yes, on Linux (skipped elsewhere, or if the handler cannot start a program even without a filter) |
| 11 | The child filter is installed before the script’s first line (1.1). | needs --script-spawn |
| 12 | A cancelled request comes back as DONE{cancelled} (1.2). | optional |
| 13 | Output arrives in CHUNKs before the DONE (1.3). | optional |
| 14 | A long request is reported alive with PING{id} (1.4). | optional |
| 15 | SHUTDOWN makes the agent exit. | yes |
The first time this suite ran, it found a real bug in the reference Python
agent: a frame that was not valid JSON raised out of the read loop and killed
the agent, taking every request in flight with it. It is an ERROR now.
How the optional checks are judged
An optional feature that is missing is skipped, not failed: an agent that only serves functions is conforming. The last line says “conforms to protocol 1” when nothing failed. A feature that is only half there is reported as partial — “conforms, with gaps named above” — and does not fail the run.
- Scripts (
--script). Pass a file in the agent’s own language: the suite cannot guess it, and Python sent to a Node agent fails in a way that looks like “does not implement 1.1”. The suite sends the script three ways: assource, as apath, and with adigestthat does not match. An agent that runs the third has no defence against a tenant swapping the file, and fails. One that handlessourcebut notpathis partial, becausepathis what the supervisor really sends. - Filter before script (
--script-spawn). The script is run once without the filter, to prove it can start a program, and once under it, to prove it cannot. - Cancel needs no flag. The suite plays the supervisor’s whole part: it
sends
CANCEL, kills the child, and checks the answer isDONEwithcancelled. An agent that answersERROR/bad_messageis reported as not implementing 1.2; theshagent is the worked example. - Stream needs no flag, and it checks order, not content. The handler prints, then sleeps for a second and a half, and the first chunk must arrive while it is still asleep. Otherwise an agent that buffered everything would pass.
- Heartbeat starts a long request and waits for a
PINGwith its id. An agent that sends none is skipped.
Checking a runtime-pool agent
An agent may also start with no handler at all: one warm interpreter per
image and dependency set, with the code arriving in each EXEC. That is a
different path through the agent — the child loads tenant code after the fork
and under the child filter — so passing in one shape says little about the
other. Check both. --pool-script sends the same echo handler as every
request’s script, with a digest, so every check above runs unchanged.
zygo agent test python3 \
--pool-script examples/agents/conformance/handler.py \
--script examples/agents/conformance/script.py -- \
agents/python/zygo_agent.py --fd 3 # note: no handler
zygo agent test node \
--pool-script examples/agents/conformance/handler.js \
--script examples/agents/conformance/script.js -- \
agents/node/zygo_agent.js
make conformance-node sends
conformance/script.ts in the
pool run, because that is where TypeScript is checked end to end. The file
goes up as written, the digest is over those bytes, and it contains an enum
so that a runtime which only blanks out type annotations cannot pass.
Four rules an agent has to keep
Each rule is in the protocol document with its reason. They are here because each has been got wrong at least once, in this repository.
- The child never returns to the parent’s loop. A forked child that
unwinds back into the agent’s
serve()runs the parent’s interpreter teardown, and then there are two agents on one socket. - Nothing runs before
GO. The supervisor has not put the child in a cgroup yet, so anything that runs early has no limits. - A malformed frame is reported, not fatal. The stream is still aligned; killing the agent takes every request in flight with it.
- Every
EXECgets exactly one answer. A request that is refused, overloaded or unreadable still gets a reply, because the supervisor’s only other choice is to wait out the deadline.
Where the milliseconds go
These are recommendations, not requirements, because none can be checked from outside. But they are where an agent’s speed is won or lost.
- Import nothing lazily on the request path. Every module the child uses
must already be loaded in the agent, so the child gets it copy-on-write. In
the reference agent, a deferred
import inspect(~10 ms) andimport random(~2 ms) put the median at 11.8 ms against a 2 ms budget. Moving them into the agent halved it, and the median on Linux is now 1.9 ms. - Freeze the heap before forking. In a runtime that counts references, the
first garbage-collection pass in a child touches every shared object and
copies the page it lives on. CPython’s
gc.freeze()cut per-request copying from 14.96 MB to 0.81 MB. - Reseed the random-number generator in the child, or every request makes the same “random” tokens, temporary names and jitter.
- Exit hard with
_exit(), so that exit handlers and teardown cannot damage state the parent also owns. - Bound captured output — Zygo’s default is 256 KiB per stream — and say when you cut it.
TypeScript, without a build step
A .ts handler and a .ts script both run as they are. There is no compile
step, no bundler, and nothing cached between requests: the types are removed
as the module loads, in the child, after the fork — the same place and moment
a .js script is compiled.
[fn.resize]
image = "node:22-slim"
entry = "./resize.ts" # `runtime` is inferred from the extension
Or, over the API, by uploading the TypeScript itself:
script = client.put_script(open("resize.ts").read())
client.run_script("node-pool", script.sha256, {"url": "..."})
Three things to know about TypeScript
- The digest is over the source as uploaded, not over the JavaScript the
agent makes from it, which would differ between Node versions and break
every request. The check a child makes before loading a script is what makes
/run/script/<digest>safe on a shared user id, and it is unchanged. enum, namespaces and parameter properties work. They are code, not just annotations, so they need Node’stransformmode rather than the defaultstripmode. The agent triesstripfirst and falls back, becausestripkeeps every line and column where the tenant wrote it andtransformdoes not.- There is no file extension on the wire. A script arrives as
/run/script/<digest>or as bytes, so the agent decides by what the source is. Valid JavaScript is valid TypeScript, so it compiles the file as JavaScript first. Only aSyntaxError— raised before any line of the module body runs — sends it back to strip types and try again. A file with no types comes back from the stripper unchanged, so a plain JavaScript syntax error stays a JavaScript error.
This needs Node 22.13 or later, where module.stripTypeScriptTypes arrives.
On older Node the agent uses amaro if the image’s dependency set has it, and
otherwise says so rather than running the file as JavaScript. Amaro is not
bundled: it is a megabyte of WebAssembly, and this agent is loaded into every
Node sandbox Zygo runs.
A handler that computes without yielding
A request that spends three seconds in a tight loop is the test of whether an
agent’s own housekeeping survives. tests/linux/agent_stall.py
measures it. It drives an agent over a socket pair and sends one streaming
request whose script writes, reports progress, then spins without yielding. It
prints when each frame came back. Against both reference agents, with the
handler spinning for three seconds:
| Python | Node | |
|---|---|---|
PONG to a PING sent one second into the spin | 0.5 ms | 0.3 ms |
| the request’s heartbeat (1.4) | on time | on time |
| output written before the spin | passed on at once | passed on at once |
Neither agent goes quiet, and the reason is in the design. The request runs in
a process of its own — a fork in Python, a pooled worker in Node — so the
loop that answers PING and CANCEL holds no tenant code and has nothing to
block it.
Lost output, and why there is no thread pool
What the measurement did find was lost output. A Node handler that wrote a
megabyte and then spun had 70 KB of it passed on, and 978 KB silently
lost: Node queues a write to a pipe in memory, and process.exit throws
away what is still queued. The worker now makes stdout and stderr blocking, as
Python’s are. Every byte is passed on as it is written, and the DONE carries
the last 256 KiB with a note that it was cut. If you write an agent in a
runtime with asynchronous output, this is the part to get right.
A thread pool inside the worker, to run tenant code off the worker’s own
loop, would buy nothing more, so there is none. Measured on node:22, one
worker_threads isolate costs 13 MB of memory and 15–23 ms to start, against
43 MB and about 2 ms for the whole Node agent now. A tenant whose handler must
report progress while it computes can start one itself: the strict filter
refuses clone only without CLONE_THREAD, and the permission fallback
passes --allow-worker for exactly that reason. What would reopen the
question is a measurement showing an agent going quiet without a thread
pool; the one above shows the opposite.
The runtimes that have no agent
Deno and Bun do not have one, and are not waiting for one:
ADR 0003 records why, and what would
change it. Both start in a few milliseconds, so a warm-exec pool serves
them with no protocol at all — cmd = ["deno", "run", "--allow-none"], with
each request’s script as the last argument. That gives up streaming,
progress(), workspaces and per-request tenant limits; see
examples/warm-exec/. The same is true of anything
that starts fast: Go, Rust, C, bash. The runtime name go is accepted in
sandbox.toml, but no Go agent ships, so a Go function uses warm-exec. Write
an agent when there is something slow to keep warm, not because a runtime is
popular.
What there is to read
examples/agents/— the contract in short form, whatzygo agent testchecks, and the things that cost an agent its milliseconds. Read it before writing one.examples/agents/sh/— a complete agent in POSIX sh andjq, about 130 lines, passing the same checks the Python one does. It is the shortest proof that the protocol is language independent. It cannot reachprctl, so understrictit refuses every request.agents/python/— the reference Python agent, and its conformance suite.agents/node/— the reference Node agent. Node has nofork(), so it keeps a pool of pre-loaded workers, each serving one request and exiting, with a replacement started off the request path. Everything else is identical, which is the point of a protocol rather than a library. It also carries the forty-line C helper that lets a Node worker install thestrictchild filter.examples/warm-exec/go/— a Go program as a warm function, where the whole integration is acmd.spec/protocol.md— the full protocol, with every field. Its test fixtures live inspec/fixtures/and are used by both the Rust tests andagents/python/test_zygo_agent.py.
19. Every command
This is the complete list of zygo commands, every flag, and every default.
It is written from the parser (crates/zygo-cli/src/cli.rs) and from the
code behind each command. zygo <command> --help prints the same flags;
this chapter adds what the help cannot: what each command really does, what
it needs, and how it ends.
The commands on one page
ONE-SHOT WARM FUNCTIONS A PROJECT (sandbox.toml)
zygo run zygo serve zygo exec zygo up zygo down
zygo ps zygo stop zygo spec validate | explain
zygo logs zygo shell
zygo top zygo stats
IMAGES ACCESS AND SECRETS PROGRAMS AND AGENTS
zygo pull zygo login zygo api
zygo images zygo token mint | ls | revoke zygo mcp
zygo image rm | prune zygo secrets keygen | set | zygo agent test
ls | rm
THE HOST MEASURING SHELL HELPERS
zygo doctor [--fix] zygo bench warm | cold | zygo completion
zygo backend list | load | all
install (hidden) zygo supervisor run | status | stop
How to read this chapter
Each command has a line showing how to call it, a few sentences on what it
does, a table of its flags, and notes on output and exit codes. [x] means
optional, … means it can repeat, A | B means one of them. Sizes are
written like 256M, durations like 30s; chapter 20
has the exact rules, which are the same for flags and for the file. Exit
codes are collected in chapter 21.
Global flags
These work with every command, before or after the command name.
| Flag | Default | Meaning |
|---|---|---|
--json | off | Print machine-readable JSON instead of tables and lines. Logs become JSON too. |
-v, --verbose | warnings only | More log output; repeat for more (-v info, -vv debug, -vvv trace). ZYGO_LOG overrides it. |
--data-root DIR | $XDG_DATA_HOME/zygo | Use another data folder: images, caches, tokens, secrets. Also read from ZYGO_DATA_HOME. |
-h, --help · -V, --version | Help for any command · the version. |
Flags shared by run, serve and mcp
Two groups of flags describe a sandbox, and they mean the same thing
wherever they appear. Each maps to a sandbox.toml field and wins over it.
| Limit flag | Default | Meaning |
|---|---|---|
--mem SIZE | 256M | Memory limit (at least 8M). |
--cpu CORES | 1.0 | CPU quota in cores, e.g. 0.5. |
--pids N | 64 | Most processes and threads at once; the fork-bomb limit. |
--timeout DURATION | 30s | Wall-clock limit. 0 needs --allow-unlimited. |
--scratch SIZE | smaller of 64M and half of --mem | Size of the writable /tmp. |
--nofile N | 1024 | Most open files. |
| Sandbox flag | Default | Meaning |
|---|---|---|
--isolation ns | gvisor | vm | ns | Where the wall is. |
--seccomp default | strict | permissive | default | The syscall profile (chapter 24). |
--net none | egress | full | host | none | Network mode; bridge is accepted for full. |
--allow RULE … | none | Egress allowlist entry: host:port, *.domain:port, CIDR:port. |
--mount HOST:GUEST[:ro|rw] … | none | Bind mount, read-only unless :rw. |
--env KEY=VALUE … | none | Environment variable. |
--user UID | 1000 | uid inside the sandbox. |
--workdir PATH | /app | Working folder inside the sandbox. |
--allow-host-net | off | Permit --net host, which removes the network boundary. |
--allow-private-net | off | Permit private and link-local ranges. |
--allow-unlimited | off | Permit --timeout 0. |
-f, --file PATH names the spec file on every command that reads one;
without it, sandbox.toml is searched for upwards from the current folder.
zygo run — a one-shot sandbox
zygo run [FLAGS] IMAGE [COMMAND [ARGS…]]
Builds a fresh sandbox from IMAGE, runs COMMAND in it (or the image’s own
entrypoint), and removes everything when it exits. Standard input, output and
the exit code pass straight through, so it behaves like the program itself.
Everything after IMAGE belongs to the command: zygo run alpine sh -c "echo --mem" does not set a memory limit. Chapter 12
teaches it step by step.
| Flag | Default | Meaning |
|---|---|---|
| limit and sandbox flags | see above | |
-f, --file PATH | searched | Use this spec’s [defaults] under the flags; [fn.*] tables are ignored. |
-t, --tty | off | Give the sandbox a terminal of its own. Without it, the sandbox shares yours — colours work, but the program can write to your terminal. |
--requirements FILE | none | A requirements.txt built once into a shared venv at /venv, /venv/bin first on PATH. |
--pull missing | never | always | missing | When to download the image. never refuses a missing image before anything starts (exit 1, outcome phase: plan). |
--outcome FILE | none | Write why the sandbox ended, as JSON (chapter 21). |
--dry-run | off | Print the plan — command, root, mounts, network, seccomp, Landlock, cgroup values — and run nothing. With --json, the full plan including the allowed syscalls. |
-q, --quiet | off | Hide Zygo’s own progress lines on stderr. The program’s output is never hidden. |
Exit: the program’s own code; 137 if the deadline or the memory limit
killed it; 2 for a spec error; 125 if this host cannot run sandboxes; 1
for other Zygo errors. Signals: the first Ctrl-C is passed to the program;
a second one kills it.
zygo serve — warm a function or a pool
zygo serve HANDLER --name NAME [FLAGS]
zygo serve --runtime NAME [--image IMAGE] [--agent AGENT] [FLAGS]
The first form builds a sandbox, loads HANDLER in it, and keeps it warm
under NAME. The second form warms a runtime pool: an interpreter with no
code, for scripts that arrive with each request. It starts the supervisor if
none is running. Chapter 13 explains both.
| Flag | Default | Meaning |
|---|---|---|
HANDLER | — | The handler file (.py, .js, .ts, …). Not with --runtime. |
--name NAME | — | The function’s name. Needs a HANDLER. |
--runtime NAME | — | Serve a pool under this name instead. |
--image IMAGE | the spec’s, or python:3.12-slim / node:22-slim for the runtime | The image to warm from. |
--agent python | node | PATH | — | With --runtime: which agent the pool runs. |
--min-warm N · --max-warm N | 1 · larger of min-warm and 4 | With --runtime: zygotes kept warm, and the most it may grow to. |
--requirements FILE | none | Shared, cached venv, as for run. |
--concurrency N | 4 | Requests at once per zygote. |
--idle-timeout DURATION | 10m | Pause the zygote after this long idle. |
--mode function | stdin | function | How the handler is called. |
--secret NAME … | none | Deliver $NAME from this shell as /run/secrets/NAME, per request. |
-f, --file PATH | searched | Spec to read. |
| limit and sandbox flags | see above |
Output: the function’s runtime, resident memory, import time and warm-up
time. Exit: 0 when warm; 2 for a spec error; 125 if it cannot start.
zygo exec — call a warm function
zygo exec NAME [EVENT]
zygo exec --runtime NAME --script FILE|sha256:… [--entry-point FN] [EVENT]
Sends EVENT (JSON; read from standard input when left out; empty means
null) to a warm function and waits. The result goes to stdout; what the
handler printed goes to stderr. With --runtime, runs a script in a pool
instead; the positional argument is then the event.
| Flag | Default | Meaning |
|---|---|---|
--runtime NAME | — | Run a script in this pool. Needs --script. |
--script FILE | sha256:… | — | A file on this host, or the digest of a script already stored (PUT /scripts). |
--entry-point FN | handler | The function in the script to call. |
--batch | off | Read one JSON event per line from stdin, run them in parallel, print one answer per line in the same order. |
--timeout DURATION | the function’s timeout | Give up after this long. |
Exit: the request’s own code; 137 if its deadline killed it; 75 if
the function is busy; 4 if there is no such function; 125 if no
supervisor is running. With --batch: 0 only if every line succeeded.
zygo ps — list warm sandboxes
zygo ps
Columns: NAME, STATE (starting, warm, paused, cold, failed),
RUNTIME, RSS (resident memory), REQUESTS, FAILURES. It never starts a
supervisor: with none running it says so and exits 0.
zygo logs — a function’s recent log
zygo logs NAME [-f] [-n N] [--failed]
The zygote’s own output, and one entry per request with its exit, duration, stdout and stderr. The supervisor keeps the last 500 entries per function, across replacements and cold spells.
| Flag | Default | Meaning |
|---|---|---|
-f, --follow | off | Keep printing new entries (checks every 500 ms). |
-n, --tail N | 50 | How many recent entries to start with. |
--failed | off | Only requests that failed. |
With --json, one entry per line.
zygo stop — stop sandboxes
zygo stop NAME
zygo stop --all
Stops one function or runtime pool by name, or everything — every function
and every pool — and then the supervisor itself. Give a name or --all, not
both. --all with nothing running exits 0; a named stop with no supervisor
is an error (125), and a name that is neither a function nor a pool is
“no function or runtime pool named …” (exit 4). On a Mac, stop --all
also stops the Linux VM afterwards.
Output: one line per thing stopped — stopped NAME for a function,
stopped runtime.NAME for a pool, and stopped the supervisor when --all
ended it — so you can see which kind went. nothing to stop means just that. A name
that is both a function and a pool stops both: stop means “forget this
name”. To stop only one of the pair, use the API, which keeps them apart:
DELETE /fn/{name} never reaches a pool, and DELETE /runtimes/{name}
never reaches a function.
zygo top — live resource table
zygo top [-i SECONDS] [--once]
ps on a timer, plus rates. Functions: NAME, STATE, RSS, REQ/S,
REQUESTS, FAILURES. Pools: RUNTIME, WARM, PAUSED, ROOM (how many
more zygotes it may start), IN/QUEUE, RSS, REQUESTS, FAILURES. Rates
need two samples, so the first frame shows —.
| Flag | Default | Meaning |
|---|---|---|
-i, --interval SECONDS | 2 | Time between frames. |
--once | off | One frame, then exit — for scripts. --json implies it. |
zygo stats — latency and outcome summary
zygo stats [NAME]
For each function and pool: STATE, REQUESTS, FAILURES, SAMPLES,
p50 (a usual request), p99 (the slowest 1 in 100), MAX, KILLED. Counters run from when the function was
warmed; latencies come from the log ring. p99 is shown only with 100
samples or more. KILLED counts timeouts and other 137 exits (usually
out of memory).
zygo up — start a whole project
zygo up [-f PATH] [--relock]
Serves every [fn.*] in the spec, in the order they are written. A function
whose spec, secret values, handler and requirements are all unchanged is
left warm; a changed one is replaced blue/green: the new one warms, then
takes new requests, while running ones finish on the old. One failure does
not stop the others. It writes zygo.lock (chapter 20).
[runtime.*] pools are not started by up, and the --allow-* flags do not
exist here, on purpose.
| Flag | Default | Meaning |
|---|---|---|
-f, --file PATH | searched | Spec to read. |
--relock | off | Accept an image that moved under an unchanged tag, and rewrite zygo.lock. |
Output: one line per function — ✓ started or replaced, · unchanged,
✗ failed with the reason. Exit: 1 if any function failed.
zygo up, for one changed function
old ──────── serving ──────────────── finishing running requests ──▶ stopped
new └─ warming ─▶ ready ─▶ takes every new request ──────────────▶
zygo down — stop a project
zygo down [-f PATH]
Stops the functions the spec declares, and nothing else. With no supervisor
running, it says so and exits 0.
zygo spec — check and explain the spec
zygo spec [-f PATH] validate
zygo spec [-f PATH] explain [NAME]
validate resolves every function and every pool, so limits, names and
network rules are really checked, and prints the warnings once each. explain NAME prints the final settings for one function or one pool after every layer
is merged — and says, for each value, which layer it came from. A pool is
resolved the way serve --runtime resolves it (strict unless a layer says
otherwise) and shows its min_warm and max_warm too; a name that is both a
function and a pool is explained as the function. explain with no name
shows what zygo run would use. Both need a sandbox.toml.
zygo pull — download an image
zygo pull IMAGE [--platform OS/ARCH[/VARIANT]]
Downloads an image into the local store, with progress lines. On Linux it then builds the Python bytecode layer once, if the image needs one (chapter 15).
| Flag | Default | Meaning |
|---|---|---|
--platform | this host’s | Pull for another platform, e.g. linux/amd64. |
zygo images — list local images
zygo images
Columns: reference, digest (first 12 characters), layers, size, and when it was pulled.
zygo image rm — remove images
zygo image rm IMAGE… (alias: zygo image remove)
Removes an image, the derived +system images built on it, and any layers
only they used. Refused while a warm function runs on it: stop the
function first.
zygo image prune — free disk space
zygo image prune [--dry-run] [--unused-for DURATION] [--blobs]
With no flags, deletes only what nothing can reach any more: layers of removed images, and venvs, flattened roots and derived-layer records whose image is gone, plus leftover temporary roots. It prints what a flag would also collect.
| Flag | Default | Meaning |
|---|---|---|
--dry-run | off | Say what would go, and how much it would free; delete nothing. |
--unused-for DURATION | off | Also delete venvs and flattened roots not used for this long, e.g. 30d. |
--blobs | off | Also delete the compressed copy of every unpacked layer. Roughly halves the store; a lost layer then means a new download. |
zygo login — a private registry
zygo login REGISTRY [-u USER] [--password-stdin]
Asks for the password with echo off, checks it against the registry, and only
then stores it in Zygo’s own auth.json. There is no --password flag:
an argument is visible to every user in ps and lands in shell history.
Zygo reads ~/.docker/config.json too, but never writes to it; where both
have a credential, Zygo’s wins. docker.io, index.docker.io and
registry-1.docker.io are the same registry.
| Flag | Default | Meaning |
|---|---|---|
-u, --username USER | asked | The account name. |
--password-stdin | off | Read the password from standard input, for CI. |
zygo secrets — the per-tenant secret store
zygo secrets keygen
zygo secrets set TENANT NAME [--stdin]
zygo secrets ls TENANT (alias: list)
zygo secrets rm TENANT NAME
An encrypted store of secrets per tenant, used by functions served through
the API. keygen prints a new 32-byte key; put it in ZYGO_SECRETS_KEY (or a
file named by ZYGO_SECRETS_KEY_FILE) before the supervisor starts. A
passphrase is refused, and a lost key means every stored secret is lost.
set reads the value with echo off, or from stdin with --stdin (at most
64 KiB); there is no --value flag. ls prints names, never values; there
is no way to read a value back. Chapter 14
explains how the store and --secret fit together.
zygo token — API tokens
zygo token mint [--tenant TENANT]
zygo token ls (alias: list)
zygo token revoke ID
mint creates a token and prints its secret — zygo_ and 64 hex characters
— on stdout, once; only a hash is stored. Without --tenant it is an
operator token (the host’s); with it, a tenant token (one customer’s,
and the tenant is created if new). Because the secret goes alone to stdout,
ZYGO_API_TOKEN=$(zygo token mint) works. ls lists id, scope and state;
revoke works from the next request, and keeps the record so old logs still
make sense.
zygo api — the HTTP API
zygo api [-f PATH] [--listen ADDR] [--no-auth] [--allow-deploy]
[--allow-private-net] [--openapi]
[--otlp-endpoint URL] [--otlp-interval D] [--usage-webhook URL]
[--usage-interval D]
Runs the HTTP API in the foreground, starting the supervisor if needed. By
default every caller needs a bearer token — ZYGO_API_TOKEN, or one from
zygo token mint. Chapter 17 has every route.
| Flag | Default | Meaning |
|---|---|---|
--listen ADDR | [api] listen, else 127.0.0.1:7700 | IP:PORT, or unix:///path. |
--no-auth | off | No tokens. Refused except on a unix socket or a loopback address. |
--allow-deploy | off | Let the bootstrap token create and destroy sandboxes, not only call them. That makes it a shell as your user; think twice. |
--allow-private-net | off | Let what deploy callers serve name private and link-local addresses in allow. A request body can never set this. Does nothing without --allow-deploy. |
--openapi | off | Print the OpenAPI 3.1 document and exit. |
--otlp-endpoint URL | $OTEL_EXPORTER_OTLP_ENDPOINT | Push metrics to an OpenTelemetry collector (/v1/metrics is added). |
--otlp-interval DURATION | 60s | How often to push. |
--usage-webhook URL | none | POST batches of usage events for billing, at least once. |
--usage-interval DURATION | 10s | How often to deliver them. |
zygo mcp — tools for an AI agent host
zygo mcp [-f PATH] [--workspace DIR] [--python-image I] [--node-image I]
[--sh-image I] [limit and sandbox flags]
Speaks the Model Context Protocol on stdin and stdout, which is how an agent host starts a tool server. The flags are a ceiling the model cannot raise: no tool accepts an image, mount, network or limit.
| Flag | Default | Meaning |
|---|---|---|
--workspace DIR | a temporary folder, removed on exit | Mounted read-write at /work for every run_code. |
--python-image | python:3.12-slim | Image for language: "python". |
--node-image | node:22-slim | Image for language: "node". |
--sh-image | alpine:3 | Image for language: "sh". |
| limit and sandbox flags | see above | Applied to every run_code. |
zygo shell — a debug shell inside a warm sandbox
zygo shell NAME [-- COMMAND…]
Starts a fresh process inside the function’s namespaces: it sees the
sandbox’s files, processes, network and host name. The warm zygote is not
touched and keeps serving. The shell holds no capabilities, but it is on
purpose not under seccomp, Landlock or the function’s cgroup, so the
memory limit cannot kill your debugging session. It runs bash, sh or
busybox sh, or COMMAND if given.
zygo doctor — can this host run sandboxes?
zygo doctor [--fix [--yes]]
Tries each requirement for real rather than reading a setting: kernel
version, user namespaces, /proc, cgroup v2 delegation, whether moving a
process into a cgroup can stall (cgroup moves), whether the systemd unit
holding the sandboxes would be stopped by one of them being OOM-killed
(systemd OOM policy), overlayfs, Landlock, seccomp, subordinate uids, KVM,
the guest kernel, runsc, and the network helpers. Each line says ok, degraded, - (absent) or FAIL, with the
fix under it. On a Mac it checks the host side and then the VM’s own doctor.
Chapter 11 walks through it.
| Flag | Default | Meaning |
|---|---|---|
--fix | off | Apply the fixes that are one command each — the AppArmor user-namespace rule, cgroup delegation, the passt and nftables packages, the AppArmor profile on pasta, and cgroup2’s favordynmods option (on the host, never in a container) — after printing every command and what it costs, and asking. |
--yes | off | With --fix: do not ask. |
Exit: 0 if no check failed.
zygo backend — optional isolation backends
zygo backend list
zygo backend install gvisor | vm
list shows ns, gvisor and vm and whether this host can use each.
install gvisor downloads gVisor’s runsc from Google’s release bucket,
checks its SHA-512 before unpacking, and installs it under the data folder.
install vm downloads nothing: it needs a build with the vm feature and a
guest kernel in place, and says how to get one.
zygo agent test — check an agent against the protocol
zygo agent test BINARY [--script F] [--script-spawn F] [--pool-script F] [-- ARGS…]
Runs your agent and has the conversation the supervisor would have: READY,
PING, a fork per request, the wait for GO, output kept apart, two
requests at once, a bad message, cancel, streaming, heartbeats, shutdown.
Chapter 18 describes each check. Exit 1 if any
failed.
| Flag | Meaning |
|---|---|
--script FILE | A script in the agent’s language, to check that a script sent with a request is loaded in the child. |
--script-spawn FILE | A script whose top level starts a program, to check the child’s filter is on before the script’s first line. |
--pool-script FILE | The agent holds no handler: send this file with every request (the runtime-pool shape). |
-- ARGS… | Arguments for the agent. |
zygo bench — measure this host
zygo bench warm [--n 10000] [--rate R] [--cpu C] [--no-cgroup] [--pool [--scripts 1000]] [-- CMD…]
zygo bench cold [--n 50] [--image python:3.12-slim] [--command "…"]
zygo bench load [--seconds 10] [--concurrency 4] [--cpu C]
zygo bench all [--quick]
warm measures the warm path (or warm-exec with -- CMD, or a pool with
--pool); cold measures zygo run with a pulled image; load measures
throughput with several clients; all runs every published measurement and
compares it with the numbers in chapter 25. Each prints
PASS or FAIL against its budget. all exits 2 — “no verdict” — when the
machine was throttled or busy while it ran, because such a number is not
about Zygo.
zygo completion — shell completion
zygo completion bash | zsh | fish | elvish | powershell
Prints a completion script, generated from the parser itself so it can never drift from the real flags.
zygo completion zsh > "${fpath[1]}/_zygo"
zygo supervisor — the warm pool’s own process (hidden)
zygo supervisor run | status | stop
Not shown in --help, because serve, up, api, token and secrets
start a supervisor when they need one. run keeps it in the foreground,
which is how you see why it will not start. status prints its version, pid
and socket (exit 1 if none). stop stops the supervisor only; it goes
through the socket, or through the pid file and SIGTERM when a version
mismatch means the socket will not listen.
Which commands start or need a supervisor
| Starts one if needed | Needs one running (else exit 125) | Fine without one |
|---|---|---|
serve, up, api, token …, secrets set/ls/rm | exec, logs, top, stats, shell, stop NAME | run, ps, down, stop --all, pull, images, image …, login, doctor, spec …, backend …, completion, secrets keygen |
On a Mac
The zygo on a Mac is a small forwarder. completion, doctor, agent test and api --openapi run on the Mac itself; everything else runs inside
the Linux VM Zygo manages, with the same arguments, folder and streams. Every
path you pass — mounts, -f, --requirements, --outcome, handler files —
must be under your home folder, because only that is shared with the VM.
Chapter 11 explains the VM.
20. sandbox.toml, field by field
One file describes a project: its functions, its runtime pools and its API.
This chapter lists every section and every field the file accepts, with the
default Zygo uses when you leave it out. It is written from the parser and
the resolver (crates/zygo-core/src/spec/), not from memory.
The smallest project
Two files make a whole project: the spec, and one handler.
my-project/
├── sandbox.toml what to run, and how
└── to_lower.py the code
# sandbox.toml
[fn.lower]
entry = "./to_lower.py" # the image is python:3.12-slim, guessed from .py
# to_lower.py
def handler(event): # event: the JSON the caller sent, as a dict
return {"text": event["text"].lower()} # returned as JSON
zygo up # warm it once
zygo exec lower '{"text": "Hello WORLD"}' # prints {"text": "hello world"}
Everything else in this chapter is optional: a field you leave out has a safe default. Chapter 13 explains handlers in full.
The shape of the file
[defaults] # applies under every [fn.*] and every [runtime.*]
mem = "256M"
[fn.resize] # a function: one handler, warmed into one zygote
entry = "./resize.py"
[runtime.py312] # a runtime pool: an interpreter with no code in it
agent = "python"
[api] # where `zygo api` listens, and how it checks callers
listen = "127.0.0.1:7700"
There are exactly four sections. An unknown section or an unknown field is an error, not a warning, so a typo cannot silently do nothing. There is no section for tenants: they are created through the API and stored by Zygo (chapter 17).
How the layers merge
highest ┌───────────────────────────────────────┐
│ a CLI flag, or the body of an API call│ --mem 512M
├───────────────────────────────────────┤
│ [fn.<name>] or [runtime.<name>] │ mem = "384M"
├───────────────────────────────────────┤
│ [defaults] │ mem = "256M"
├───────────────────────────────────────┤
lowest │ Zygo's built-in defaults │ mem = 256M
└───────────────────────────────────────┘
the highest layer that sets a field wins
A list or table — allow, mounts, env, secrets, cmd, system —
replaces the one below it; it is never added to it. So allow in
[fn.fetch] is the whole allowlist for that function, not an addition to one
in [defaults]. zygo spec explain <name> prints the result of the merge,
and zygo spec validate checks the file without running anything.
Where the file is found, and relative paths
-f PATH (or --file PATH) names the file. Without it, Zygo looks for
sandbox.toml in the current folder, then in each parent folder in turn,
and uses the first one it finds. Relative paths in the file — entry,
requirements, and the host side of mounts — are relative to the folder
the file is in, not to where you ran the command. Without a spec file, they
are relative to the current folder.
Names
Function and pool names ([fn.<name>], [runtime.<name>]) are 1 to 64
characters: a letter or digit first, then letters, digits, ., _ or -.
Names in env and secrets follow the shell’s rule: a letter or _ first,
then letters, digits or _. A system package is a Debian package name, at
least two characters of a-z 0-9 + . -, optionally followed by
=version.
Value syntax
| Kind | Written as | Notes |
|---|---|---|
| bytes | "256M", "1.5G", "512K", or a bare integer | A bare integer is bytes. Suffixes B, K/KB/KiB, M/MB/MiB, G/GB/GiB, T/TB/TiB, any case. All are binary: 1M is 1 MiB. |
| duration | "30s", "250ms", "5m", "1h", "2d", or a bare integer | A bare integer is seconds. Units are case-sensitive: ms, s, m, h, d. |
| cpu | 0.5, 2, or "1.5" | Cores. Must be above zero. |
| enums | "ns", "strict", "function" | isolation, seccomp, mode and [api] auth must be lowercase in the file. network and the built-in runtime names accept any case. |
What to run
| Field | Type | Default | What it does |
|---|---|---|---|
image | image reference | python → python:3.12-slim, node → node:22-slim; otherwise required | The OCI image that becomes the root file system: python:3.12-slim, ghcr.io/org/app:1.2, or name@sha256:…. |
entry | path | — | The handler file. Makes this an agent function: loaded once, forked per request. Cannot be used with cmd. Refused in a pool. |
cmd | list of strings | — | The program to run. Without entry or a runtime, makes this a warm-exec function: a fresh process per request, event on stdin, result on stdout. For zygo run, the image’s own entrypoint is used when empty. In a pool, makes a warm-exec pool (the script path is added as the last argument). |
mode | function | stdin | function | How the agent calls your handler: handler(event) and its return value, or the event on stdin and the result from stdout. |
runtime (alias agent) | "python", "node", "go", or { agent = "/path" } | guessed from entry: .py → python; .js .mjs .cjs .ts → node; .go → go | Which agent lives in the sandbox. A table names an agent of your own, by its path inside the sandbox. A built-in runtime needs an entry. |
requirements | path | — | A requirements.txt, built once into a venv inside the image, mounted read-only at /venv, with /venv/bin first on PATH. Shared by everything with the same image and the same file. |
system | list | [] | apt packages ("libwebp7", "libpq5=16.4-1"), installed once into a derived layer of the image. |
nix | list | [] | Accepted by the parser; not built: serving a function that sets it fails with a clear error. |
workdir | path | /app | The working folder inside the sandbox; / if the image has no /app. |
user | uid | 1000 | The uid inside the sandbox, mapped to your own uid on the host. |
Isolation
| Field | Values | Default | What it does |
|---|---|---|---|
isolation | ns | gvisor | vm | ns | Where the wall is (chapter 6). ns is the only backend with warm functions and networking; gvisor (after zygo backend install gvisor) and vm run one-shot sandboxes and refuse the rest with a reason. |
seccomp | default | strict | permissive | default for a function, strict for a pool | The syscall allowlist (seccomp profiles). A pool with anything but strict gets a warning, because its zygotes are shared between tenants. Chapter 23’s T2 — contracted customers’ code — is strict, set here. |
Limits
These are enforced for every request. None can be switched off, except
timeout = 0, which needs --allow-unlimited.
| Field | Type | Default | Enforced by | Rules |
|---|---|---|---|---|
mem | bytes | 256M | memory.max on each request’s own cgroup, and on the warm process’s; memory.high at 90%; no swap | At least 8M. Bounds one request: its whole process tree is killed together, and nothing beside it. A warm function may use mem once for its zygote plus once per concurrent request. |
cpu | cores | 1.0 | cpu.max, over a 100 ms period | Above zero. A request that spins is slowed, not the host. |
pids | integer | 64 | pids.max | Not zero. The fork-bomb limit. |
timeout | duration | 30s | the supervisor, with cgroup.kill | Not zero unless --allow-unlimited. The request exits 137. |
scratch | bytes | the smaller of 64M and half of mem | the size of the /tmp tmpfs; also the largest file a process may write; 10 000 files at most | Must be smaller than mem (it counts against it); a warning above half. |
nofile | integer | 1024 | RLIMIT_NOFILE | |
io_read, io_write | bytes per second | unlimited | io.max on the device behind the root | A warning when neither is set, except for zygo run. |
connections | integer | 256 | the sandbox’s firewall | Not zero. TCP connections a networked function may hold at once. |
bandwidth | bytes per second | unlimited | traffic shaping in the sandbox | Not zero. What the function may send (and receive, where the host has an ifb device). A warning when unset with egress or full, except for zygo run. |
Network
| Field | Type | Default | What it does |
|---|---|---|---|
network | none | egress | full | host | none | none: its own loopback only, on which a function may listen. egress: only what allow names, plus DNS through Zygo’s own resolver. full: the public internet (bridge is accepted as another spelling). host: no network namespace at all; needs --allow-host-net. |
allow | list of rules | [] | The egress allowlist. Only valid with network = "egress"; egress with an empty list allows nothing, and warns. |
The forms an allow rule takes:
| Form | Example | Matches |
|---|---|---|
host:port | api.stripe.com:443 | that name, that port |
host | api.stripe.com | that name, every port |
*.domain:port | *.example.com:443 | every subdomain of example.com, not example.com itself |
CIDR:port | 203.0.113.0/24:5432 | that address range, that port |
| IPv6 | [2001:db8::1]:443, 2001:db8::/32 | brackets when a port follows |
Private and link-local ranges — 10/8, 172.16/12, 192.168/16,
127/8, 169.254/16 (the cloud metadata address), 100.64/10, 0/8,
multicast and reserved (224/4, 240/4), ::1, fe80::/10, fc00::/7,
ff00::/8 — are never reachable in a namespaced mode unless you pass
--allow-private-net, and a rule inside one of them — a CIDR or a single
address — is refused without it. network = "egress" or "full" together
with seccomp = "strict" is refused too: strict takes socket away, so
nothing could be reached.
Files, environment and secrets
| Field | Type | Default | What it does |
|---|---|---|---|
mounts | list of host:guest[:ro|rw] | [] | Bind mounts, read-only unless :rw, and always nosuid,nodev. Both apply to every mount below the host path too. guest must be absolute. A host path cannot contain :. Two mounts cannot share a target. |
env | table | {} | Environment variables for the sandbox. The zygote sees them, so never put a secret here. |
secrets | list of names | [] | Each name is read from the environment of the shell that runs serve/up (or the tenant store), and delivered as the file /run/secrets/<NAME> (mode 0400), only for the length of one request. A name cannot be in both env and secrets. In [runtime.<name>] the values come from the calling tenant’s store instead; see below. |
A mount may not target /, /proc, /sys, /dev, /dev/pts, /dev/shm,
/tmp, /run, /run/script or /work. These are Zygo’s own; a folder
inside one, such as /tmp/cache, is fine.
Keeping it warm
| Field | Type | Default | What it does |
|---|---|---|---|
concurrency | integer | 4 | Requests running at once in one zygote. Up to four times as many more may wait; past that, the answer is busy: HTTP 429, CLI exit 75. |
idle_timeout | duration | 10m | After this long without a request, the zygote is paused: frozen, still in memory, woken in milliseconds by the next request. |
cold_after | duration | 1h | After this long, it is cold: the sandbox is dropped and the next request pays the warm-up again. A warning if shorter than idle_timeout. |
request ──▶ WARM ──(idle_timeout: 10m)──▶ PAUSED ──(cold_after: 1h)──▶ COLD
▲ │ │
└────── next request: ~ms ─────┘ │
└────── next request: pays the warm-up again (~100s of ms) ─┘
[runtime.<name>]: a runtime pool
A pool is an interpreter and its dependencies, warmed with no code in it. Each request brings its own script, which is loaded in the forked child and gone with it. It is how one warm zygote serves thousands of different scripts (chapter 13). Every field above means the same thing here, except these:
| Field | Default | What it does |
|---|---|---|
agent (or runtime) | — | Which agent the zygotes run: "python", "node", or { agent = "/path" }. A pool needs either agent or cmd, not both. |
min_warm | 1 | Zygotes kept warm whatever the load; 0 counts as 1. |
max_warm | the larger of min_warm and 4 | Zygotes the pool may grow to, one per second while every zygote is full. Cannot be below min_warm. |
entry | refused | Anything warmed into a shared zygote would be forked into every tenant’s request. |
seccomp | strict | Unless a layer sets it. |
secrets | [] | Names a request may receive, never values: each request gets the calling tenant’s values from the tenant store, as /run/secrets/<NAME>, and has its zygote to itself while they exist. A tenant without one of the names is refused before anything runs; a pool naming secrets on a host with no store key is refused at serve. Never read from a shell. Chapter 14. |
min_warm and max_warm are refused in [fn.*]. A pool runs
concurrency × max_warm requests at once, and up to four times as many more
may wait; past that it answers busy. zygo up starts
only [fn.*]; a pool is started by zygo serve --runtime <name> or
POST /runtimes, and stopped by zygo stop <name> or
DELETE /runtimes/{name}.
[api]
| Field | Values | Default | What it does |
|---|---|---|---|
listen | IP:PORT or unix:///path | 127.0.0.1:7700 | Where zygo api listens. An IP address, not a host name: localhost:7700 is refused. --listen overrides it. |
auth | bearer | none | bearer | bearer needs ZYGO_API_TOKEN set, or tokens minted with zygo token. none is only accepted on a unix socket or a loopback address. --no-auth overrides it. |
Flags that loosen, and zygo up
Three things are refused unless a flag says so: network = "host"
(--allow-host-net), a private range in allow (--allow-private-net), and
timeout = 0 (--allow-unlimited). The flags exist on zygo run,
zygo serve and zygo mcp; zygo api has --allow-private-net for what its
deploy callers serve. zygo up has none of them, on purpose: a
spec that needs one has to be served deliberately, one function at a time,
with the flag typed by a person.
Which fields have a flag
On run and serve | Only on run | Only on serve | No flag: file or API only |
|---|---|---|---|
--mem --cpu --pids --timeout --scratch --nofile --isolation --seccomp --net --allow --mount --env --user --workdir --requirements | image (positional), command → cmd | handler → entry, --image, --concurrency, --idle-timeout, --mode, --agent, --secret, --min-warm, --max-warm | system, nix, io_read, io_write, connections, bandwidth, cold_after, cmd for a served function |
zygo.lock
zygo up writes zygo.lock beside the spec, and it is meant to be
committed. It records what the spec’s names pointed to on the machine that
ran up: the image digest each function resolved to, the versions apt chose
for its system packages, and the hash of its requirements file. It never
changes what runs; it only refuses to let it change silently. It is
saved only when something in it changed.
version = 1
[fn.api]
image = "python:3.12-slim"
digest = "sha256:1f2e…" # the multi-platform index: the same image everywhere
system = ["libpq5=16.4-1"] # what apt really installed
[fn.api.requirements]
path = "requirements.txt"
sha256 = "9ab3…"
digest is the multi-platform index’s digest when the registry has one, so
it means the same image on every CPU type. For an image built for one
platform only, it is that manifest’s digest, and a platform field says
which.
| Situation | What zygo up does |
|---|---|
| No lock file | Writes one. |
| The spec changed: another image, another package list, an edited requirements file | Rewrites that entry, silently — you asked for the change. |
| The spec is the same, but the image behind the tag moved | Refuses, printing both digests. zygo up --relock accepts it. |
| The same packages resolved to other versions | Records them, with a warning. apt does not keep old versions, so refusing would break every new host. |
| A function is gone from the spec | Drops its entry. |
| The file has a wrong version or cannot be read | An error that tells you to delete it. |
Not built yet: pinning pip’s full dependency tree (only the requirements
file’s hash is kept), pulling the locked digest, and a --frozen mode for CI.
sandbox.toml ──▶ zygo up ──▶ resolves tags and packages ──▶ zygo.lock (commit it)
│
next `zygo up` ────────────┴──▶ same spec, different image? ──▶ refuse
└─ --relock ─▶ accept
A complete example
[defaults]
image = "python:3.12-slim"
mem = "256M"
cpu = 0.5
timeout = "30s"
[fn.resize] # agent function, with dependencies
entry = "./resize.py"
requirements = "./requirements.txt"
system = ["libwebp7"]
mem = "512M"
mounts = ["./cache:/cache:rw"]
concurrency = 8
[fn.parse] # warm-exec: a static binary, any language
image = "alpine:3"
mounts = ["./bin/parse:/app/parse:ro"]
cmd = ["/app/parse"]
mem = "64M"
[fn.fetch] # networked, with a secret
entry = "./fetch.py"
network = "egress"
allow = ["api.stripe.com:443", "*.example.com:443"]
connections = 32
bandwidth = "2M"
secrets = ["STRIPE_KEY"]
timeout = "10s"
[runtime.py312] # a pool for scripts that arrive per request
agent = "python"
requirements = "./pool-requirements.txt"
min_warm = 2
max_warm = 8
[api]
listen = "unix:///run/user/1000/zygo-api.sock"
auth = "bearer"
The files beside it
The example above names some files. Here they are, so you can see what a real project looks like on disk.
my-project/
├── sandbox.toml
├── resize.py [fn.resize] makes a small picture
├── requirements.txt [fn.resize] its Python packages
├── cache/ [fn.resize] a folder it may write to
├── fetch.py [fn.fetch] calls an API with a secret
├── bin/parse [fn.parse] a program you compiled (Go, C, …)
└── pool-requirements.txt [runtime.py312] packages for the pool
resize.py gets a picture as base64 text, makes it at most 200 pixels
wide, and sends it back as WebP. PIL comes from requirements.txt; the
libwebp7 system package lets it write WebP.
# resize.py
import base64, io
from PIL import Image # installed from requirements.txt
def handler(event):
picture = Image.open(io.BytesIO(base64.b64decode(event["image"])))
picture.thumbnail((200, 200)) # at most 200 × 200, same shape
out = io.BytesIO()
picture.save(out, format="WEBP")
return {"image": base64.b64encode(out.getvalue()).decode()}
# requirements.txt
Pillow
fetch.py reads its secret from a file — never from the environment — and
calls the one host its allow list opens.
# fetch.py
import json, urllib.request
def handler(event):
key = open("/run/secrets/STRIPE_KEY").read().strip() # there for this request only
request = urllib.request.Request(
"https://api.stripe.com/v1/balance",
headers={"Authorization": f"Bearer {key}"},
)
with urllib.request.urlopen(request, timeout=5) as answer:
return json.load(answer)
bin/parse is any program that reads one JSON event on standard input and writes one JSON answer on standard output (chapter 6 shows how to build one in Go or C). The pool holds no file of yours: each request brings its script with it.
# word_count.py — sent with the request, not named in sandbox.toml
def handler(event):
return {"words": len(event["text"].split())}
export STRIPE_KEY=sk_test_… # secrets come from your shell
zygo up # warms resize, parse and fetch
zygo exec fetch '{}'
zygo serve --runtime py312 # pools are started by name
zygo exec --runtime py312 --script word_count.py '{"text": "one two three"}'
21. Environment, files and exit codes
Everything Zygo reads from its environment, everything it writes to disk, and every way it can end. Written from the code; if you script around Zygo, this is the chapter to keep open.
Environment variables Zygo reads
| Variable | Read by | Meaning | Default |
|---|---|---|---|
ZYGO_DATA_HOME | CLI, supervisor | The data folder (same as --data-root). | $XDG_DATA_HOME/zygo, else ~/.local/share/zygo |
ZYGO_RUNTIME_DIR | CLI, supervisor | The runtime folder: sockets and pid files. | $XDG_RUNTIME_DIR/zygo, else /tmp/zygo-<uid> |
ZYGO_LOG | CLI | Log filter, e.g. debug or zygo=trace. Overrides -v. Logs go to stderr. | warn |
NO_COLOR | CLI | Any value turns colour off. Colour is also off when output is not a terminal. | |
ZYGO_API_TOKEN | zygo api, SDKs | The bootstrap bearer token. Never a flag, because flags show in ps. It is removed from the environment of sandboxes the API starts. | |
ZYGO_API_URL | SDKs | Where the API is: unix:///path, http://host:port or host:port. | http://127.0.0.1:7700 |
ZYGO_SECRETS_KEY | supervisor | The 32-byte key of the secret store, as 64 hex characters or base64. | none: secret routes refused |
ZYGO_SECRETS_KEY_FILE | supervisor | A file holding that key. Setting both is an error. | |
ZYGO_ALLOW_SHARED_UID | supervisor | 1 lets tenants be registered on a host whose user has no subordinate uid range, where every tenant’s sandbox runs as the same host uid. Without it, POST /tenants is refused there (chapter 23). | unset: refused |
ZYGO_BYTECODE | image store | 0 turns off the Python bytecode layer. | on |
OTEL_EXPORTER_OTLP_ENDPOINT | zygo api | Same as --otlp-endpoint. | |
OTEL_EXPORTER_OTLP_HEADERS | zygo api | Extra headers for OTLP, as key=value,key=value. | |
DOCKER_CONFIG | pull, login | Where Docker’s config.json is, for registry credentials. | ~/.docker |
ZYGO_KRUN_CONSOLE | vm backend | Write the guest’s console to this file, for debugging. | |
ZYGO_IN_SCOPE | CLI | Set by Zygo itself when it re-runs inside a systemd scope, so it does not do it twice. Do not set it by hand. |
Only on a Mac
| Variable | Meaning |
|---|---|
ZYGO_LINUX_BIN | The Linux build of Zygo to copy into the VM, before the ones Zygo looks for itself. |
ZYGO_LIMA_TEMPLATE | The Lima template to create the VM from. |
LIMA_HOME | Where Lima keeps its VMs (~/.lima). |
Every ZYGO_* variable in your shell is passed into the VM; so are the
variables named by --secret and by secrets = [...] in the spec. Nothing
else from your shell crosses.
Environment a sandbox receives
| Variable | Who sets it | Meaning |
|---|---|---|
ZYGO_FUNCTION | Zygo | The function’s name. (ZYGO_TENANT holds the same value; the name is historical.) |
HOME | Zygo | /tmp, unless the image or env sets one — so programs that write to ~ work on a read-only root. |
ZYGO_REQUEST_ID | the agent, per request | The request’s id. |
ZYGO_DEADLINE_MS | the agent, per request | The request’s time budget in milliseconds (its timeout); 0 means none. |
TMPDIR, TMP, TEMP | the agent, per request | The request’s own temporary folder: the workspace if one was sent, otherwise /work/tmp-<random>. Removed when the request ends. |
ZYGO_AGENT_TMP_PARENT | you, for an agent’s tests | Where the reference agents make those folders instead of /work. Nothing in Zygo sets it. |
ZYGO_WORKSPACE | the agent, per request | The request’s workspace folder, when one was sent; the handler starts in it. |
ZYGO_CHILD_SECCOMP | Zygo, under seccomp = "strict" | The filter each forked child installs after GO, as base64 of a raw seccomp program. For the agent; a handler never needs it (chapter 24). |
ZYGO_CHILD_SECCOMP_HELPER | you, for the Node agent | The path of the small shared object that lets a Node worker install that filter. Without it, the agent looks for zygo_child_seccomp.so beside itself, then falls back to Node’s permission model (chapter 18). |
your env | you | Everything in env / --env. |
Secrets are not environment variables: they are files in
/run/secrets/.
Files Zygo writes
~/.local/share/zygo/ the data folder (ZYGO_DATA_HOME)
├── images/
│ ├── blobs/sha256/… compressed layers, as downloaded
│ ├── layers/<digest>/ unpacked layers, shared by every sandbox
│ └── index.json image name → manifest
├── cache/
│ ├── venvs/<key>/ one venv per (image, requirements)
│ ├── flat/<digest>/ flattened roots, where overlayfs can't be used
│ ├── system/ records of derived apt layers
│ └── bytecode-failed/ bytecode builds that failed, not retried
├── scripts/ blobs/ deps/ what the API stores: scripts, tars, dependency sets
├── tenants/ one JSON file per tenant
├── secrets/<tenant>.json encrypted (ChaCha20-Poly1305); names only readable
├── tokens.json token hashes, never secrets (mode 0600)
├── auth.json `zygo login` credentials (mode 0600)
├── agents/ the Python and Node agents, kept up to date
├── backends/ gvisor/runsc · krun/Image (the vm guest kernel)
└── tmp/ locks, and sandbox roots while they exist
$XDG_RUNTIME_DIR/zygo/ the runtime folder (ZYGO_RUNTIME_DIR), mode 0700
├── supervisor.sock how the CLI talks to the supervisor (mode 0600)
├── supervisor.pid
├── host-report.json a short-lived cache of the host checks
└── tenants/<name>/agent.sock … one socket per warm sandbox
Beside your project, zygo up writes zygo.lock, which you should commit.
zygo doctor --fix may write ~/.config/systemd/user/user@.service.d/delegate.conf,
/etc/sysctl.d/60-zygo-userns.conf, /etc/apparmor.d/zygo (the profile that
lets this binary use user namespaces) and
/etc/systemd/system/zygo-cgroup-favordynmods.service, and prints each one
before it does. Putting pasta’s profile in complain mode runs aa-complain,
which edits /etc/apparmor.d/usr.bin.passt or leaves a marker in
/etc/apparmor.d/force-complain/. On a
Mac, the VM lives in ~/.lima/zygo/. There is no Zygo config file: the spec
and the lock are the only configuration.
Exit codes
0 ─────── success
1 ─────── a Zygo error, a failed check, a failed function in `up`, a failed batch line
2 ─────── the spec is wrong · `bench all`: no verdict, the host was busy
4 ─────── `exec`: no such function
75 ─────── `exec`: busy — every slot and the queue are full; retry later
111 ─────── (Mac) the VM could not be reached after three tries
125 ─────── this host cannot run the sandbox, or no supervisor is running
137 ─────── the program was killed: its deadline, or its memory limit
1–255 ───── `run` and `exec`: otherwise, the program's own exit code
| Command | Exit |
|---|---|
run | The program’s code. 137 for a deadline or memory kill (use --outcome to tell which). 2 spec error, 125 host cannot run it, 1 other errors, including --pull never with no image. |
exec | The request’s code; 137 deadline; 75 busy; 4 unknown function; 125 no supervisor. --batch: 0 only if every line succeeded. |
up | 1 if any function failed to start. |
doctor | 0 if no check says FAIL. With --fix: 1 if you declined or a command failed. |
bench | 0 within budget, 1 a budget missed, 2 (all only) no verdict. |
agent test | 1 if any check failed. |
supervisor status · stop | 1 if none is running · nothing to stop. |
shell | The shell’s own code (130 if a signal ended it). |
| On a Mac | The Linux command’s code; 128 + N if a signal ended it; 125 if the VM cannot be set up; 111 if it cannot be reached. |
The outcome file
zygo run --outcome FILE writes, when the sandbox ends, why it ended. The
exit code cannot say this alone: a deadline kill and a memory kill are both
137, and a judge or a CI step needs to know which. The file is written
atomically — a temporary file, then a rename — so a reader never sees half of
it. If Zygo fails before the program starts, the file is still written, with
started: false and the step that failed.
{"exit_code": 137, "timed_out": false, "oom_killed": true, "peak_rss_kb": 65780,
"wall_ms": 412.7, "plan_ms": 3.1, "start_ms": 9.4, "started": true, "phase": "run"}
| Field | Meaning |
|---|---|
exit_code | The same number zygo run exits with. |
timed_out | The deadline killed it. |
oom_killed | The memory limit killed it. |
peak_rss_kb | The most memory the sandbox used, in KiB. |
wall_ms | How long the program ran. |
plan_ms · start_ms | Time spent planning (spec, pull, venv, network, root) · starting (up to execve). |
started | Whether the program ever started. |
phase | Where it ended: plan, start, or run. |
Ports and sockets
Zygo opens no port unless you run zygo api, which listens on
127.0.0.1:7700 by default. The supervisor listens only on its unix socket,
which only your user can open. No sandbox mode accepts connections from
outside.
22. Troubleshooting
This chapter is a list of things that go wrong, what each one looks like, and
how to fix it. The error texts are kept word for word, so you can search this
page for the line you see on your screen. Start with zygo doctor; most of
what follows is doctor in longer form.
Start with zygo doctor
zygo doctor
zygo doctor checks this host for everything a sandbox needs. It does not just
read a setting: it tries each thing, such as making a mount inside a user
namespace, because a setting can say yes while the kernel says no. For anything
missing it prints one line with the fix. Some fixes need root, and for those
zygo doctor --fix can apply them for you: it prints every command and what it
costs, and asks before it runs anything.
┌──────────────┐ all ok ┌──────────────────────────┐
│ zygo doctor │ ─────────────▶ │ the host is fine: look │
└──────┬───────┘ │ at your spec or program │
│ a line says └──────────────────────────┘
│ FAIL / degraded
▼
┌──────────────────────────┐ ┌──────────────────────────┐
│ read its remedy │ │ zygo doctor --fix │
│ (this chapter has the │ ─▶ │ shows each command and │
│ longer story) │ │ its cost, asks, applies │
└──────────────────────────┘ └────────────┬─────────────┘
▲ │ or fix it by hand
│ ▼
│ ┌──────────────────────────┐
└─────────────────────── │ run zygo doctor again │
until all ok └──────────────────────────┘
How to read an error
A Zygo error names what failed, and usually says why and what to do. The
first question is always who failed: the host, Zygo, or your program. The
exit status answers that most of the time (see the table below). A second
question is when it failed: before the sandbox started, or while your
program ran. --outcome answers that one (below).
If the error text is not clear, run the same command again with
ZYGO_LOG=debug, which turns on Zygo’s own tracing.
error on screen
│
├─ exit 125 ───────────▶ the HOST cannot do it → zygo doctor
├─ exit 2 ─────────────▶ your SPEC or flags are wrong → the message names the field
├─ exit 1 ─────────────▶ your INPUT or PROGRAM failed → read its stderr
├─ exit 137 ───────────▶ KILLED: time or memory → --outcome says which
├─ exit 75 / 4 ────────▶ zygo exec: busy / no such function
└─ exit 111 (macOS) ───▶ the Linux VM was not reached → retry, see below
Exit statuses that are Zygo’s
Every other status is your program’s own: it ran, and that is what it said.
| status | meaning | where to look |
|---|---|---|
| 1 | the input is wrong, for example a dependency build failed | the message; A dependency build failed |
| 2 | the spec or the flags are wrong | the message names the field |
| 4 | zygo exec: no such function | Exit 4 |
| 75 | zygo exec: the function is at its concurrency limit; retry | Busy |
| 111 | macOS only: the Linux VM could not be reached | Session open refused by peer |
| 125 | this host cannot run sandboxes, or there is no supervisor to talk to | exit 125 |
| 137 | Zygo or the kernel killed it: the deadline or the memory limit | Exit 137 |
Host setup
These errors mean the machine itself is not ready. They come before your program runs, so your code is not the problem.
“this host cannot run sandboxes” (exit 125)
zygo doctor says which precondition is missing. The common ones are:
- unprivileged user namespaces are turned off completely
(
kernel.unprivileged_userns_clone=0on Debian and its relatives). A user namespace is the kernel feature that lets a normal user be “root” inside a sandbox and nobody outside it; - the kernel is older than 5.3.
Exit 125 also comes from a command that needs a supervisor when none is running; see “no supervisor running”.
“the program does not exist inside the image”, and it plainly does
Three causes, from most to least likely:
- It is on your host, not in the image. Everything after the image name
runs inside the sandbox. Mount the file in and name its path there:
zygo run --mount ./hello.py:/hello.py:ro python:3.12-slim python3 /hello.py. - It is a dynamically linked binary and the image has the wrong libc. A
dynamically linked program needs a loader from the C library to start. The
kernel reports a missing loader as a missing program, so a glibc binary in
alpine:3fails with this exact message. Use an image from the same family as the binary, or a static binary. - The path is relative and the working directory is not what you
thought.
workdirdefaults to/app, and falls back to/when the image does not have it.zygo run --workdir /where …sets it.
A dependency build failed (exit 1)
pip could not resolve a requirement, or apt could not find a package. The
message carries the last forty lines of the build’s own output, and the
reason is there.
The exit code is 1, not 125. The host is fine and the input is wrong, so a CI job that retries on another machine would fail there too. 125 is kept for a build that could not start.
“this host has no subordinate uid range for the user running Zygo”
POST /tenants (or client.create_tenant) was refused. Without a range in
/etc/subuid and /etc/subgid, every sandbox maps to your one host uid, so
two tenants’ sandboxes are the same user to the kernel and the wall between
their files is only what the mounts and Landlock add. zygo doctor reports
it as subuid/subgid … degraded and prints the fix, which is uidmap and a
line per user:
sudo apt install uidmap
sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 $USER
zygo supervisor stop # the next command starts a new one
To run multi-tenant without that anyway — a test host, say — start the
supervisor with ZYGO_ALLOW_SHARED_UID=1.
AppArmor and user namespaces
AppArmor is a set of rules, loaded by the system’s administrator, that the kernel checks for every process (see chapter 4). Ubuntu uses it to limit what a normal user may do inside a user namespace, and that gets in a sandbox’s way.
“applying a bind mount from the spec failed: No such file or directory”
On Ubuntu or Debian, when the path clearly exists, the cause is
kernel.apparmor_restrict_unprivileged_userns=1. It lets a normal process
create a user namespace, then refuses the first mount inside it. That mount is
the first thing every sandbox does.
zygo doctor finds this by trying that mount, and prints the fix.
zygo doctor --fix applies it: it prints every command and what it costs, and
asks first. Where AppArmor 4 and apparmor_parser are installed — Ubuntu
24.04 has both — the fix is an AppArmor profile for the zygo binary,
written to /etc/apparmor.d/zygo and loaded. It lets that one program use
user namespaces and leaves the restriction on for everything else. It is
attached by path, so a binary you move or install elsewhere needs
--fix again. packaging/apparmor/zygo is
the same profile for /usr/local/bin/zygo, for an image or a package to ship.
Where a profile cannot be loaded, --fix falls back to the sysctl, and writes
/etc/sysctl.d/60-zygo-userns.conf so it survives a reboot. Read
the threat model
first: that one turns a protection off for every process on the machine,
and --fix says so in those words above the question.
On a Mac, when the path is outside your home directory, the cause is
different. The Linux VM mounts $HOME and nothing else, so a path elsewhere
does not exist inside it. System temporary folders are the usual problem,
because macOS puts them under /var/folders. Move the folder under $HOME.
The shim (the small zygo program on the Mac that forwards commands into the
VM) refuses every such path before forwarding, and names it: a mount, a spec
or requirements file, a handler, or an --outcome file. An output file it let
through would be written inside the VM, where you would never find it.
Cgroups
A cgroup is a kernel group of processes with limits on memory, CPU and process count (see chapter 3). Zygo must be allowed to make its own cgroups, which is called delegation.
“no cgroup controllers” or “there is no memory.max here”
Your shell is in a cgroup that cannot delegate. On a systemd machine, an ssh
login sits in a session-N.scope that systemd owns, and a normal process may
not create a cgroup inside it.
Zygo re-runs itself inside a new, short-lived systemd scope when it sees
this, so the problem usually fixes itself. When a supervisor is running,
zygo run hands the sandbox to it and never needs a scope at all. When
neither works, try this:
systemd-run --user --scope -p Delegate=yes -- zygo run alpine:3 /bin/true
If that works and a plain zygo run does not, your systemd-run is refusing
the delegation. loginctl enable-linger $USER is often the missing piece.
“A process of this unit has been killed by the OOM killer”, and the API is gone
zygo api was running under systemd. One request went over its mem limit,
the kernel killed it inside its own cgroup — which is correct — and then
every later request failed with connection refused. The journal for the
unit says:
n8n-zygo-api.service: A process of this unit has been killed by the OOM killer.
n8n-zygo-api.service: Failed with result 'oom-kill'.
Systemd’s default OOMPolicy=stop stops a unit when any process in its
cgroup is OOM-killed, and every sandbox’s cgroup is inside the unit’s. So the
one request took the API, the supervisor and every pool down with it. The
fix is one line in the unit, OOMPolicy=continue, then daemon-reload and a
restart; for a transient unit, systemd-run -p OOMPolicy=continue ….
Chapter 16 has the whole
unit file. zygo doctor reports the unit’s setting:
systemd OOM policy n8n-zygo-api.service: OOMPolicy=stop: one sandbox over its memory limit stops this unit, the supervisor and every pool with it degraded
and zygo api prints the same warning when it starts inside such a unit.
The same applies to any unit that ends up holding the supervisor, including
one running zygo run for a long-lived sandbox.
Networking
A sandbox with a network uses pasta, a program that moves packets between the
sandbox and the host as your own user, and nft, which sets up the firewall
inside the sandbox (see chapter 4). Most
network errors are one of these two tools being missing or blocked.
┌ sandbox ───────────────────────┐
│ program ──▶ tap0 ──▶ nftables │ needs /dev/net/tun, and nft installed
└──────────────────────┬─────────┘
│
┌─────────▼─────────┐
│ pasta (as you) │ needs to be on PATH, and not blocked
└─────────┬─────────┘ by the passt AppArmor profile
▼
host's normal sockets
“pasta could not configure the sandbox’s network: Couldn’t open user namespace … Permission denied”
An AppArmor profile is holding pasta back and refusing it the sandbox’s user
namespace. This is the distribution’s policy. It has nothing to do with
/dev/net/tun, which is usually there and working.
sudo aa-status | grep -i passt
sudo aa-complain /usr/bin/pasta # or: zygo doctor --fix
zygo doctor --fix offers this one too, when it finds the profile loaded and
enforcing. It uses aa-complain rather than unloading the profile: the profile
stays loaded and keeps logging what it would have refused. sudo aa-enforce /usr/bin/pasta puts it back.
Or use network = "none", the default, which needs no pasta at all.
“Couldn’t open PID file … Permission denied”
The same distribution policy, seen from the other side. Ubuntu’s passt
AppArmor profile attaches by path to /usr/bin/passt, and to pasta, which is
a link to it. It lets the program write files only where it expects, and Zygo’s
pid file is in Zygo’s data folder, so it is refused. The profile is enforced by
the host’s kernel, so this happens inside a container too, even one started
with --security-opt apparmor=unconfined. dmesg shows
apparmor="DENIED" operation="mknod" profile="passt".
sudo aa-complain passt # on the host
cp -L /usr/bin/pasta /usr/local/bin/pasta # in an image: a path the profile does not name
The Zygo container image already does the second. Remove the /usr/bin/pasta
link afterwards, so PATH cannot find the blocked one first.
“Failed to set up tap device in namespace”, or “did not finish configuring … within 10 s”
There is no /dev/net/tun, the device a sandbox’s network card is made from.
Container runtimes allow the device but do not create the file for it, so this
is the first thing a networked sandbox in a container says. zygo doctor
reports it on the egress line. A run with a network now refuses early, rather
than asking pasta: passt 2025_01 prints the line above and then never exits.
docker run --device /dev/net/tun … # Docker
sudo modprobe tun # a host without the module
In Kubernetes, mount the node’s /dev/net/tun as a hostPath volume of type
CharDevice. runc and crun allow the device by default.
“pasta is not on PATH”
sudo apt install passt nftables # Debian, Ubuntu
sudo dnf install passt nftables # Fedora
When these are missing, a networked sandbox does not start, rather than starting with no firewall. That is on purpose.
A name inside the sandbox does not resolve
Under network = "egress", the sandbox uses Zygo’s own name resolver, and a
name that the allow list does not cover does not resolve. That is the
allowlist doing its job. Add the name:
allow = ["api.example.com:443", "*.cdn.example.com:443"]
A private address is refused even with network = "full"
On purpose. Private and link-local address ranges — your host, its neighbours
on the network, and 169.254.169.254 — stay refused in every namespaced mode
unless you pass --allow-private-net. 169.254.169.254 is the cloud metadata
address, and it is the first thing a compromised handler tries.
When a request fails
These are problems with one run or one request, after the sandbox started (or while it was trying to).
“[Errno 1] Operation not permitted”, naming a file that exists and is readable
The file is fine. A syscall was refused. A syscall is a request from a
program to the kernel, and every sandbox runs under a seccomp allowlist: a
filter that answers EPERM to any syscall the profile does not name
(chapter 24). libc and Python report EPERM as
“Operation not permitted”, against whatever path the call was about. So the
traceback names the file, never the syscall. pip install --target once
failed this way in nine tracebacks about RECORD and WHEEL; the refused
call was listxattr, inside shutil.copy2.
Find out which syscall, in this order:
-
--seccomp permissive, once. If it works there, the profile is the cause.permissiveisdefaultplus namespaces, mounts,ptraceand friends. It is not “no filter”, so a call refused under both is not a seccomp refusal at all. -
ZYGO_LOG=debug zygo run …. The launcher then asks the kernel to log every refusal, and each one shows up in the host’s kernel log asaudit: type=1326 … comm="python3" … syscall=<n>:ZYGO_LOG=debug zygo run --mount ./out:/out:rw python:3.12-slim python3 -c 'import shutil; shutil.copy2("/etc/hostname", "/out/x")' sudo journalctl -k -n 20 | grep type=1326 # or: sudo dmesg | grep seccomp<n>is the syscall number for the sandbox’s CPU architecture. The tables incrates/zygo-core/src/backend/ns/syscalls.rsmap it to a name. -
strace -f -e trace=%fileon the program outside Zygo, when you cannot read the kernel log. It shows every file-related syscall the program makes, and the one missing from the profile is usually easy to spot.
"Operation not permitted" on a file that is fine
│
▼
retry with --seccomp permissive ── works ──▶ the profile is the cause
│ still fails │
▼ ▼
not a seccomp refusal (or the call ZYGO_LOG=debug + journalctl -k
is in no profile at all) → "type=1326 … syscall=<n>"
│
▼
look <n> up in syscalls.rs, then report it
Then report it. A syscall that a real package needs and the profile refuses is a bug in the profile. The extended-attribute family was one, until an early user found it.
Exit 137, and you cannot tell why
A deadline kill and an out-of-memory kill are both a SIGKILL, so both exit
137, and the wait status carries nothing else. Ask for the reason:
zygo run --outcome /tmp/why.json ...
cat /tmp/why.json
timed_out comes from the launcher. oom_killed comes from the kernel’s own
counter. Over the HTTP API the same three fields are in the answer to
POST /run, and the SDKs expose them as timed_out / timedOut and
oom_killed / oomKilled (Elixir uses the Python names).
The sandbox never started, and it looks like the program failed
--outcome tells the two apart. A run that could not build its sandbox —
the image is not there, the host cannot do it, a mount does not exist — writes
the file too, with started: false and the phase that failed (plan or
start). A program that ran and failed has started: true and
phase: "run". Over the API the same two fields are in the answer to
POST /run, and the SDKs expose started / phase. Decide on those fields,
not on the error’s text: a sandbox that never started is unavailable, not
the code’s fault.
started: false, phase: "plan" ─▶ the spec could not be turned into a plan
started: false, phase: "start" ─▶ the sandbox could not be built } not your code
started: true, phase: "run" ─▶ your program ran, and this is its result
“the supervisor did not start the sandbox within 30s” (exit 125)
When a supervisor is running, zygo run does not build the sandbox itself:
it hands the run to the supervisor, which already sits in a delegated cgroup,
and waits 30 seconds for the answer that the sandbox has started. Before it
answers, the supervisor builds whatever a first run of that image still
lacks: the Python bytecode layer, a venv from requirements, a system
layer. On a fast disk that is a few seconds; on a slow one it is not.
A Raspberry Pi 5 on an SD card took over a minute to compile
python:3.12-slim, and the run was refused with this message while the
build went on. A second run in that minute waits on the same build and is
refused the same way.
The build finishes on its own, so the next run may simply work. To take it
off the request path, zygo pull IMAGE builds the same layers ahead of time,
with no budget on it;
chapter 15 says
which layers. A host with no supervisor running is not affected: there
zygo run builds everything itself and waits as long as it takes.
If the message repeats for an image whose layers are all built, the
supervisor is running but not replying. zygo logs says what it is doing;
zygo supervisor stop ends it, and the next serve or up starts a fresh
one.
“<name> is at its concurrency limit — retry” (HTTP 429, exit 75)
This is backpressure, not a failure: the request never ran. The function
is at its concurrency limit and its queue is full. Retry after a moment, or
raise concurrency if the function can really take more. Over the API it is
HTTP 429. zygo exec exits 75, which is the same answer in the form a
shell understands.
The SDKs raise this as its own type, Busy (in Elixir, a Zygo.Error with
kind: :busy), so a caller can tell it apart from a handler that failed. A
handler that raised will raise again; a Busy will not.
Exit 4: no such function
zygo exec exits 4 when the supervisor has no function by that name. A
script can branch on it. Over the API a tenant gets the same single answer for
“no such function” and for “that one belongs to somebody else”, because the
difference between them is a fact about another customer. Check the name with
zygo ps.
The handler raised and the traceback is missing
It is in the answer, not on your terminal. zygo exec prints the error. Over
the API it is the error field, with stdout and stderr beside it. In the
SDKs it is HandlerError, which carries both streams.
zygo logs resize --failed -n 20
A request sees state from a previous one
It should not, and this is worth reporting. Each request is a fresh fork()
of the warm agent, so module-level state is whatever the zygote (the warm
process every request is copied from) had at import time. Nothing a request
writes survives it.
The one thing that does last is anything a handler writes outside the process: a file in a writable mount, a row in a database. That is your state, not Zygo’s.
Warm functions
A warm function is kept loaded by the supervisor, the background process
that zygo serve and zygo up start (see chapter 13).
These problems are about it staying up.
warm ──(idle_timeout)──▶ frozen ──(cold_after)──▶ cold (dropped)
▲ │ │
└──── one write to wake ──┘ │
▲ │
└────────── next request pays a warm-up ─────────────┘
It keeps restarting
zygo logs <name> -n 50
An agent that crashes is warmed again automatically, with a growing pause between tries (a backoff). Repeated rewarming means the handler fails at import time. The zygote’s own output is in the log, above the requests.
It went cold on its own
That is idle_timeout and cold_after. A function past idle_timeout is
frozen: it keeps its memory and costs one write to wake. Past cold_after
it is dropped completely, and the next request pays a warm-up.
idle_timeout = "10m"
cold_after = "1h"
zygo ps shows the state. To bring one back before a real request arrives:
curl -X POST .../fn/<name>/warm # or client.warm(name) in the SDKs
1 request in 100 takes ~10 ms, and the rest take ~1.5
You see it in zygo bench warm or zygo stats: the usual request is fast,
and the slowest 1 in 100 is several times slower. bench warm shows where the
time goes, and says it: admit owns most of the slow request, followed by
cgroup2 here has no favordynmods. zygo doctor reports the same thing as
cgroup moves no favordynmods: ~1 warm request in 100 waits several ms to enter its cgroup degraded
On Linux 6.0 and later, moving a process into a cgroup sometimes waits for the kernel to pass a quiet point. Only warm functions with an agent (Python, Node) and pools move their requests; warm-exec and one-shot runs are created inside their cgroup and do not wait. The fix is a setting of the whole machine:
zygo doctor --fix # remounts cgroup2 with favordynmods, now and at boot
It prints the four commands and what they cost before it asks: every fork and
exit on the machine gets slightly slower (about 2 µs usually, measured). On
the Lima VM it took the slow 1 in 100 from 10.3 ms to 3.4 ms. Inside a
container this is the host’s setting — doctor says so and does not offer to
change it. To undo it, sudo systemctl disable zygo-cgroup-favordynmods and
reboot. Chapter 25
has the numbers.
slow 1 in 100 ──▶ zygo bench warm: most of it is `admit`?
│
yes ───┴──▶ zygo doctor: "cgroup moves … degraded"?
│
yes ───┴──▶ zygo doctor --fix (host-wide, asks first)
“tenant acme has no secret named STRIPE_KEY”
A runtime pool names secrets, and a call from acme arrived before that
tenant had a value stored under one of the names. The call was refused
before anything ran (400, bad_spec), because a pool’s values come from
the calling tenant’s store and nowhere else — not from the shell, not
from the pool’s own tenant. Store it, then call again:
zygo secrets set acme STRIPE_KEY # or PUT /tenants/acme/secrets/STRIPE_KEY
A zygo exec --runtime call is the default tenant’s, so it reads
default’s store. A pool that names secrets on a host with no
ZYGO_SECRETS_KEY is refused at serve instead, and says so.
“no supervisor running”
Nothing is warm. The supervisor is started by zygo serve or zygo up, and it
exits when the last function stops. zygo exec, ps, logs, shell,
stats and top only ever connect to one that already exists. A command that
needs one and finds none fails with no supervisor at <socket> and exit 125,
and tells you to start one with zygo serve <handler> --name <name>.
The image store
Zygo keeps pulled images in its own local store, a folder of image layers (see chapter 15).
“image is not in the local store”
zygo pull python:3.12-slim
zygo run pulls on first use, as docker run does. zygo serve and zygo up
do not: a deploy should not quietly depend on a registry being reachable.
“the image has moved” during zygo up
zygo.lock records the digest (the content hash) each image resolved to.
The tag now points somewhere else, and Zygo stops rather than quietly running
something different.
zygo up --relock # accept the move and rewrite the lock
The store is using too much disk
zygo image prune --dry-run
zygo image prune
zygo image prune --unused-for 30d --blobs
Without flags, it removes only what nothing can reach: layers of images that
were removed, and caches whose image is gone. --blobs drops the compressed
copy of every unpacked layer. That roughly halves the store, and costs a
download if a layer folder is ever lost.
The vm backend
The vm backend runs each sandbox in a small virtual machine with its own
kernel (see chapter 12).
“the guest kernel is not installed”
zygo backend install vm
The guest kernel is a separate file for two reasons: it is GPL, while this
binary is Apache-2.0, and it is twenty megabytes against a fifteen-megabyte
size budget. From a checkout, make vm-kernel builds it, and zygo doctor
reports it once it is in place.
A vm sandbox’s root is read-only
This host cannot build the guest’s private writable layer, so the sandbox fell
back to sharing the image read-only. The log line starts with “no writable
scratch for this guest” and says why. The layer needs rootless overlayfs,
which is Linux 5.11 and newer; zygo doctor’s overlayfs (userns) line is
the check. On a host that has it, a guest writes to / and /tmp freely, up
to scratch, and nothing it writes reaches the shared image.
KVM GICv3 creation failed, falling back to KVM GICv2
Noise, not an error. On a host whose interrupt controller is GICv2 — a Raspberry Pi, for example — libkrun tries the newer one first and falls back. Guests boot either way.
On a Mac
On macOS, Zygo runs a Linux VM with Lima, and the zygo command on the Mac is
a shim: it forwards each command into the VM over one shared SSH connection.
Mac Linux VM (Lima, "zygo")
┌──────────────────────────┐ ┌────────────────────────────┐
│ zygo (shim) │ one SSH │ sshd (MaxSessions: 64) │
│ checks paths are under │ ─────────▶ │ ▼ │
│ $HOME, retries a │ connection │ zygo (Linux build) │
│ refused session twice │ many │ ▼ │
└──────────────────────────┘ sessions │ sandboxes, supervisor │
$HOME ═══════════════════ same path ══▶│ $HOME (and nothing else) │
└────────────────────────────┘
Everything is slow
Crossing into the Linux VM costs about 22 ms per command once the VM is up,
over the SSH connection Lima keeps open. If every command costs 100 ms or more,
that connection is not being used. ssh -F ~/.lima/zygo/ssh.config -O check lima-zygo should say Master running. The millisecond warm path is reached
through the HTTP API or the SDKs, where the hop is paid once per connection
rather than once per request. See what Zygo costs.
“limactl is not installed”
brew install lima
“the Linux build is missing”
make guest-build # compiled inside the VM; needs no Docker
make tests/linux/bin/zygo-linux-musl # the same binary, built in a Docker container
That is the binary that runs inside the VM. A release ships it beside the Mac
one. From a checkout it is one make, and the next zygo command copies it
in.
“Session open refused by peer”, or exit 111
Every command forwarded into the VM is one session on one shared
(multiplexed) SSH connection. The guest’s sshd limits the sessions one
connection may carry (MaxSessions, ten by default). Past that, when many
zygo commands ran at once — an adopter measured it at 24 — some were refused
before the guest ran anything, with SSH’s own line
mux_client_request_session: session request failed: Session open refused by peer
and exit 255.
Three things stand against it now:
-
The shim retries a session the peer refused, twice, with a short pause before each try. It is the one layer that knows the guest ran nothing, so a retry is safe. What still fails after three attempts exits 111 with a sentence of Zygo’s, and SSH’s line is kept for
-v. 111 differs from every status a program can produce and from 125, so a caller can branch on it. -
The VM template raises the limit to 64, in the guest’s
/etc/ssh/sshd_config.d/zygo.conf. That reaches a VM created from this release’s template and not one created before it, because Lima copies the template only once.zygo doctorreads the template generation back from the VM’s copy and says when it is behind. -
For an existing VM, either recreate it — nothing under
$HOMEis in it, so you lose only warm functions and about a minute:limactl delete zygo # the next zygo command builds a fresh oneor apply the change by hand and keep the VM:
limactl shell zygo -- sudo sh -c 'printf "MaxSessions 64\nMaxStartups 64:30:128\n" > /etc/ssh/sshd_config.d/zygo.conf && systemctl reload ssh'
make verify-shim-concurrency fires 24 commands at once for six rounds and
wants 144 of 144 to succeed.
attempt 1 ── refused ──▶ short pause ──▶ attempt 2 ── refused ──▶ longer pause
│
┌─────────────────────────────────────────────────────────────────┘
▼
attempt 3 ── refused ──▶ exit 111 "the Linux VM could not be reached"
│
└── accepted ──▶ the command runs, and its own exit status comes back
A command is refused because of where it was run
The VM mounts your home folder at the same path, and nothing else. A command
run from outside $HOME is refused only when something in it depends on
where it was run: a relative path — a mount, a handler file, -f, a script —
or a sandbox.toml found by searching upwards from there, which the VM could
not find. The message names the argument.
A command whose paths are all absolute and under $HOME, or that names no
path at all, runs from anywhere: a server started from /, or a systemd unit
with its default working folder. It runs in the VM’s /, so a relative path
that slipped through would name a file that does not exist, rather than a file
of yours that you did not mean.
“client speaks control v14, this supervisor speaks v13”
The supervisor in the VM is from the previous release. The shim replaced the binary, but a supervisor started from the old one was still running. The shim now stops it itself when it replaces the binary, and says so. If you see the message anyway:
zygo supervisor stop # only the supervisor: it drains, exits, and the next serve starts a new one
zygo stop --all also works, but on a Mac it stops the whole Linux VM, and
the next command pays the boot. It says so before it does it.
What doctor --json says
This is the document a health check reads:
{
"checks": [
{"name": "limactl", "status": "ok", "detail": "at /opt/homebrew/bin/limactl", "side": "host"},
{"name": "vm", "status": "ok", "detail": "instance `zygo` is running", "side": "host"},
{"name": "kernel", "status": "ok", "detail": "6.8.0-31-generic", "side": "vm"},
{"name": "pasta", "status": "FAIL", "detail": "not on PATH", "remedy": "apt install passt", "side": "vm"}
],
"backends": ["ns"],
"ok": false
}
checks[]: one per probe.statusisok,degraded(usable, with a fallback),absent(an optional backend that is not installed) orFAIL.remedyis present when there is one. On a Mac,sidesays whether the check is the Mac’s (host) or the VM’s (vm); on Linux there is one side and no field.backends[]: the isolation backends usable right now — the host has what each needs, and this binary implements it. On a Mac these are the VM’s.ok: no check failed. The exit status is 0 exactly whenokis true. Both come from the same list, so you can trust either one alone. A stopped VM isok: false: nothing can vouch for the sandboxes until it is up, and the remedy says so.
Still stuck
zygo run --dry-run --json <image>prints the resolved configuration, the mount plan and the cgroup values, and runs nothing.zygo spec explain <fn>prints what a function resolved to, and where each value came from.ZYGO_LOG=debug zygo <command>turns on Zygo’s own tracing.zygo backend listsays which isolation backends this host can really use, and why not the others.
If it looks like an escape — anything reaching the host from inside a sandbox — please report it privately. SECURITY.md says how, and what is in scope; chapter 23 has the short version.
23. Security: the threat model
A threat model is a written answer to three questions: what are we protecting, from whom, and what stands in the way. This one is written against what is actually built and measured, not what is planned. Where a control is not built, or is built but not yet checked, this chapter says so, because a threat model that claims too much is worse than none: it is what people plan around.
Reporting a vulnerability
If you find a way out of a sandbox, do not open a public issue. Report it privately, through GitHub’s private vulnerability reporting on the repository (Security → Report a vulnerability) or by e-mail. SECURITY.md has the address, what to include, how fast you will hear back, what is in scope and what is not. Zygo is pre-1.0, only the latest release is supported, and there is no bug bounty: reports are answered, fixed and credited, not paid for. No external audit has been done; one is planned.
Words used in this chapter
| word | meaning |
|---|---|
| tenant | whoever supplies the code that runs in a sandbox |
| escape | code inside a sandbox reaching something outside it |
| control | one lock that stands against an attack, such as a seccomp filter |
| vector | one way an attacker might try to get out |
| CVE | a public, numbered record of a known security bug |
| LPE | local privilege escalation: a bug that lets a normal process become root |
| residual risk | the risk that is left after every control, and that you accept |
| backend | how Zygo isolates a sandbox: ns (namespaces), gvisor or vm |
What is being protected
- The host’s integrity: its files, its kernel, its other processes.
- Other tenants: their code, their data, their secrets.
- The supervisor itself, which holds every function’s spec and secrets.
- The host’s resources: memory, CPU, pids, disk, network.
Who the attacker is
The attacker is a tenant who can run any code they like inside a sandbox. That is the design assumption, not the worst case. Running code you did not write is what Zygo is for. So every control below is judged by one question: what can hostile code, already running inside, do next?
The three trust classes
Not all code is equally hostile. The design sorts it into three trust classes, and gives each one a backend and a risk that is accepted.
| Class | Who | Backend | Residual risk accepted |
|---|---|---|---|
| T1 | Your own team, CI | ns, relaxed seccomp | A kernel CVE |
| T2 | Authenticated, contracted customers | ns + strict seccomp + Landlock + a network allowlist | A kernel local-privilege-escalation CVE — historically a few critical ones a year |
| T3 | Anonymous, hostile | vm — today one-shot only, no network, no in-guest cgroups, seccomp or Landlock; needs KVM and a make vm-build binary | A VMM or KVM CVE, which are much rarer |
T2 is a setting, not the default. A function ships with seccomp = "default";
T2 means seccomp = "strict" in its [fn.<name>] table or --seccomp strict, an egress allowlist rather than full, and Landlock, which is on
wherever the kernel has it. A runtime pool is strict by default, because
its zygotes are shared between tenants (chapter 20).
who wrote the code? backend what could still break it
─────────────────── ─────── ─────────────────────────
┌─────────────────────┐ ┌──────────────────────┐
│ T1 your team, CI │ ─────▶ │ ns, relaxed seccomp │ ─▶ a kernel CVE
└─────────────────────┘ └──────────────────────┘
┌─────────────────────┐ ┌──────────────────────┐
│ T2 paying, │ ─────▶ │ ns + strict seccomp │ ─▶ a kernel LPE CVE
│ known customers │ │ + Landlock │ (a few critical a year)
└─────────────────────┘ │ + network allowlist │
└──────────────────────┘
┌─────────────────────┐ ┌──────────────────────┐
│ T3 anonymous, │ ─────▶ │ vm │ ─▶ a VMM or KVM CVE
│ hostile │ │ (not complete today; │ (much rarer)
└─────────────────────┘ │ see below) │
└──────────────────────┘
The vm backend today
The vm backend runs one-shot sandboxes. A guest boots, the program runs
under a kernel of its own, and the root filesystem is read-only at the device,
not by a mount option the guest could change. What it does not have yet:
networking, warm functions, and its own in-guest cgroups, seccomp and
Landlock. So a tenant’s limits are the VMM’s cgroup on the host side, and
nothing finer. (A VMM, or virtual machine monitor, is the host program that
runs the virtual machine.) Until those land, T3 workloads do not have the
boundary the design gives them. The honest answer for anonymous code today is
a separate machine.
The gvisor backend today
The gvisor backend runs one-shot sandboxes. gVisor runs its own kernel
in user space, called the Sentry, so the program’s syscalls reach the Sentry
instead of the host kernel. On a host without KVM, that is a smaller and
easier-to-defend target than ns. make gvisor-linux checks that the same
spec behaves the same way on both. It is not a T3 answer either: it holds no
warm functions and has no networking. A rootless runsc cannot write cgroups,
so its resource limits are advisory, and a tenant there can still use up the
host’s memory.
The layers an attacker must pass
On the ns backend, hostile code has to beat every one of these locks to get
out, or find a bug in the kernel underneath them all.
Chapter 4 explains each lock.
┌─────────────────────────────────────────────────────────────────────┐
│ host: the kernel, your files, other tenants, the supervisor │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ user namespace: root inside, nobody outside; uid_map fixed │ │
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
│ │ │ pid, mount, net, ipc, uts, cgroup namespaces │ │ │
│ │ │ ┌───────────────────────────────────────────────────┐ │ │ │
│ │ │ │ cgroup limits: memory, cpu, pids.max always set │ │ │ │
│ │ │ │ ┌─────────────────────────────────────────────┐ │ │ │ │
│ │ │ │ │ pivot_root, read-only root, no cgroupfs │ │ │ │ │
│ │ │ │ │ ┌───────────────────────────────────────┐ │ │ │ │ │
│ │ │ │ │ │ no capabilities, no_new_privs, nosuid │ │ │ │ │ │
│ │ │ │ │ │ ┌─────────────────────────────────┐ │ │ │ │ │ │
│ │ │ │ │ │ │ Landlock (5.13+), nftables │ │ │ │ │ │ │
│ │ │ │ │ │ │ ┌───────────────────────────┐ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ seccomp allowlist │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ ┌──────────────┐ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ │ tenant code │ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │ └──────────────┘ │ │ │ │ │ │ │ │
│ │ │ │ │ │ │ └───────────────────────────┘ │ │ │ │ │ │ │
│ │ │ │ │ │ └─────────────────────────────────┘ │ │ │ │ │ │
│ │ │ │ │ └───────────────────────────────────────┘ │ │ │ │ │
│ │ │ │ └─────────────────────────────────────────────┘ │ │ │ │
│ │ │ └───────────────────────────────────────────────────┘ │ │ │
│ │ └─────────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
every layer is a feature of ONE kernel: a kernel LPE bug skips them all
How the controls are tested
Every row below marked attempted is run by make escape-linux. It runs
the escape itself, not a check of a setting, because a test that reads a flag
also passes on a kernel that ignores that flag. The suite attempts 21 vectors in 32
checks, and on Linux 6.8 reports 32 blocked, 0 escaped, 0 skipped. On
5.10 it skips one, the Landlock half of case 20, which needs a 6.7 kernel.
Run rootless, it skips another: setting up a file capability to try needs
root on the host.
Beside it, make fuzz-linux sweeps every syscall number the architecture
has — 469 of them — against all three seccomp profiles. Each call is made in a
forked child, so a syscall that blocks or exits takes nothing else with it.
The escape suite tries the attacks somebody thought of; the sweep needs no
imagination. That is what makes it the right check for a filter whose jump
offsets are computed by a program. It checks that the profiles are ordered on
a real kernel (permissive ⊋ default ⊋ strict), that no syscall kills the
process, that clone3 answers ENOSYS rather than EPERM, and that every
syscall named in the tables below is refused.
make escape-linux make fuzz-linux
───────────────── ───────────────
every known attack, really tried all 469 syscall numbers
→ 32 blocked, 0 escaped, 0 skipped × 3 profiles, one forked child each
→ profiles ordered, nothing kills
the process, clone3 → ENOSYS
Vectors: the kernel and privileges
| Vector | Control | Status |
|---|---|---|
| Kernel syscall surface | seccomp allowlist (default: ~215 syscalls named; 190 of them exist on aarch64, all on x86_64); bpf, io_uring, userfaultfd, keyctl, perf_event_open and ptrace refused | attempted — and swept: all 469 syscall numbers under each profile, 271 refused with EPERM under default (Linux 6.8, aarch64); the numbers above the table answer ENOSYS |
mount() to reach the host | CAP_SYS_ADMIN dropped; seccomp refuses mount | attempted |
setns into the host’s namespaces | refused: no capability in the host’s user namespace | attempted |
| Regaining capabilities via a new user namespace | unshare(CLONE_NEWUSER) refused by seccomp | attempted |
Rewriting uid_map to become another uid | the map is written by the parent and is then read-only | attempted |
| Regaining privilege through a setuid or file-capability binary | one uid mapped, so setuid has no other identity to switch to; every mount nosuid; no_new_privs; an empty bounding set | attempted — a copy of python3 given CAP_DAC_OVERRIDE reads a mode-000 file outside a sandbox and cannot inside; with the three controls switched off in a test build, it could |
Reading kernel memory (/dev/mem, /proc/kcore) | masked and not present | attempted |
| Creating a block device to read the host’s disk | mknod refused; no block devices in /dev | attempted |
Vectors: files
| Vector | Control | Status |
|---|---|---|
| Overwriting the runtime binary (CVE-2019-5736 shape) | read-only root; /proc/self/exe is not writable | attempted |
cgroup release_agent | cgroupfs is not mounted in the sandbox at all | attempted |
| Writing through a read-only bind mount | read-only for the mount and every mount below it: mount_setattr(AT_RECURSIVE) on 5.12+, one remount per submount (read from the host’s mount table) below that | attempted — including a tmpfs mounted inside the read-only source |
| Setuid binaries or device nodes in a shared folder | every bind, :rw as well, is nosuid,nodev, recursively | attempted |
| Escaping a writable mount by symlink | pivot_root; the symlink resolves inside the sandbox root | attempted |
| Reaching the host’s filesystem | pivot_root with the old root detached | attempted |
| Tampering with shared image layers | the store is not reachable from inside; layers are bound read-only | attempted |
Vectors: processes and resources
| Vector | Control | Status |
|---|---|---|
| Seeing or signalling host processes | separate pid namespace; only the sandbox’s own processes are visible | attempted |
| Resource exhaustion | mandatory cgroup limits; pids.max always set; memory.max and memory.oom.group on each request’s own cgroup | attempted separately by make verify-linux (the fork bomb is cut off at pids.max; the memory hog is OOM-killed inside its own cgroup and the host loses 0 MB) and make verify-oom-linux (in a warm function, the hog dies and the three requests beside it, and the zygote, do not) |
| Zygote contamination | the zygote never handles a request itself; every request is a fresh process that ends in _exit | by construction |
| A file left in the temp folder for the next request (or tenant) | each request’s TMPDIR is its own folder under /work, which cannot be listed, removed when the request ends; a literal /tmp/... path is still shared by the sandbox | attempted (case 18, in a runtime pool) |
A warm-exec program keeping the namespace descriptors its helper used, to setns back out | the helper marks every descriptor it renumbers close-on-exec before execve; setns is refused by seccomp as well | attempted (case 16) |
Vectors: the network
| Vector | Control | Status |
|---|---|---|
| Reaching the host over the network | default network = "none"; under egress/full, RFC1918, CGNAT, link-local, loopback, multicast and reserved ranges are rejected above every allow rule, so a hostname that resolves into one is refused too | attempted by the supervisor suite, and measured from outside by the first consumer: under --net full the cloud metadata address, the host’s own Postgres and the LAN router are all no route, where Docker’s default bridge reaches two of the three (see below) |
| Using a resolver of one’s own to dodge the allowlist | DNS is forced to one address; port 53 to anything else is rejected | attempted |
| A pooled script opening a TCP listener for the other tenants’ requests to reach | a pool’s namespace is shared, so bind is handled and never granted there (Landlock, 6.7+), and strict, the pool default, removes the socket calls before that. A function’s own namespace has no such rule: nothing from outside reaches a port opened in it (ADR 0008) | attempted (case 20: under strict, and under default where the kernel has Landlock’s network rules; below 6.7 the second is skipped) |
Reaching a service the host bound to 127.0.0.1 only, through the sandbox’s own loopback or the gateway address | pasta is started with --tcp-ns none --udp-ns none --no-map-gw: it neither splices the sandbox’s loopback ports through to the host’s nor answers for the gateway itself. Before 0.1.4 both were on by default, and an allow rule for any name on port N also opened the host’s 127.0.0.1:N; below Linux 6.7, every loopback port | attempted (case 19: under egress with the port allowed, under full, and by the gateway address with --allow-private-net; a host whose gateway reflects loopback ports back on its own, as a Lima VM’s does, skips the last) |
Vectors: tenant against tenant
A runtime pool is one sandbox for many tenants: its zygotes are shared, and every process inside runs as one uid. The rows the suite attempts today are about one request reaching the traces of another. The rows it does not yet attempt are listed too, because a multi-tenant claim that rests on construction alone is not a tested one; the roadmap’s phase 5 is that work.
| Vector | Control | Status |
|---|---|---|
| Listing or reading another request’s workspace in a shared pool | /work is a tmpfs of Zygo’s own, mode 0311, one folder with a random name per request | attempted (case 14b) |
A handler leaving a link in its workspace, so that ?out=1 packs the supervisor’s files | the workspace is walked by descriptor, openat with O_NOFOLLOW at every step, and a name is packed only if it is still a folder or a regular file when opened | tested by zygo-core’s own tests, including a name swapped after the folder was read; not yet in make escape-linux |
| A file left in the temp folder for the next tenant’s request | each request’s TMPDIR is its own folder under /work (above) | attempted (case 18) |
| A pooled script rewriting its own file, which the next request will load | /run/script is a read-only bind of a folder the supervisor owns on the host, so the answer is EROFS on every kernel; the child also checks the digest of the bytes it read | attempted (case 17) |
| Tenant A running or reading tenant B’s script in the same pool | scripts are bound read-only under /run/script/<digest> | not yet attempted |
| Tenant A reading tenant B’s secret file while both requests are in flight | secret files are written per request, mode 0400, and removed after | not yet attempted as a cross-tenant case |
Tenant A reaching tenant B’s process through /proc/<pid> in a shared pool | one pid namespace and one uid for the whole pool; what one fork may read of another is what this row would test | not yet attempted |
| Tenant A reaching a service tenant B started on the pool’s loopback | one network namespace for the whole pool | not yet attempted |
Tenant A at its pids limit, with a full scratch or a spinning CPU, slowing tenant B | per-tenant cgroups under zygo.slice/tenants/<id> | not measured: no bound on B’s 1-in-100 time is published |
Vectors: secrets and the supervisor
| Vector | Control | Status |
|---|---|---|
| Secrets | never in EXEC, never in the zygote: written by the supervisor from outside the sandbox to /run/secrets/<name> (0400) between FORKED and GO, removed when the last request in flight finishes. A warm-exec sandbox is reached through a directory descriptor its own init hands out before it hardens, not through /proc — which a non-dumpable process does not offer an unprivileged supervisor at all | attempted — the file is absent between requests, the value is absent from the agent’s environ, and both paths are exercised as an ordinary user |
| The supervisor’s socket | unix socket 0600 inside a 0700 directory, plus an SO_PEERCRED uid check | attempted |
A request writing to the supervisor as its agent: a DONE for another request’s id, in a pool another tenant’s | the agent closes the child’s copy of its control socket before any request code runs (protocol §3, rule 13); a Node worker never holds it | tested by the Python agent’s own suite, which has a handler write a forged DONE; not yet in make escape-linux |
| The HTTP API | bearer token from the environment only, compared in constant time; refuses to start unauthenticated on a reachable address | attempted |
Out of scope
| Vector | Control | Status |
|---|---|---|
| Timing / microarchitectural side channels | out of scope | — |
Timing side channels, such as Spectre, let code learn secrets by measuring
how long things take on a shared CPU. Tenants who need protection from that
belong on the vm backend and on separate hosts.
The stated guarantee for egress
Egress is traffic from the sandbox out to the network. Under
network = "egress" or "full", a sandbox cannot reach the cloud
metadata endpoint (169.254.169.254), any RFC1918 address (the host, its
neighbours, the LAN’s router), any CGNAT, link-local, multicast or reserved
address, or the host’s loopback — whatever name they resolve from — unless the operator passes
--allow-private-net. This is enforced inside the sandbox’s own network
namespace by nftables rules that sit above every allow rule, and by a
resolver that admits only what the allowlist names. The host’s loopback is
closed a second way, because the firewall has to pass the sandbox’s own
loopback: pasta is told not to carry the sandbox’s loopback ports to the
host’s and not to answer for the gateway address (chapter
14). What Zygo does not
guarantee is anything about the public internet under full: that mode means
“the internet and nothing of yours”.
sandbox wants to connect to …
│
▼
┌───────────────────────────────────────────┐
│ nftables rule 1 (checked first): │
│ 169.254.x, 10.x, 172.16-31.x, 192.168.x, │── match ──▶ refused, "no route"
│ CGNAT, link-local, loopback │ (unless --allow-private-net)
└─────────────────────┬─────────────────────┘
│ no match
▼
┌───────────────────────────────────────────┐
│ allow rules (egress: only the allow list; │── match ──▶ connected
│ full: the public internet) │
└─────────────────────┬─────────────────────┘
│ no match
▼
refused
Measured against Docker
This was measured, not read from the code, with a small connection probe on
one host. The probe through Zygo’s --net full found
no route to the metadata address, the host’s Postgres, the LAN router and
10.0.0.1, and reached 1.1.1.1:53. Docker’s --network bridge on the same
host reached the host’s Postgres and the LAN router, and routed the metadata
address (it was refused by the host, not blocked). Docker needs four
iptables -I DOCKER-USER … -j DROP rules for the same result, one for each of
169.254.0.0/16, 10.0.0.0/8, 172.16.0.0/12 and 192.168.0.0/16.
Chapter 5 has more on Docker’s network.
| destination | Zygo --net full | Docker --network bridge |
|---|---|---|
cloud metadata 169.254.169.254 | no route | routed (refused by the host, not blocked) |
| the host’s Postgres | no route | reached |
| the LAN router | no route | reached |
10.0.0.1 | no route | not reported |
1.1.1.1:53 | reached | not reported |
Where the boundary is weaker than it looks
This part is said plainly, because zygo doctor says it too. Each item is a
place where the protection is thinner than the tables above might suggest.
ns is one kernel
Every control above is a feature of the kernel. A kernel
local-privilege-escalation bug defeats all of them at once. That is the
accepted residual risk for T1 and T2, and the reason vm exists for T3.
zygo doctor says how old the running kernel’s series is, and warns past
two years, because that risk grows with the gap. Age is not the same as
unpatched: a long-term series gets fixes backported without changing its
version, and the warning says which kind it is looking at. But a host four
years behind is four years of hardening behind, and every control here rests
on it.
Without newuidmap, tenants share a uid
newuidmap is the small setuid helper (from the uidmap package) that gives
each sandbox its own range of user ids. With no such range, the id map falls
back to a single entry, so two sandboxes run as the same host uid. They are
then kept apart by namespaces and file modes, but not also by different uids.
zygo doctor reports this as degraded.
Landlock needs 5.13, and its network rules 6.7
On older kernels the filesystem allowlist is absent, and the mount plan is the
only filesystem boundary. The process-level network rules — bind refused in
a runtime pool, connect limited to the allowlist’s ports under egress —
are built and unit-tested, and enforced for real in one place: CI’s
landlock-network job, on an ubuntu-24.04 runner (Linux 6.8, Landlock ABI
v4), runs tests/linux/verify_landlock_net.sh (make landlock-net-linux runs the
same script in a container). It picks the refusals nftables cannot produce:
a bind() on a TCP port in a pool, which sends no packet, and a loopback
connect() to a port off the allowlist, which the packet filter accepts on its
first line. Both must fail with EACCES; a loopback connect on an allowed
port must get past Landlock; a function must be able to listen on its own
loopback, and under network = "none" connect to itself, with anything off
loopback ENETUNREACH from the empty namespace. On a kernel below 6.7 the
script says SKIP and exits 0 — which is why the job is not mixed into one
that also runs on 22.04. Neither of this project’s own
development machines can run it: Docker Desktop’s 5.10 kernel reports ABI 0,
and the Raspberry Pi’s kernel has no Landlock at all. Below 6.7, the nftables
allowlist inside the namespace is the only egress control, and it is the one
every other check here runs against.
cgroup.kill needs 5.14
cgroup.kill is one file write that kills every process in a cgroup. Below
5.14, killing a request’s process tree falls back to freeze → signal → thaw.
That has a race which the freeze closes, but it is more machinery than one
write.
The seccomp profiles have been run against a sample, not a population
The compatibility matrix
runs six Python packages, the standard library’s sqlite3 and three Node cases
under default and strict.
Each round of widening it has found something:
- the filter refusing every thread;
strictrefusing the agent its own control socket;- when Node was added,
strictremovingsocketpair, which libuv uses for every pipe, so astrictNode function could not start a worker at all.
All are fixed and pinned by tests. But a control that has been run against ten cases has been run against ten cases.
The child filter is installed by the agent
Under strict, the agent’s forked child is locked down further: no execve,
no new process. The agent does this, at the supervisor’s request, so it is
only as good as the agent. zygo agent test now checks it rather than
trusting it: an agent that ignores ZYGO_CHILD_SECCOMP and runs the request
anyway fails conformance. The Python agent installs the filter. The Node agent
installs it when the image has the helper object, and otherwise falls back to
Node’s permission model, which is weaker against a V8 escape and says so in
READY. The sh example refuses the request outright.
Chapter 24 has the details.
On Ubuntu 24.04 and later, Zygo asks you to turn something off
kernel.apparmor_restrict_unprivileged_userns=1 stops a normal process from
mounting inside a user namespace, which is the first thing every sandbox does.
zygo doctor finds this by trying the mount, not by reading the sysctl.
zygo doctor --fix then installs an AppArmor profile that gives the zygo
binary alone the userns permission — the way Ubuntu lets its own browsers
and container tools past the same rule — and the restriction stays on for
every other process. Only where AppArmor cannot load a profile does it offer
sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 instead. Be clear
about what that is. It is a host-wide protection against a class of
local privilege escalation that starts with an unprivileged user namespace,
and turning it off removes it for every process on the machine, not only
Zygo’s. It is the right call on a host whose job is running sandboxes, and it
is what shim/lima.yaml does inside Zygo’s own VM. On a shared workstation,
the profile is the right answer; packaging/apparmor/zygo
is the file.
Troubleshooting
shows zygo doctor --fix, which applies it after asking.
Egress needs pasta and nft
If either is missing, a networked sandbox refuses to start. It does not start without a firewall. But that is a liveness failure — the service stops working — and you should know about it before it happens.
A derived system layer is built as root, with host networking
A derived layer is an image layer Zygo builds by installing system packages on top of an image. It installs them as root inside the sandbox’s user namespace, with host networking, once, before any tenant code exists. The package list is checked against the Debian package-name alphabet and passed as separate arguments, never through a shell.
What has not been reviewed
No external security audit has been done. That is the largest gap in this
chapter, and it needs an auditor, not a commit. The hardening that could be
done without one is in place: the full syscall sweep described above, and the
kernel-age warning in zygo doctor.
Until an audit happens, the strongest honest statement is this: every vector marked attempted above is tried by a suite that runs on every change, none of them currently succeeds, the ones not yet attempted are marked as such, and the syscall surface they rest on is swept in full rather than sampled.
Hardening your deployment
These points come from SECURITY.md:
- The
nsbackend leans on one kernel and does not hide it. For code that is hostile rather than semi-trusted, the wall isisolation = "vm"— today one-shot only, without a network or a warm path, and in amake vm-buildbinary on a host with KVM (thevmbackend today). Until that is more, the honest answer for anonymous code is a separate machine. - Keep
network = "none"unless a function really needs egress, and keep theallowlist to the hosts it needs. - Do not run Zygo as root. It does not need it, and
pasta,newuidmapand cgroup delegation all behave better without it. - Bind the HTTP API to loopback or a unix socket. It refuses to start without a token on a reachable address, but the safe default is worth keeping.
- Install
uidmap, so tenants get separate subordinate uid ranges rather than sharing one identity map. Zygo does not let a host become multi-tenant without one: registering a tenant is refused on a host whose user has no range in/etc/subuid, unless the supervisor was started withZYGO_ALLOW_SHARED_UID=1, which is the operator saying they accept that every tenant’s sandbox shares one host uid.
Fork safety, question by question
A warm function answers each request with a fork() of a process that has
already done its imports. That is where Zygo’s speed comes from, and it is
the part people ask about first. This page answers those questions one at a
time, and says plainly where the answer is “not fully”. Each answer links to
the chapter with the detail.
zygote: interpreter + your imports, never runs a request
│
├── fork ──▶ child 1: GO ─▶ child filter ─▶ reseed ─▶ own TMPDIR ─▶ handler ─▶ _exit
├── fork ──▶ child 2: GO ─▶ …
└── fork ──▶ child 3: GO ─▶ …
▲
└── each child starts as a copy of the zygote, not of child 1 or 2
Is a forked child really clean?
Its memory is. A child starts as a copy of the zygote, and the zygote has never run a request. Whatever child 1 changes — a global, a monkeypatch, a cache — is in child 1’s own pages and is gone when it exits. Child 2 is a new copy of the zygote, not of child 1. A test holds this line for every agent (rule 4 of the protocol).
Its files need more than a fork, because every request in one sandbox
sees the same filesystem. Each request gets its own temporary folder, named
by TMPDIR and removed when it ends. But a literal /tmp/... path is one
folder for the whole sandbox, and so is any writable mount you add. Write
temporary files through tempfile or os.tmpdir(), never to a fixed path.
Warm functions lists what a
request sees.
What does a child share with the zygote?
Everything the zygote had when it was forked: the loaded modules, anything built at import time, and open descriptors. The pages are shared copy-on-write: they are only copied when one side writes. That is the point. Your import-time work is paid once and every request starts with it. It is also the rule to remember: what you build at import time, every request sees. Build clients, compiled templates and models there. Do not put per-user data there.
Does every child have the same memory layout (ASLR)?
Yes. ASLR places a program’s memory at random addresses, so an attacker
cannot know where things are. A fork copies the layout rather than drawing a
new one. So every child of one zygote has the same addresses, and an address
leaked by one request holds for the next. The layout changes only when the
zygote starts again, such as on a zygo up that replaces it or a serve
after a stop. This is a real cost of the fork model, and it is
why the ns backend is described as a wall for semi-trusted code. The Node
agent is different: each of its workers is a new process with its own
layout.
Can a secret end up in the zygote?
No. A secret is never in the environment, never in the EXEC message and
never in the zygote. The supervisor writes it as a file, /run/secrets/NAME
(mode 0400), from outside the sandbox, after the child exists and before it
is told to start. The file goes when the function’s last request in flight
finishes. Requests of the same function that run at the same moment can read
it too: they have the same value, the same uid and the same folder. A request
of another function cannot, because that function has its own sandbox. In a
runtime pool, which many tenants share, a request that receives secrets has
its zygote to itself while the files exist, so no other tenant’s child is
forked beside them; the values are the calling tenant’s and are written the
same way, never through the zygote.
Chapter 14 has
the details.
What about threads and locks?
A fork copies only the thread that called it. If another thread held a lock
at that moment, the lock stays locked in every child. At warm-up the Python
agent checks for threads that would survive a fork, Python threads and native
ones. If it finds any, it stops forking and starts a fresh interpreter for
each request instead: much slower, but correct, and zygo logs says it did.
Start threads inside the handler, or lazily on first use.
Chapter 13.
Do children draw the same random numbers?
Not from the usual places. The agent reseeds Python’s random, numpy’s
global generator and torch’s generator in every child, before your code runs.
os.urandom, secrets and uuid.uuid4() ask the kernel on every call, so a
fork does not repeat them. What the agent cannot reseed is a generator
you created at import time, such as RNG = random.Random(). Every request
draws the same numbers from it. The agent names such a generator in
zygo logs at warm-up. Create it inside the handler.
What about a connection opened at import time?
Every child inherits the same socket. Two requests running at once would then
write into one connection, and the replies would mix. The agent does not
detect this. Build the client at import time, which is where the cost is,
and let it connect on first use in the child. Most clients, boto3 and
requests.Session among them, connect lazily.
Can a request talk to the supervisor?
No. The zygote reaches the supervisor over one socket, and a forked child starts with a copy of it. The agent closes that copy in the child before any of your code runs, so a request cannot send messages as the agent. Without this, a request could answer for another request, which in a runtime pool may be another tenant’s. The Node agent’s workers never get the socket at all. The rule is in the protocol, and the agent’s tests try to break it.
Do atexit handlers run?
No. A child leaves with os._exit: no atexit handlers, no interpreter
shutdown, and no flushing of buffers that the zygote also owns. Anything your
handler must save, it saves before it returns.
Does the seccomp filter survive the fork?
Yes, and it cannot be removed. The kernel copies a process’s filters into its
children, and nothing can take one away. The zygote runs under the sandbox’s
filter, so every child does too. Under strict, each child also installs a
second filter after GO and before any request code: no execve, no new
process. Filters stack, and the strictest answer wins. That second filter is
installed by the agent, so it is only as good as the agent.
Chapter 24.
Can one tenant’s data reach another through shared pages?
Not through a function’s zygote. An agent function’s zygote holds that function’s code and nothing else, and the multi-tenant pattern is one zygote per script version (ADR 0005). A runtime pool is shared by tenants, so its zygote holds no tenant code at all. The script arrives with the request and loads in the child. Zygo sends it as a path to a read-only mount, which never passes through the zygote. The fallback, the script’s text in the message, does pass through the zygote’s memory, where the next tenant’s child inherits a copy. Zygo logs when it has to use that fallback. What a child writes stays in its own pages.
Why not snapshot and restore a microVM instead?
It is the same idea one level down: restore a whole VM from a snapshot
instead of copying a process. It gives each call a hardware boundary, which
ns does not. It also costs more: the projects that do it report around
10–20 ms per restore, against Zygo’s 1.4 ms fork. It has the same problems
too: every restore of one snapshot starts with the same memory, random state
included. Zygo’s vm backend runs one-shot sandboxes, and warm functions stay
on ns (ADR 0002 says why, and what would
change that). Similar projects
compares the two.
Is the Node agent any different?
Yes. Node cannot be forked safely, so its agent keeps a few workers ready. Each worker is a new Node process that loads the handler itself, serves one request and exits, and a replacement starts off the request path. Nothing is inherited copy-on-write. So the thread, random-number and layout questions above do not arise. The cost is a little higher than a fork. The handler’s import-time code runs when the worker starts, before the request it will serve has its own cgroup. Chapter 13.
24. Seccomp profiles
Every Zygo sandbox runs under a seccomp filter, and you pick one of three
profiles for it: default, strict or permissive. This chapter says what
each one allows and refuses, which real packages have been tested under each,
and how to choose. Chapter 4 explains what seccomp
is, if you have not met it yet.
An allowlist, not a denylist
A syscall is a request from a program to the kernel: open a file, start a
thread, make a socket. seccomp lets a process install a small filter that
the kernel runs on every syscall, and that answers “allow” or “refuse”. Zygo’s
filter is an allowlist: a syscall that is not named returns EPERM
(“Operation not permitted”). The direction matters. A denylist names what is
blocked, so it quietly gains a hole every time the kernel adds a syscall, and
it adds several per release. An allowlist blocks a new syscall until someone
decides to allow it.
denylist (Docker) allowlist (Zygo)
───────────────── ────────────────
"block these" "allow these"
kernel adds new_syscall() ──▶ ALLOWED kernel adds new_syscall() ──▶ refused
until someone notices until someone chooses to allow it
Refused, or never heard of
A refusal comes in two forms, and the difference matters to a program. Zygo
carries a table of every syscall in the Linux 6.10 headers. A syscall in that
table that the profile does not allow answers EPERM: “this exists, and you
may not”. A number above the table — a syscall newer than this build —
answers ENOSYS: “there is no such syscall here”. That is the truth, and it is
the one answer a C library falls back from. glibc tries fchmodat2 for
chmod and uses the old call only on ENOSYS; on EPERM, python3 -m venv
fails.
Each syscall that Linux 5.11 to 6.10 added was decided on its own. The ones
that are a newer form of something already allowed are allowed: fchmodat2,
epoll_pwait2, the futex_* family. So are Landlock and mseal, which only
take power away from the caller. The new mount API, mount_setattr,
pidfd_getfd, memfd_secret, cachestat, statmount, listmount and the
lsm_* calls are refused. A test fails when the table grows past what has
been decided.
syscall number
0 ─────────────────── in the table (≤ 462) ──────────────────┬──── above ────▶
allowed by the profile ──▶ runs │
in the table, not allowed ──▶ EPERM "exists, and refused" │ ENOSYS
│ "no such call"
The three profiles at a glance
You choose a profile per function with seccomp = "…" in sandbox.toml, or
with --seccomp on the command line. The three are nested: strict is a
subset of default, which is a subset of permissive. Anything outside all
three is refused under every profile, permissive included.
┌───────────────────────────────────────────────────────────────────┐
│ never allowed: anything in no list (a new syscall, most of the │
│ kernel's rarely used calls) │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ permissive = default + clone3, ptrace, unshare, setns, │ │
│ │ mount, pivot_root, chroot, mknod, process_vm_readv/writev,│ │
│ │ personality … (Docker's default set) NOT for tenants │ │
│ │ ┌───────────────────────────────────────────────────────┐ │ │
│ │ │ default ≈ 215 syscalls for everyone (T1, T2) │ │ │
│ │ │ clone only without CLONE_NEW*; ioctl minus TIOCSTI │ │ │
│ │ │ ┌─────────────────────────────────────────────────┐ │ │ │
│ │ │ │ strict = default − socket, connect, bind, │ │ │ │
│ │ │ │ listen, accept4, ptrace, mount, umount2 │ │ │ │
│ │ │ │ (+ child filter: no execve, no new process) │ │ │ │
│ │ │ └─────────────────────────────────────────────────┘ │ │ │
│ │ └───────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────┘
| Profile | What it is | Who it is for |
|---|---|---|
default | ~215 syscalls: the set five reference packages exercise their real code paths under — numpy’s BLAS threads, Pillow’s codecs, pandas’ file I/O, pydantic’s Rust core, requests’ TLS setup. clone is allowed only with every CLONE_NEW* flag clear, so a sandbox cannot make a namespace; ioctl is allowed except for TIOCSTI and its relatives. bpf, io_uring_*, userfaultfd, keyctl, perf_event_open, ptrace, mount and unshare are absent. | Everyone (T1, T2) |
strict | default minus the calls that reach the network — socket, connect, bind, listen, accept4. (Its source also names ptrace, mount and umount2, as a guard should default ever gain them; today it has none of the three.) In the agent’s forked child, additionally minus execve, execveat, fork, vfork and any clone without CLONE_THREAD (see the child filter). | A network = "none" function whose author wants the kernel to refuse a socket, not merely the namespace to have nothing behind it — and every runtime pool by default, because a pool’s child runs a script that arrived over an API. With network = "egress" or "full" it is refused, since nothing could be reached |
permissive | default plus clone3, ptrace, unshare, setns, mount, pivot_root, chroot, mknod, process_vm_readv/writev, personality and the rest of Docker’s default profile. Those eight are the only appendix-B exclusions it grants, and a test asserts the list. It is not “no filter”: a syscall outside all three lists is refused under permissive too. | Debugging a package the tighter profiles break, and Zygo’s own derived-layer builds, where dpkg uses the legacy chown/chmod/mknod calls. Not a tenant profile. |
(T1 and T2 are the trust classes from chapter 23: your own team, and known, paying customers. “Appendix B” is the design’s list of syscalls a sandbox should never need.)
default
default is what a function gets when its author does not choose. It names
about 215 syscalls (216 in the source; 190 of them exist on aarch64 and all
of them on x86_64, and a name the kernel does not have is left out of the
filter), found by running five reference packages through their real work:
numpy’s BLAS threads, Pillow’s image codecs, pandas’ file I/O, pydantic’s
Rust core, and requests’ TLS setup. clone, the call that makes a
new process or thread, is allowed only when no CLONE_NEW* flag is set, so a
sandbox cannot make a new namespace. ioctl is allowed except for TIOCSTI
and its relatives, which could push keystrokes into a terminal. bpf,
io_uring_*, userfaultfd, keyctl, perf_event_open, ptrace, mount
and unshare are not in it; each is a large, complex part of the kernel
that has had serious bugs.
strict
strict is default without the calls that open network connections:
socket, connect, bind, listen and accept4. Its list also names
ptrace, mount and umount2, so that they stay out if default ever
gains them; today default has none of the three. It is for a network = "none" function whose author
wants the kernel itself to refuse a socket, not just a namespace with nothing
behind it. It is also the default for every runtime pool, because a pool’s
child runs a script that arrived over an API. In the agent’s forked child,
strict also removes execve, execveat, fork, vfork and any clone
without CLONE_THREAD; the child filter explains how.
permissive
permissive is default plus clone3, ptrace, unshare, setns,
mount, pivot_root, chroot, mknod, process_vm_readv/writev,
personality and the rest of Docker’s default profile. Those eight are the
only appendix-B exclusions it grants, and a test checks the exact list. It
is for debugging a package that the tighter profiles break, and for Zygo’s own
derived-layer builds, where dpkg uses the old chown/chmod/mknod calls.
It is not a tenant profile.
permissive is not “seccomp off”
A reader once used this profile wrongly and drew the wrong conclusion, so it
is worth saying plainly. permissive means “the default plus namespaces,
mounts, ptrace and friends” — the set Docker’s default profile allows. It is
not “no filter”, and it is not the “turn seccomp off” step when you debug.
An early user tried --seccomp permissive against a failure whose
cause (listxattr, below) was in no list, saw no change, and decided the
flag did nothing. It did work; the syscall was simply missing from all three
profiles. If something fails under permissive too, follow the
troubleshooting entry for EPERM,
which shows how to find the syscall.
Seeing which profile applies
zygo run --dry-run prints the resolved profile, where the choice came from,
and how many syscalls it names. So you can see that the flag took effect
without running anything.
The extended-attribute family
Extended attributes are small name–value labels stored on a file, next to
its normal owner and mode. Every profile allows the whole family —
getxattr, listxattr, setxattr, removexattr and their l/f forms —
since an early user found listxattr missing. shutil.copy2 calls
it, and pip install --target is one copy2 per file, so a profile without it
broke every Python package install into a mounted folder, with a traceback
about RECORD.
strict keeps them on purpose. Reading and listing attributes on a filesystem
the sandbox owns leaks nothing. A user.* write is bounded by the mount. The
kernel refuses trusted.* and security.* to an unprivileged uid before any
filter is asked. And a network = "none" function copies files like any other.
A unit test holds all twelve calls under all three profiles, and
make verify-seccomp-profiles-linux does the copy2 and the pip install
on a real kernel.
How to choose
Is the code from someone you do not know, arriving over an API?
│
├─ yes ─▶ strict (the default for a [runtime.<name>] pool)
│
└─ no ──▶ Does the function need no network, and do you want the
kernel itself to refuse sockets?
│
├─ yes ─▶ strict
│
└─ no ──▶ default (the default for a [fn.<name>])
│
└─ a package breaks under default?
▶ try permissive ONCE, on your own machine,
to find the cause; then report the syscall.
Never hand permissive to a tenant.
| you want | profile |
|---|---|
| a normal function, your own or a known customer’s | default |
a function with network = "none" that must not even make a socket | strict |
| a runtime pool running scripts from an API | strict (already the default) |
| to find out whether seccomp is why a package fails | permissive, once, then report |
to build a derived system layer with dpkg | permissive (Zygo does this itself) |
The compatibility matrix
make seccomp-matrix-linux installs six packages into one venv inside the
image, then imports and uses each one under every profile. Then it does the
same for Node, in a second container, because a matrix built only from CPython
says nothing about a runtime that talks to the kernel differently. Every cell
is an attempt: the package is made to do the thing people use it for, and the
cell is what the sandbox returned.
The third column is the same code again, run as a script in a runtime
pool: one warm zygote holding no tenant code, the script written into the
sandbox per request, and strict as the pool’s default rather than something
the caller asked for.
| package | default | strict | pool (strict) |
|---|---|---|---|
| requests 2.32.3 | works | works — the socket is refused with EPERM, which requests reports as its own ConnectionError; nothing hangs | works |
| httpx 0.27.2 | works | works — refused at client construction rather than at send, and reported as its own error | works |
| pydantic 2.9.2 | works | works | works |
| numpy 2.1.3 | works | works | works |
| pandas 2.2.3 | works | works | works |
| Pillow 11.0.0 | works | works | works |
| sqlite3 (stdlib) | works | works — on disk, so fcntl locking, fsync and ftruncate are all exercised | works |
| Node | default | strict | pool (strict) |
|---|---|---|---|
worker_threads | works | works — a thread is a clone with CLONE_THREAD, which the child filter permits | works |
crypto + zlib + fs | works | works | works |
child_process | works | refused, which is the point of the profile | refused |
Measured on Linux 6.8 (aarch64, the Lima VM) on 26 September 2026, with the
same result as on Linux 5.10 (Docker Desktop), where it was first run.
“Works” means the handler
returned {"ok": true} from a real operation — a validation error caught, a
mean over a million numbers, a CSV grouped, an image resized and encoded, a
worker thread joined — not just that the module imported.
Checking the profiles themselves
make verify-seccomp-profiles-linux is the short form of the same idea, for
the profiles rather than the packages. From inside a sandbox, unshare
succeeds under permissive and returns EPERM under default, and socket
succeeds under default and returns EPERM under strict. The unit tests
prove the lists differ; this proves the sandbox does. make fuzz-linux is the
long form: every syscall number under every profile (see
chapter 23).
unit tests the lists in the code differ
verify-seccomp-profiles a real sandbox answers differently per profile
seccomp-matrix real packages do real work under each profile
fuzz-linux all 469 syscall numbers × 3 profiles
What the matrix found on its first run
The first run of the matrix found two bugs that made whole profiles useless. Both are fixed, and tests now hold the fixes in place.
Every threaded program was dead under default
clone3 was only in permissive, so under the other two profiles it returned
EPERM, like any other unlisted syscall. glibc’s pthread_create tries
clone3 first and falls back to clone only on ENOSYS; on EPERM it
gives up, and the program sees RuntimeError: can't start new thread. pip
starts a thread for its progress bar on any download over a few megabytes.
That is how the venv build of this very matrix failed at numpy’s 13.6 MB
wheel — and numpy itself starts several threads on import. The profile had
been “validated” against these packages earlier, but with a JSON profile
applied by a different tool, not with the filter that ships.
clone3 cannot simply be allowed. It takes its flags in a struct in memory,
which the filter cannot read, so a profile that checks clone’s namespace
flags cannot let clone3 through unchecked. It now answers ENOSYS, which is
what Docker’s profile does for the same reason, and glibc takes the clone
path, where the flags are checked.
pthread_create
│
▼
clone3(...) ──▶ filter ──▶ EPERM ──▶ glibc gives up: "can't start new thread" (before)
└─▶ ENOSYS ──▶ glibc falls back
│
▼
clone(flags) ──▶ filter checks: no CLONE_NEW* ──▶ ok
strict killed every function before its handler ran
The first version of strict removed the whole socket family, including the
calls that only move data: sendto, recvfrom, sendmsg, setsockopt. But
the agent talks to the supervisor over a socket it inherited, and a socket
it cannot recvfrom is a supervisor it cannot hear. So every strict
function died at start-up with “expected READY from the agent, got end of
stream”. Moving bytes on a descriptor a process was handed is not a new power;
opening one is. strict now removes creation and keeps transfer, and a
unit test holds the line in both directions.
The child filter
The sandbox’s own filter cannot remove execve. The launcher installs the
filter just before it execs the agent, so a profile without execve would be
a sandbox that cannot start. So that extra lock goes where it belongs: in the
agent’s forked child, which is already running the interpreter and never
needs another program.
Under strict, the supervisor builds a second filter program
(STRICT_CHILD_REMOVED
plus a flag check on clone). It hands it to the agent as
ZYGO_CHILD_SECCOMP: base64 of the raw struct sock_filter array, so an
agent in any language can install it without knowing a syscall number. The
reference agent decodes it once at start-up. Then, in every child, after GO
and before the handler, it installs it with one prctl(PR_SET_SECCOMP).
Filters stack, and the kernel takes the strictest answer, so the sandbox’s own
filter stays in force underneath.
sandbox (seccomp: strict)
┌───────────────────────────────────────────────────────────────────┐
│ agent / zygote reads ZYGO_CHILD_SECCOMP once at start-up │
│ │ fork() per request │
│ ▼ │
│ child ── GO ──▶ prctl(PR_SET_SECCOMP, child filter) ──▶ handler │
│ │
│ the child now has TWO filters; the kernel takes the strictest: │
│ sandbox filter (strict) │
│ + child filter (no execve, execveat, fork, vfork, │
│ no clone without CLONE_THREAD) │
└───────────────────────────────────────────────────────────────────┘
What the child can and cannot do
The child cannot execve or execveat: no new program. It cannot fork,
vfork, or clone without CLONE_THREAD: no new process. So a handler cannot
fork-bomb its way up to pids.max, and subprocess.run fails with
PermissionError at the clone (or at the execve, on a platform whose libc
uses vfork). It still can start a thread, which is a clone with the flag.
An agent that cannot install the filter fails the request rather than running
it unfiltered. A malformed value is a start-up error that the supervisor sees.
Which agents honour it, and how
The Python agent installs it directly: ctypes, one prctl, decoded once
at start-up. The Node agent cannot do that. Node’s standard library has no
FFI (a way to call C functions), and the filter must go in after the last
execve, so a launcher that installs it and then execs cannot work either.
So the Node agent does one of two things, and says which in READY:
seccomp— it loadsagents/node/zygo_child_seccomp.c, forty lines built with onecccommand, throughprocess.dlopen. Its constructor installs the filter before the “not a Node addon” error is thrown. The worker checks it by readingSeccomp_filtersfrom/proc/self/status, and refuses to run the request if the count did not go up. This is the real thing: a kernel filter that survives any code running inside the worker.node-permission— the image has no helper object, so the worker runs under Node’s own permission model instead: no child processes, no native addons, no WASI, and worker threads still allowed, because the filter it stands in for permits aclonewithCLONE_THREAD. It removes whatstrictasks to remove, but it is Node enforcing it, not the kernel, so a V8 escape gets past it where it would not get past a filter.
The sh example agent does neither, and refuses every request under
strict. That is the protocol’s other correct answer. zygo agent test
checks all of this rather than trusting it: it starts a second copy of the
agent with ZYGO_CHILD_SECCOMP set, asks the handler to start a program, and
fails an agent that runs the request anyway.
| agent | under strict | enforced by |
|---|---|---|
| Python | installs the child filter with ctypes + prctl | the kernel |
| Node, helper object in the image | loads zygo_child_seccomp.c, checks Seccomp_filters; READY says seccomp | the kernel |
| Node, no helper object | Node’s permission model; READY says node-permission | Node (weaker against a V8 escape) |
sh example | refuses every request | — |
strict is the default for a runtime pool
A [fn.<name>] gets default unless its author says otherwise, and a
[runtime.<name>] gets strict. The difference is who the child is. A
function’s child runs code that the function’s own author deployed, and
default is the profile that author chose by not choosing. A pool’s child runs
a script that arrived over an API, from somebody who may never have met the
operator.
A pool may name a lower profile — an operator may know their tenants — and then resolution prints a warning saying what that allows. The pool column of the matrix above is the same packages run as scripts in a pool, so this default is measured, not just claimed.
The filter goes on before the script’s first line
The filter is installed before the script’s first line, not only before
the handler is called. A script’s module body is request code too, and an
agent that loaded it first would give a strict pool nothing.
zygo agent test --script-spawn <file> is the check: a script whose module
body starts a program, run once without the filter to prove it can, and once
under it to prove it cannot.
The Rust and Python test suites, make verify-supervisor-linux (a
subprocess.run under default and then under strict, and a thread under
strict), and every strict cell of the matrix above all run the child under
the child filter.
What is not done
- The packages people will ask about next are not in the matrix: anything
with a JIT (
numba, PyTorch), anything that opens a browser, and on the Node sidesharpandaxios. Add a row before relying on the answer. permissivegrants eight of appendix B’s exclusions. That is what the profile is for — it is howdpkgbuilds a derived layer — but it meanspermissiveis an operator’s debugging tool, not something to hand a tenant. A test checks the exact list, so one more joining it is a decision, not a detail.io_uring_setup,io_uring_enter,io_uring_registeranduserfaultfdwere in that list until the escape suite started trying every appendix-B attack against all three profiles and found them reachable.
25. What Zygo costs: performance
This chapter lists every number Zygo publishes about itself: how long a request takes, how much memory a warm script uses, and where the time goes. Each number was measured by a command in this repository, on a real kernel, and each one names the machine it came from. You can run the same commands and check them.
The short version
All numbers were measured on 25 September 2026, on the code at commit
9607289, on the Lima VM unless marked. The machines
says why that machine.
what one request costs, usually (the median), on the Lima VM unless marked
──────────────────────────────────────────────────────────────────────────
warm function (a fork) 1.44 ms ▌
runtime pool (a fork) 1.91 ms ▌
warm-exec (a new process) 1.43 ms ▌
one-shot sandbox 12.3 ms █
vm backend, one-shot (Pi 5) 422 ms ████████████████████████████████████████
──────────────────────────────────────────────────────────────────────────
one █ is about 10.5 ms
| Usually | 1 in 100 | Where it was measured | |
|---|---|---|---|
| A warm request | 1.44 ms | 10.5 ms | Lima VM |
| A warm request from a pool, a different script each time | 1.91 ms | 11.4 ms | Lima VM |
| A warm-exec request | 1.43 ms | 4.3 ms | Lima VM |
| Sustained throughput through one warm function | 1,108 requests a second | Lima VM | |
| A one-shot sandbox, image already pulled | 12.3 ms | 15.3 ms | Lima VM |
A one-shot sandbox under a hardware boundary (vm) | 422 ms | Raspberry Pi 5 | |
| One more warm script, one zygote each | 11.1 MB | Lima VM | |
| One more warm script, in a runtime pool | 0.0 kB | Lima VM |
The “1 in 100” column is much higher than the usual one for the warm function
and the pool. That is a known kernel effect on Linux 6.x, not noise: with the
favordynmods setting zygo doctor --fix offers, both fall to about 3.4 ms
(why).
The rest of the chapter explains each line, and the embedder’s benchmark compares them with what you would do without Zygo.
Words used in this chapter
A few words come up again and again. Each is simple once named.
| Word | What it means |
|---|---|
| p50 (the median) | Sort all the request times. The one in the middle is p50: half the requests were faster, half slower. |
| p90, p99 | The time that 90 (or 99) requests out of 100 beat. p99 shows the slow “tail” that a few unlucky requests see. |
| min | The fastest single request. |
| overhead | The time Zygo adds around your code, with your code’s own time taken out. |
| throughput | How many requests are finished per second, when many are sent. |
| RSS (resident set size) | The memory pages a process holds in RAM, counting shared pages in full for every process that shares them. |
| PSS (proportional set size) | The same, but each shared page is split between the processes that share it. Two processes sharing one page count half each. |
| throttling | When a cgroup’s CPU limit is reached, the kernel stops the process until the next time slice. The CPU of a hot machine can also slow itself down; that is thermal throttling. |
| zygote | A warm process that already started the interpreter and loaded the code. Each request is a fork() of it (chapter 6). |
Why p99 and not just the average? Because the average hides the slow requests, and a user who waits for the slow one does not care about the average.
100 requests, sorted from fastest to slowest (each bar is one request's time)
request 1 ██████████
request 25 ███████████
request 50 ████████████ ◄── p50: half of the requests were faster
request 75 █████████████
request 90 ███████████████ ◄── p90: 9 in 10 were faster
request 99 ███████████████████ ◄── p99: 99 in 100 were faster
request 100 ████████████████████████████████ the slowest; p99 ignores it
The machines
Every millisecond in this book came from one of these three machines.
| Lima VM (the main one) | Raspberry Pi 5 | Docker Desktop’s VM | |
|---|---|---|---|
| Hardware | 2 vCPU, 4 GiB, of an Apple M1 Max | 4× Cortex-A76, 8 GiB, aarch64 | 5 vCPU, 8 GiB, of the same Mac |
| OS and kernel | Ubuntu 24.04, Linux 6.8 | Ubuntu, Linux 6.5 | LinuxKit, Linux 5.10 |
| How Zygo ran | an ordinary user under a systemd login, the binary on the VM’s own disk | an ordinary user, under a systemd session, everything in RAM (/dev/shm) | a privileged container, as root |
| What is measured here | almost everything: warm path, pool, warm-exec, one-shot, throughput, density, the embedder’s benchmark, bytecode, dependencies | the vm backend, warm-up times, and the older embedder’s table with kern | a check of the warm path on an older kernel |
Why Lima. Until 25 September most numbers came from Docker Desktop’s VM. Re-measured that day, it had become twice as slow at one-shot sandboxes (40.7 ms against the 18.4 ms once published) — and a build of Zygo from 20 September was just as slow there, so the machine had changed, not the code. Lima is closer to a real host: a normal Linux, a normal user, overlayfs. On Docker Desktop’s VM, the same day, the warm path was 1.55 ms usually and 2.60 ms for 1 in 100; the pool 2.01 / 3.20 ms; throughput 1,043 requests a second.
Two things to know about these machines
Unless a section says otherwise, a number is from the Lima VM. It is a virtual machine on a laptop with two virtual CPUs, not a server. A real server is usually faster; the numbers here are careful, not flattering.
Only one record is from an x86_64 machine, and it is a shared one. All
three hosts above are aarch64 (64-bit ARM). The bench workflow records
the same suite on GitHub’s x86_64 and arm64 runners — 4 vCPU of an AMD EPYC
9V45, 16 GB, Linux 6.17, on Azure — and the first record, from 26 September
2026, is in bench/results/.
It is a shared virtual machine, and zygo bench all says so in its verdict
(“the host was throttled or busy”), so it is a cross-check, not a
publication:
| x86_64 runner, 26 September 2026 | measured | the Lima VM’s number |
|---|---|---|
| a warm request, usually | 1.47 ms | 1.44 ms |
| a warm request, 1 in 100 | 1.97 ms | 10.5 ms |
| a pooled script, usually | 2.11 ms | 1.91 ms |
zygo run, usually | 17.9 ms | 12.3 ms |
| throughput at concurrency 4 | 790 requests/s | 1,108 requests/s |
The medians agree with the Lima VM’s within a third; the one-in-a-hundred
tail is five times shorter, which is what a 6.17 kernel with favordynmods
looks like (below). The arm64
runner, the same day, gave 1.52 / 1.78 ms for the warm path.
Reproduce any of the numbers:
zygo bench warm # the warm path, with a phase breakdown
zygo bench cold # a one-shot sandbox, start to finish
zygo bench load # sustained throughput through one warm function
Reproducing them below lists every flag and budget.
The warm path
A warm function is a sandbox that is already up. A request is a fork() into
it. You pay for three things: the fork, the cgroup write that admits the new
child, and the reply.
one warm request, from the supervisor's side
─────────────────────────────────────────────────────────────────
fork ───────► admit ──────► run (GO … DONE) ──► reply
copy the put the your handler the answer goes
zygote child in its runs; its time is back to the caller
own cgroup taken out of the
overhead
─────────────────────────────────────────────────────────────────
| Lima VM, 10,000 requests at 250 a second | |
|---|---|
| Usually (median) | 1.44 ms |
| 1 in 100 (99th percentile) | 10.5 ms |
| Sustained throughput | 1,108 requests a second, 4 clients |
These are overhead: the time Zygo adds around your handler, with the handler’s
own work taken away. zygo bench warm reports the two separately. It also
reports the host’s own fork() floor next to them, which is the time the
machine needs for a bare fork. So you can see how much of the number belongs
to the machine and how much to Zygo.
What the 1.4 ms is made of
zygo bench warm times each phase of every request. One run of 3,000
requests at 250 a second, on the Lima VM (Linux 6.8) inside a privileged
container, on 25 September 2026 — so a little noisier than the table above:
| phase | what happens | usually | 1 in 100 |
|---|---|---|---|
| fork | the agent copies the zygote, and says FORKED | 507 µs | 4.9 ms |
| admit | the supervisor makes the request’s cgroup and moves the child in | 156 µs | 9.7 ms |
| run | GO to DONE: the child wakes, reseeds, makes its temp folder, runs the handler, writes the answer | 496 µs | 4.1 ms |
| — of which the handler | an empty one | 17 µs | 85 µs |
| release | the request’s cgroup is removed, after the answer has gone | 26 µs | 217 µs |
| the whole request | 1.19 ms | 15.6 ms |
The same host’s bare fork() and wait() take 95 µs, so most of fork is
Python and the protocol, not the kernel. Without a cgroup per request
(--no-cgroup), admit drops to 27 µs and the median to 1.0 ms: per-request
containment costs about a fifth of the median, and most of the slow tail.
What is not in the number, because it happens once, when the zygote
starts (zygo serve: 34 ms on this VM with a supervisor running, about
150 ms on a Raspberry Pi 5 counting the supervisor’s start;
warming up has both):
- creating the namespaces and mounting the root,
/procand/tmp; - loading the seccomp filter and the Landlock rules — a child inherits both,
and only
strictadds a small filter of its own per request; - starting the interpreter and running your imports.
Why 1 in 100 is slow on newer kernels
On a stock Linux 6.x, 99 requests in 100 take about 1.5 ms and the last one
takes about 10. The phase breakdown shows where: 80–87% of that slow request
is admit, the step that puts the new process in its own cgroup. Moving a
process between cgroups takes one of the kernel’s locks for writing, and since
Linux 6.0 the first writer after a quiet spell waits for the whole kernel to
pass a quiet point — several milliseconds. Before 6.0 the kernel kept that lock
ready for writers all the time, which is why Docker Desktop’s 5.10 kernel does
not show it (1 in 100 there: 2.60 ms).
There are two ways to not pay it, and Zygo uses both:
- Do not move the process at all. A process created inside its cgroup
(
clone3withCLONE_INTO_CGROUP, Linux 5.7) never takes the lock for writing. One-shot sandboxes and zygotes have always been started this way, and since 25 September 2026 so is every warm-exec request. - Keep the lock ready for writers. A warm request on the agent path is
forked by Python inside the sandbox, where
clone3is refused by the seccomp filter on purpose, so it has to be moved. Mounting the cgroup file system with thefavordynmodsoption makes the move cheap.zygo doctorreports it ascgroup moves, andzygo doctor --fixturns it on, now and at every boot.
| Lima VM, Linux 6.8, 10,000 requests at 250 a second | usually | 1 in 100 |
|---|---|---|
| agent warm function, as the kernel comes | 1.61 ms | 10.3 ms |
agent warm function, with favordynmods | 1.61 ms | 3.4 ms |
| pooled script, as the kernel comes | 2.06 ms | 11.1 ms |
pooled script, with favordynmods | 1.99 ms | 3.3 ms |
| warm-exec, created in its cgroup, as the kernel comes | 1.43 ms | 4.3 ms (was 10.5) |
1 in 100 warm requests, Lima VM, Linux 6.8
─────────────────────────────────────────────────────────────────
agent, as the kernel comes ████████████████████████ 10.3 ms
agent, with favordynmods ████████ 3.4 ms
warm-exec, born in its cgroup ██████████ 4.3 ms
─────────────────────────────────────────────────────────────────
What favordynmods costs. It is a setting of the whole machine, not only
Zygo’s: every fork and every exit takes a slightly slower path through the
same lock. Measured on the same VM, a bare fork-and-wait went from 106 to
108 µs usually and from 206 to 230 µs for 1 in 100. That is why Zygo asks
before turning it on rather than doing it for you. Inside a container it is
the host’s setting, and doctor says so instead of offering a fix.
Warming up
Warming up is paid once, by zygo serve or the first zygo up. It is the cold
sandbox, the interpreter, and whatever the handler imports. After cold_after
the sandbox is dropped and the same cost is paid again.
warm-up of a Python handler, Raspberry Pi 5 (includes starting the supervisor)
────────────────────────────────────────────────────────────────
imports nothing 154 ms █████████████████████████████████
imports seven modules 185 ms ████████████████████████████████████████
────────────────────────────────────────────────────────────────
the seven: json, re, ssl, decimal, datetime, hashlib, urllib.request
Both numbers are the median of five, and both include starting the
supervisor, which the first serve does. With a supervisor already running
it is less: on the Lima VM, zygo bench warm warms a function in 34 ms.
(Before the bytecode layer, the same two took ~270 and
~470 ms: most of the seven imports’ cost was compiling them.)
Warm-exec
In warm-exec, the sandbox is held open but each request is a fresh process, not
a fork. This is the mode for a compiled program: no agent, no runtime, just
cmd. It costs 1.43 ms usually, and 4.3 ms for 1 in 100. Zygo creates
each request directly inside its cgroup, so the kernel tail
above does not reach it. Chapter 13
shows how to set it up.
A runtime pool
In a runtime pool the zygote holds no code at all. The script arrives with the request, and the forked child loads it. It costs 1.91 ms usually and 11.4 ms for 1 in 100. That was measured with a different script on every request: a thousand scripts, each called once before anything was measured.
One zygo bench all, Lima VM | usually | 1 in 100 |
|---|---|---|
| A warm function | 1.44 ms | 10.5 ms |
| A pooled script | 1.91 ms | 11.4 ms |
| What the pool costs | +0.47 ms | +0.83 ms |
Both rows come from one run on one host, so the difference belongs to the pool, not to the machine. Docker Desktop’s VM, the same day, agrees on the cost: +0.46 ms usually, +0.60 ms for 1 in 100.
warm function vs pooled script, usually, one run, Lima VM
────────────────────────────────────────────────────
warm function 1.44 ms ██████████████
pooled script 1.91 ms ███████████████████
────────────────────────────────────────────────────
one █ is 0.1 ms
That half a millisecond is the whole cost. It is writing the script into the
sandbox, and the child compiling and loading it.
zygo bench warm --pool --scripts 1000 reproduces it. The memory side — a
thousand scripts in one zygote, flat in the script count — is in
density with a runtime pool.
When the number is about your limits, not about Zygo
Every function has a cpu limit, its CPU quota. Drive a function past its own
quota and the 99th percentile becomes about 47 ms. That is the quota working,
not Zygo being slow.
The kernel counts CPU time in periods. A process that has used up its quota waits for the rest of the period before it may run again, and half a period is about 50 ms. A request that lands in that wait pays it.
cpu quota reached: the tenant waits for the next period
──────────────────────────────────────────────────────────────────
period 1 period 2
████████████████████░░░░░░░░░░░░░░░░░░█████████████████...
runs, uses its quota waits (throttled) runs again
▲
a request arriving here waits too: p99 ≈ 47 ms
──────────────────────────────────────────────────────────────────
zygo bench warm reads the tenant’s own CPU accounting. When the tenant was
throttled, it declines to judge the 99th percentile and prints
NOT MEASURED instead. That number would be about the limit, not about the
code. A benchmark that cannot tell you which one it measured is not telling you
anything.
A one-shot sandbox
zygo run builds a sandbox, runs a program and tears it down.
Lima VM, python3 -c pass, 50 runs | |
|---|---|
| Usually, image already pulled | 12.3 ms |
| 1 in 100 | 15.3 ms |
| Budget it was measured against | 50 ms |
The same with /bin/true instead of Python | 3.6 ms |
The /bin/true line is the sandbox alone: namespaces, cgroup, mounts
(chapter 6). The rest of
the 12.3 ms is Python starting.
Two first-time costs are not in it. The first run of an image also pulls it.
The first run on a kernel without unprivileged overlayfs also flattens the
image’s layers into one directory. zygo bench cold says which of those
happened, because a number that hides them is misleading. (Docker Desktop’s
VM is such a kernel; there the same run took 40.7 ms on 25 September —
see the machines.)
A one-shot sandbox on a systemd login
On a normal systemd login, zygo run costs more. The shell’s own cgroup cannot
hold a sandbox, so zygo run first re-executes itself inside a transient
systemd scope (a small cgroup that systemd makes on request). That costs
about 12 ms: a scope, a second process, and a cgroup tree that is thrown
away.
When a supervisor is running, zygo run hands the sandbox to it instead and
pays none of that:
zygo run python:3.12-slim python3 -c pass, usually, median of 15 | |
|---|---|
| in its own scope, no supervisor | 25.7 ms |
| the same, through a running supervisor | 13.5 ms |
| Measured on | Lima VM, Ubuntu 24.04, kernel 6.8 |
zygo run python:3.12-slim python3 -c pass, usually, Lima VM
────────────────────────────────────────────────────────────────
own scope ████████████████████████████████████████ 25.7 ms
through supervisor █████████████████████ 13.5 ms
────────────────────────────────────────────────────────────────
zygo run -v says which one happened: its timing line ends in
(through the supervisor) when it did. The full hunt for this cost is in
the other finding.
A sandbox with a hardware boundary
The vm backend boots a guest kernel under KVM (the Linux feature that runs
virtual machines) and runs the program inside it.
Raspberry Pi 5, zygo run … true, median of 7 | |
|---|---|
--isolation vm, image already in the store | 422 ms |
--isolation ns, same host, own systemd scope | 73 ms |
one-shot run, Raspberry Pi 5
────────────────────────────────────────────────────────
ns ███████ 73 ms
vm ████████████████████████████████████████ 422 ms
────────────────────────────────────────────────────────
That is about six times the cost of ns, in exchange for a kernel the tenant
does not share with the host. The first run of an image is several seconds
longer, because the store flattens it. That is all that is measured: the vm
backend has no warm path and no networking, so there is nothing else to time
yet (ADR 0002 says why).
Dependencies
A requirements file is built into a virtual environment (a venv: a folder
with its own Python packages) once. Every run and every function that names
the same file against the same image then shares it.
Lima VM, requirements.txt = requests | |
|---|---|
First build (the plan phase of the first zygo run --requirements) | 2.5 s |
| Every later run: finding the built venv | 1–2 ms |
first build ████████████████████████████████████████ 2516 ms
reused ▏ 2 ms
The build installs with the image’s own pip (pip --python <venv>) and skips
ensurepip. ensurepip put a second pip into every venv, and when this was
changed it cost 2.0 s of every build before a single package was installed.
The cache is keyed on the image’s digest and the file’s bytes. So two projects
with the same requirements share one build, and any edit makes a new one. The
same cache serves zygo run --requirements and zygo serve.
Chapter 15 shows how to use it.
Python bytecode
Python compiles each .py file to bytecode (a .pyc file) before it runs
it, and normally saves that file for next time. The official python:*-slim
images ship no .pyc files — 1097 .py files in python:3.12-slim’s standard
library and not one compiled. A sandbox’s root is read-only, so Python cannot
save what it compiles either. So every run compiled every module it imported
again.
So the first zygo pull or run of such an image compiles its standard
library once, inside a sandbox, into a layer of its own. On the Lima VM that
takes 3.2 s and makes an 18.6 MB layer for python:3.12-slim. The result is
served as <image>+bytecode.<key>. The .pyc files sit next to the sources
and are unchecked-hash: a layer never changes, so there is nothing to check
them against.
| Lima VM, the run phase, median of 7 | without the layer | with it |
|---|---|---|
python3 -c pass | 9.4 ms | 6.9 ms |
import re, json, hmac, hashlib, base64, datetime, urllib.request, urllib.parse, uuid, decimal | 164.8 ms | 34.9 ms |
import ssl | 65.7 ms | 17.1 ms |
importing ten common modules, Lima VM
────────────────────────────────────────────────────────────────
without bytecode layer ████████████████████████████████████████ 164.8 ms
with bytecode layer ████████ 34.9 ms
────────────────────────────────────────────────────────────────
An image that already has bytecode, or has no Python, is served as it is.
ZYGO_BYTECODE=0 turns the layer off. A build that fails is a warning, and you
get the original image — never a failed run.
On a Mac
Sandboxes are Linux. On macOS every command runs inside a Linux virtual machine
that Zygo manages. Crossing into it costs about 22 ms per command once the
VM is up. The shim goes over the SSH connection Lima already holds open
(ssh -F ~/.lima/zygo/ssh.config). It asks limactl for nothing unless that
connection is down — which is when the VM needs booting anyway.
The millisecond warm path is still reachable on a Mac: through the HTTP API or the SDKs. There the round trip happens inside the VM, and the hop is paid once by the connection, not once per request.
Where a one-shot run from a Mac spends its time
Median of nine, after a warm-up, on an M1 Max, 25 September 2026:
| supervisor running in the VM | no supervisor | |
|---|---|---|
zygo run python:3.12-slim true, from a Mac shell | 28.7 ms | 41.8 ms |
| the same, typed inside the VM — the sandbox alone | 6.2 ms | 16.4 ms |
zygo ps from the Mac — the pure hop, no sandbox | 23.6 ms | |
ssh -F … lima-zygo true — the connection alone | 10.9 ms | |
zygo --version — no VM at all | 5.4 ms |
one zygo run from a Mac shell, supervisor running, 28.7 ms end to end
──────────────────────────────────────────────────────────────────
◄──────── the hop into the VM: ~22 ms ─────────►◄ sandbox 6 ms ►
████████████████████████████████████████████████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
──────────────────────────────────────────────────────────────────
Before the shim used the SSH connection directly, the same run was
171 ms. About 148 ms of that was limactl shell (40–50 ms) plus a
limactl list per command to ask whether the VM was running. Both are gone
from the hot path.
Why run looks slow on a Mac
So “why is run 29 ms from my Mac when bench cold says 12?” has one
answer: the hop. A Linux host sees the 12. The same true through Docker
Desktop on the same Mac (docker run --rm python:3.12-slim true) took
397 ms. The hop is not being made faster, by decision
(ADR 0001 puts macOS latency on its “not now”
list). For anything that must be fast on a Mac, use the warm path through the
API; chapter 13
has the worked example of a multi-tenant consumer on the warm path.
What is not measured
- The
vmbackend beyond the one-shot cost above. There is no warm path to measure on it, and no network. - Anything across more than one machine. Zygo’s capacity is a per-host budget,
and a
429answer past it. - Receive-side bandwidth shaping (limiting how fast data comes in). It needs
an
ifbdevice the test hosts do not have. - x86_64 on a machine of the project’s own. The one x86_64 record is from a shared GitHub runner, a cross-check and not a publication, as said above.
Reproducing them
One command runs everything:
zygo bench all # or `make bench`, which does the container setup too
zygo bench all --quick # fewer runs: checks the harness works, not a measurement
It runs five steps, prints the machine it ran on, and then compares what it measured with the numbers published in this chapter. It allows a factor of two either way. A difference is not a failure: these numbers were taken on the machines above and yours is a different one. That is why the machine is printed next to the numbers.
zygo bench all
──────────────────────────────────────────────────────────────
1. warm a warm function at 250 requests a second
2. warm-exec the same, with `sh -c cat` as the command
3. pool a runtime pool, a different script each time
4. cold a one-shot sandbox, start to finish
5. load sustained throughput through one function
──────────────────────────────────────────────────────────────
then: print the host, compare with the published numbers
The single commands and their budgets
Each command has a budget: the number it must beat to print PASS.
| Command | What it measures | PASS when |
|---|---|---|
zygo bench warm | a warm function | p50 < 2000 µs and p99 < 10000 µs |
zygo bench warm -- CMD | warm-exec, running CMD per request | p50 < 3000 µs |
zygo bench warm --pool --scripts 1000 | a runtime pool | p50 and p99 < 5000 µs |
zygo bench cold | a one-shot sandbox | p50 < 50 ms |
zygo bench load | sustained throughput | ≥ 600 requests a second |
(1000 µs, microseconds, is 1 ms.) If the tenant hit its CPU quota during
bench warm, the p99 line reads NOT MEASURED rather than PASS or FAIL.
| Flag | Default | What it does |
|---|---|---|
warm --n | 10000 | how many requests |
warm --no-cgroup | off | serve without a per-request cgroup, to see what the cgroup costs |
warm --rate R | as fast as possible | offered load in requests a second; unset drives the tenant into its own quota |
warm --cpu C | the spec’s 1.0 | the tenant’s CPU quota, in cores |
warm --pool --scripts N | 1000 | measure a runtime pool cycling through N distinct scripts |
warm -- CMD | none | measure warm-exec with this command, e.g. -- sh -c cat |
cold --n | 50 | how many runs |
cold --image | python:3.12-slim | the image; it must already be pulled |
cold --command | start the interpreter and exit | the program to run |
load --seconds | 10 | how long to run |
load --concurrency | 4 | how many clients call at once |
load --cpu | none | the tenant’s CPU quota, in cores |
The raw records
make bench-record runs bench all and the warm path with and without a
per-request cgroup, and keeps what they printed as JSON in
bench/results/, one folder per run, named after the date, the
kernel and the architecture. A run that missed a budget is kept as well.
Records from Linux 6.8 and 5.10 on aarch64, and from GitHub’s runners on
6.17, are there now. The bench workflow makes the same record on GitHub’s
x86_64 and arm64 runners every week; those are shared VMs, so their numbers
are noisier than a quiet host’s, and a run that saw the machine busy says so.
The run behind the short version, as an ordinary user, is not among them: its raw output is not in this repository. The Lima record there is the same VM on the same day, inside a privileged container. It measured a warm request at 1.21 ms usually and 15.4 ms for 1 in 100, and 1,176 requests a second, against the 1.44 ms, 10.5 ms and 1,108 above.
Two things bench all does that most benchmarks do not
It lifts the tenant’s CPU quota for the throughput run, and only for that
run. With the spec’s default cpu = 1.0, a tenant hits its quota long before
the runtime is the limit, so the number would measure the quota. The latency
runs keep the default quota, because there the limit is part of what is being
reported.
It refuses to give a verdict on a disturbed host. Around the whole run it reads the CPU’s thermal throttle counters and the Raspberry Pi’s firmware flag. Before it, it reads the load average (how many processes wanted the CPU over the last minute). A number taken on a machine that was overheating or busy is a number about the machine. It exits 2, not 0 or 1, to say which kind of non-zero it is.
| Exit code | Meaning |
|---|---|
| 0 | every budget was met |
| 1 | a budget was missed |
| 2 | no verdict: the throttle counters rose, the Pi firmware flagged throttling, or the 1-minute load was above half the number of cores |
How these are kept honest
Four rules the benchmarks and test suites are built on. Each one was learned by getting it wrong first.
- A test attempts the thing, it does not inspect a setting. Reading a flag passes on a kernel that ignores the flag.
- A test does not disturb what it measures. Checking whether standard output is a terminal, through a pipe, measures the pipe.
- A latency measurement can say whether it hit a limit. See the CPU quota above.
- A negative check first proves the thing ran. “The connection was refused” and “nothing happened at all” look the same from outside, and only one of them is a result.
The embedder’s benchmark
The sections above measure Zygo against its own budgets. This one measures it against what an embedder would otherwise do. An embedder is a program, such as a workflow engine, that runs other people’s scripts and would use Zygo inside it. For an embedder, the number that decides is not Zygo’s overhead. It is the ratio to running a container per call.
This is the gate in ADR 0001: the warm fork has to be at least 10× under the best one-shot runner on an import-heavy script, or the product idea is wrong.
make bench-embed # or: sh tests/linux/bench_embed.sh --runs 100
What the embedder’s benchmark ran
| Host | the Lima VM: 2 vCPU, 4 GiB, Ubuntu 24.04, Linux 6.8, aarch64 |
| Image | python:3.12-slim, already pulled, the same one for every runner |
| Script | sixteen standard-library modules imported at module level, then a little XML, a hash and a UUID |
| Runs | 60 per runner, after 3 warm-up calls |
| Measured | the whole per-request command: process start, request, answer |
A runner here is one way of running the script: a warm fork, a fresh sandbox per call, or a fresh container per call.
The result on the Lima VM
| runner | usually (p50) | p90 | 1 in 100 (p99) | fastest |
|---|---|---|---|---|
zygo exec (warm fork) | 2.8 ms | 3.4 ms | 11.2 ms | 2.1 ms |
zygo run (a fresh sandbox per call) | 70.8 ms | 72.1 ms | 76.1 ms | 67.4 ms |
docker run --rm | 542.4 ms | 553.2 ms | 559.1 ms | 528.5 ms |
kern box | not measured on this host — see the Pi table below |
usually, per call, same script, same image, Lima VM
────────────────────────────────────────────────────────────────────────
docker run --rm ████████████████████████████████████████ 542.4 ms
zygo run █████▏ 70.8 ms
zygo exec ▏ 2.8 ms
────────────────────────────────────────────────────────────────────────
25× faster than the best one-shot runner. The one-time cost of getting
there — warming the function — was 114 ms. Two calls of zygo run would have
paid for it.
The ratio was 60× when this was first measured, on Docker Desktop’s VM
(6.4 ms against 384.7 ms, with docker run at 761.9 ms). It fell because the
one-shot path got five times faster — mostly the bytecode
layer, which stopped Python from compiling its standard
library on every run — not because the warm path got slower. The gate in
ADR 0001 is 10×, so it still holds with room.
A second host, with kern in it
This table is older: it was measured before the bytecode layer existed, and was not repeated on 25 September, to keep load off that machine (it is also a production server). Read its ratios, not its absolute numbers.
The same benchmark on a Raspberry Pi 5: 4× Cortex-A76, 8 GiB, Ubuntu 24.04,
kernel 6.5, bare metal, nothing else running. It includes
kern 0.10.0, fetched from its releases and
checked against the published .sha256. 40 runs each.
| runner | p50 | p90 | p99 | min |
|---|---|---|---|---|
zygo exec (warm fork) | 9.2 ms | 9.5 ms | 9.8 ms | 8.6 ms |
kern box | 924.1 ms | 943.8 ms | 954.6 ms | 915.6 ms |
zygo run | 940.1 ms | 5439.9 ms | 20582.6 ms | 923.5 ms |
docker run --rm | 40006.5 ms | 46684.4 ms | 51533.1 ms | 27922.8 ms |
p50 per call, Raspberry Pi 5 (docker left out: see below)
────────────────────────────────────────────────────────────────────────
zygo run ████████████████████████████████████████ 940.1 ms
kern box ███████████████████████████████████████ 924.1 ms
zygo exec ▌ 9.2 ms
────────────────────────────────────────────────────────────────────────
100× faster than the best one-shot runner, which here is kern. Two things in that table are about Zygo and are not flattering. They are the reason the table is printed in full rather than summed up.
On the Pi, zygo run’s slow tail is the SD card
kern and zygo run are the same speed at p50: 924 ms against 940 ms is a tie.
But zygo run’s p99 was 20 583 ms. That was chased down, and it is not a code
path in Zygo:
- Per-phase timing was added to
zygo run.zygo -v run …printstiming: plan … start … run …, and the same fields land in--outcome. In a slow run the whole stall is inplan: 1324 ms against 7.9 ms for a typical run.start(29 vs 10 ms) andrun(46.6 ms, the same) are untouched.planis everything before the sandbox exists: the image index, the layer and whiteout reads, the root directory’smkdir. It is the first disk I/O the process does, which is where a stalled filesystem journal is felt. - The same 50 runs with the data home on tmpfs (
/dev/shm, a filesystem in RAM) instead of the SD card: p99 152 ms, no slow run at all. zygo runitself writes about 2 KB per run to the card (measured from/proc/diskstatsacross ten runs). It is not causing the pressure; it is stuck behind it./proc/pressure/ioon this Pi readfull avg10=75%at the end of the benchmark: every task on the machine was blocked on disk I/O three-quarters of the time. It fell to ~1% within a minute of rest. The root filesystem is an SD card at 89% full that lost 9,699 sectors three days earlier, and/tmpand/var/log/journalare on it.
zygo run on the Pi: where a slow run's time went, compared with a normal run
──────────────────────────────────────────────────────────────────────
normal run slow run
plan 7.9 ms ▏ 1324 ms ████████████████████████████████
start 10 ms ▏ 29 ms ▌
run 46.6 ms █ 46.6 ms █
──────────────────────────────────────────────────────────────────────
plan is the first disk read; the card was stalled, so plan waited
So the honest reading is this. On a healthy disk, kern box and zygo run are
a tie. On this disk, anything that reads the disk first pays the tail, and
zygo run reads first. kern’s flatter p99 here is most likely because its
prepared rootfs cache touches the card later or less. That is a real property
and a likely one, but not one this host can measure fairly. The warm path’s
spread on the same card is 9.2 ms to 9.8 ms, because a fork touches no disk.
On the Pi, the Docker column is not a fair number
It is kept only because deleting it would be worse. Forty seconds for
docker run --rm on a Pi is not a believable measurement of Docker. It is a
measurement of this Pi’s storage under a cycle of creating and destroying
containers, and possibly of the four runners sharing the machine. Do not quote
it. The Lima figure above (542.4 ms usually) is the one to read.
What the two hosts agree on
This is the only claim being made. The gap is the interpreter. Every one-shot runner pays the interpreter on every call, including the fastest one. A warm fork does not pay it.
Reading it honestly: the gap is the interpreter
zygo run is 70.8 ms in the Lima table, while
the one-shot number for /bin/true is 3.6 ms. Both are
right. The ~4 ms is the sandbox; the other ~67 ms is CPython starting and
importing sixteen modules — even with the bytecode layer. A one-shot runner
that was infinitely fast would still take those ~67 ms on this script,
because the interpreter is the cost. Warming it is the only thing that removes
it. A fork is the only way to warm it without also keeping its state.
zygo run, 70.8 ms, Lima VM
──────────────────────────────────────────────────────────────────
██▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
█ the sandbox, ~4 ms ▓ CPython start + sixteen imports, ~67 ms
──────────────────────────────────────────────────────────────────
So the ratio is a property of the script, not of the runner. A script that imports nothing would show the three runners much closer together. A script that imports pandas and Pillow would show them further apart. Sixteen standard-library modules is the careful, low end of what a real script does.
2.8 ms is the CLI, not the API
The 2.8 ms includes starting the zygo program itself for every call, and
that program talking to the supervisor. An embedder does not pay that. It
calls zygo api over a unix socket, or links zygo-core, and gets the
~1.4 ms that zygo bench warm measures. The CLI number is
used here because it is the only thing docker run can be compared with.
This host is a small one
The Lima VM is a virtual machine with two CPUs on a laptop. A real server with more cores is faster at everything in the table, and a bare-metal kernel avoids some of the virtual machine’s costs. The ratio between the runners is what carries over; the absolute numbers are this machine’s.
Adding the kern column yourself
# fetch kern from its releases, check it against the published .sha256
ZYGO_BENCH_KERN=/path/to/kern make bench-embed
make bench-embed does not download kern. A benchmark that fetches and runs a
binary from the internet is not one to run unattended, so adding the column
takes one deliberate act.
The harness calls kern box, not kern run. kern run limits a process on
the host with no image and no namespaces. That is a different thing entirely,
and comparing it with zygo run would be an honest comparison of nothing.
What was predicted about kern, and what happened
Before it was measured, the prediction was: kern would beat zygo run on the
sandbox, and land in the same band as everything else on this script, because
it also starts a fresh interpreter per call. That was half right. It lands in
the same band (924 ms against 940 ms on the Pi). It does not beat zygo run
at p50. Its p99 is far better on that host, and
the SD-card section says why
that number belongs to the card rather than to either runner.
Density: what script number 501 costs
Density is how many warm scripts fit on one machine. This was the other half
of Phase 0 of the embedded-runtime roadmap, and the one that decides whether
the old shape could serve an embedder at all. An embedder does not have one
function. It has ten thousand scripts, most of them idle. A Zygo zygote was
then one function: entry imported, forked per request. So the question is
what one more warm script adds.
make bench-density ARGS="--scripts 32"
Thirty-two distinct scripts (a different constant in each source, so nothing can be shared as a duplicate), one image, one runtime, on the Lima VM:
| 32 warm | 672.5 MB resident, 366.5 MB proportional |
| Time to warm each | 114 ms |
| One more script, marginal RSS | 21.01 MB |
| One more script, marginal PSS | 11.14 MB |
(On Docker Desktop’s VM, when it was the reference, the same benchmark gave 16.39 MB and 9.98 MB.)
Why PSS, and why the slope
PSS is the honest figure. It divides each shared page among the processes that share it, so thirty-two interpreters of one image are not counted thirty-two times. RSS counts every shared page in full for every process, so it overstates.
The cost of one more script is taken from the slope between the first and the last checkpoint, not from the total divided by the count. The first zygote pays for the interpreter’s pages, and the thirty-second does not.
total PSS as scripts are added (shape, not to scale)
─────────────────────────────────────────────────────────
PSS │ ●
│ ●
│ ● slope = 11.14 MB
│ ● per extra script
│ ● ◄── the first pays for the interpreter
└──────────────────────────────────────── scripts
─────────────────────────────────────────────────────────
Extrapolated: one zygote per script does not scale
This is the number a SaaS company asks first:
| scripts | PSS |
|---|---|
| 1 000 | ~10.9 GiB |
| 10 000 | ~109 GiB |
This does not work, and that is the finding. Ten thousand scripts is a small platform, and a hundred and nine gigabytes is not one machine. Idle tiering does not rescue it either: a paused zygote is still a process holding its memory. (In this run nothing was tiered down inside the window at all, and the harness says so rather than reporting the warm figure as a paused one.)
Phase 1 of the roadmap (ADR 0001) is the answer:
a zygote per runtime instead of per script. The script arrives in the
EXEC message and the forked child loads it. Its exit criterion was this number
going flat: 1 000 distinct scripts, one runtime, and resident memory that does
not grow with the script count.
Density with a runtime pool
Phase 1 is built, so the same benchmark can be asked of it:
make bench-density ARGS="--pool --scripts 1000"
A thousand distinct scripts — the same distinct-constant sources — are each
registered once with PUT /scripts, then called by digest, through one
runtime pool. Every call goes over the HTTP API. That is the path an embedder
uses, and a zygo exec per call would measure process start-up instead.
| one zygote per script | one runtime pool | |
|---|---|---|
| 1 000 scripts, proportional memory | ~10.9 GiB (extrapolated from the slope) | 29.4 MB, measured |
| One more script | 11.14 MB | 0.0 kB |
| Zygotes | 1 000 | 1 |
memory for 1 000 distinct scripts, Lima VM
────────────────────────────────────────────────────────────────────────
one zygote per script ████████████████████████████████████████ ~10.9 GiB
one runtime pool ▏ 29.4 MB
────────────────────────────────────────────────────────────────────────
The slope is not “small”, it is zero. The checkpoint at 125 scripts and the checkpoint at 1 000 read the same 29.4 MB. The pool’s zygote holds an interpreter and a dependency set, and the scripts are never in it. That is the whole of what Phase 1 set out to change, and it is a measurement, not an argument.
The latency half, settled
zygo bench warm --pool --scripts 1000 # or `zygo bench all`, which runs it
A thousand distinct scripts, a different one on every request, each called once before anything is measured, at 250 requests a second. Phase 1’s exit criterion is a 1-in-100 time under 5 ms, with memory flat in the script count. Measured on both machines on the same day:
| 25 September 2026 | usually | 1 in 100 |
|---|---|---|
| Docker Desktop’s VM (Linux 5.10): a warm function | 1.55 ms | 2.60 ms |
| Docker Desktop’s VM: a pooled script | 2.01 ms | 3.20 ms — inside 5 ms |
| Lima VM (Linux 6.8): a warm function | 1.44 ms | 10.5 ms |
| Lima VM: a pooled script | 1.91 ms | 11.4 ms — outside 5 ms |
The memory half is met everywhere: the slope is zero. The latency half is
met on the older kernel and missed on a stock newer one — and there a warm
function, with no pool at all, misses it by the same amount. The slow 1 in
100 is the kernel’s cgroup move (why),
not the pool: with favordynmods on the same Lima VM, the pooled script’s
1 in 100 is 3.3 ms, inside the budget. What the pool itself adds is the same on both machines: about
half a millisecond usually, and under one millisecond for 1 in 100. It is
writing the script into the sandbox and the child compiling it; the phase
breakdown puts it in run (GO→DONE), where the load happens, not in
fork or admit.
The same thing measured badly, and why it looked like a failure
The first attempt measured the pool over the HTTP API, with a Python client making a thousand calls one after another. On a Raspberry Pi it reported p50 6.28 ms / p99 25.24 ms: outside the budget by a factor of five. What saved the conclusion was the control in the same run — a warm function, no pool involved. It measured 4.28 / 20.99 through the same client on the same host. If the control cannot meet a budget either, the budget is not measuring the thing under test.
The numbers are kept here because the pair is the point:
| Raspberry Pi 5, 1 000 scripts | p50 | p99 |
|---|---|---|
| Pool, by digest, over the API | 6.36 ms | 25.24 ms |
| Control: a warm function, same API, same host, same 1 000 calls | 4.28 ms | 20.99 ms |
| The pool’s own cost | +2.08 ms | +4.25 ms |
This was measured with the data directory on tmpfs, and repeated with it on
the Pi’s SD card: 6.28 / 26.10 against a 4.29 / 21.29 control. The two runs
agree to within a millisecond, which rules the storage out. That card has
stalled this machine on I/O before, and a tail measured on it is worth nothing
until something shows it was not the disk.
What that bad measurement really measured
Neither column is inside the 5 ms budget, including the one with no pool in it at all. So this tool measures a Python client making a thousand serial HTTP calls on a four-core machine. It does not measure the request path the budget was written for. The pool’s own cost is the difference, and even that reads high here (+2.08 ms, against the +0.47 ms the direct measurement finds), because HTTP variance lands in both columns.
It is kept as a warning about tools, and as the number an embedder calling over HTTP from Python will really see on a Raspberry Pi. It is not the number the phase gate is written in. (The same run inside Docker Desktop’s VM: p50 3.38 ms, p99 14.74 ms, slope 0.0 kB. On the Lima VM on 25 September: the pool 2.48 / 5.99 ms over the API, against a warm-function control of 1.54 / 2.38 ms, slope 0.0 kB.)
Memory per warm script on a smaller VM
ADR 0005 ran the density benchmark on the Lima VM earlier, with a hundred distinct Python handlers, each its own function. It agrees with the 25 September run above (11.14 MB proportional per script):
| per warm script | 100 scripts | |
|---|---|---|
| resident (RSS) | 21.4 MB | 2 143 MB |
| proportional (PSS) | 11.3 MB | 1 141 MB |
time to warm (zygo serve, round trip) | 109 ms | 10.9 s |
On that VM about 300 warm Python scripts fit in 4 GB with nothing else running, and a thousand would need 11 GB. The same hundred scripts in one pool used 29.5 MB, and one more script added 0 kB. The ADR has the latency side too.
The other finding: zygo run pays for a cgroup it throws away
This section and the three after it are the story of a fix, with the numbers measured while it was made. Today’s numbers are in a one-shot sandbox on a systemd login.
This is not part of the gate, but it came out of the same work. It is the
largest avoidable cost on the one-shot path, and avoiding it took a different
route than the obvious one. The whole investigation is written up in
crates/zygo-cli/src/scope.rs. Here is
the short version.
On an ordinary systemd user session, zygo run re-executes itself inside a
transient scope. It has to, because the session-N.scope a login lands in
cannot take a child cgroup. Broken down on an idle Ubuntu 24.04 VM, kernel 6.8:
| p50 | |
|---|---|
/bin/true | 0.30 ms |
systemd-run --user --scope … -- true | 5.14 ms |
zygo --version | 3.23 ms |
systemd-run --user --scope … -- zygo --version | 13.72 ms |
systemd-run --user --scope … -- zygo run … | 41.21 ms |
zygo run … | 50.31 ms |
p50, idle Ubuntu 24.04 VM, kernel 6.8
──────────────────────────────────────────────────────────────────────────
/bin/true 0.30 ▏
systemd-run --scope -- true 5.14 ████
zygo --version 3.23 ███
scope -- zygo --version 13.72 ███████████
scope -- zygo run … 41.21 █████████████████████████████████
zygo run … 50.31 ████████████████████████████████████████
──────────────────────────────────────────────────────────────────────────
ms; one █ is about 1.25 ms; "scope --" is systemd-run --user --scope --
A fresh cgroup is ~10 ms of overhead before Zygo does anything. The cgroup
operations are not where it goes: every mkdir and subtree_control write
is under 0.15 ms. Moving a process into a freshly made cgroup is 5.6 ms on its
own.
The obvious fix was built, and it cannot work
The obvious fix was to put zygo.slice under user@$UID.service, which
systemd already delegates (hands over to the user to manage). That makes the
layout last between runs, and the layout does work there. Tenants get
memory.max, pids.max and cpu.max, and the limits bite: exit 137 on OOM,
threads refused at the pids cap, both checked.
But getting into it is forbidden by cgroup v2’s delegation containment rule.
To move a process, you need write access to the common ancestor of the source
and the destination cgroups. That ancestor is user-$UID.slice, which is
root:root 644 on both hosts checked — an aarch64 VM on 6.8 and a Raspberry
Pi 5 on 6.5. The code was removed rather than left as something that never
runs.
moving a process from the login session into zygo.slice
─────────────────────────────────────────────────────────────────
user-$UID.slice (root:root 644 — you cannot write here)
├── session-N.scope ◄── the zygo run process starts here
└── user@$UID.service
└── zygo.slice ◄── where it wants to go
the common ancestor is user-$UID.slice, so the move is refused
─────────────────────────────────────────────────────────────────
Two more ideas, measured and rejected
systemd-run --slice=zygo.slice still pays for the scope. Caching the
zygo doctor host probe made no difference at all: 44.92 ms against 45.33 ms
over 90 runs each, taken in turns. The probe cache was kept anyway. The
supervisor probes once per function it warms, and an embedder warming five
hundred scripts was paying five hundred times.
What works: hand the sandbox to the supervisor
What works is reusing a cgroup that is already delegated and already built —
exactly what the supervisor holds. So now zygo run hands a one-shot sandbox
to a running supervisor. The client sends the spec and its flags, passes its
own three streams (stdin, stdout, stderr) over SCM_RIGHTS (a way to send open
files over a unix socket), forwards its terminal’s signals, and waits. The
supervisor starts the sandbox in the cgroup it already has. Exit code, stdin,
both output streams, --outcome, the deadline and OOM all come back as they
would have.
Same VM, same command, forty runs a round, two rounds:
| p50 | p90 | |
|---|---|---|
zygo run …, making its own scope | 45.9 / 43.0 ms | 50.1 / 49.5 ms |
zygo run …, a supervisor takes it | 30.4 / 29.0 ms | 32.9 / 33.8 ms |
A third of the command is gone. None of this ever touched an embedder: a
supervisor pays for its cgroup once, at start-up. It was zygo run at a
terminal that paid every time — and now it pays only on a machine where nothing
else is running.
Verdict of the embedder’s benchmark
Phase 0 of the embedded-runtime roadmap (ADR 0001)
passes its gate on every host it was measured on. It is 25× on the Lima
VM against zygo run today, was 60× on Docker Desktop’s VM before the
bytecode layer made one-shot runs faster, and 100× on a Raspberry Pi
against kern, the fastest one-shot runner in the field. The bar was 10×.
how much faster the warm fork is than the best one-shot runner
──────────────────────────────────────────────────────────────────
the gate ████ 10×
Lima VM, 25 Sep ██████████ 25×
Docker Desktop, earlier ████████████████████████ 60×
Raspberry Pi 5, earlier ████████████████████████████████████████ 100×
──────────────────────────────────────────────────────────────────
It also showed why Phase 1 had to come first. The warm fork is worth tens of one-shot runs, yet with one zygote per script you could have only a few hundred warm scripts per host. The thing that makes Zygo worth embedding was the thing it could not yet do at an embedder’s scale. The runtime pool (above) is the fix.
Two defects the benchmark found
Neither of them is in the warm path:
zygo runon an ordinary systemd session pays ~34 ms for a cgroup it throws away, and the obvious fix is forbidden by cgroup delegation containment. The supervisor hand-off above now avoids it when a supervisor is running.zygo runhad no per-phase timing, so a 20 s p99 on the Pi looked like a Zygo defect for a day. It has one now, and the p99 was the SD card.
Both are in the one-shot path. An embedder does not use that path; a developer at a terminal does.
Behind n8n’s Code node
n8n runs a Code node through a task runner, a process apart from n8n that
its task broker hands the code to. examples/n8n-runner
is a runner that sends each task to a Zygo runtime pool instead. This section
runs the same workflows through three stacks behind the same n8n, and reads
every part’s cgroup for CPU and memory.
n8n 2.38.7 ── broker ──┬─ stock: n8nio/runners, its own sidecar
├─ Zygo: zygo_runner.py → a pool fork per task
└─ box: n8nio/runners unchanged, in one Zygo sandbox
Measured on the Lima VM — 2 vCPU, 4 GB, Ubuntu 24.04, Linux 6.8, aarch64, on
an M1 Max — on 27 September 2026, Zygo at 970ccba, the kernel as it comes.
Every stack ran twice, the second round in the opposite order; the figures
are the mean of the two, and the widest gap between rounds was 18 ms. The
load came from inside the VM, from a cgroup of its own. Each Code node ran in
Python and in JavaScript: trivial returns a count, cpu loops, items
transforms 1,000 items, deps hashes with hashlib or crypto. n8n alone,
with no Code node, took 23 ms (1 in 100: 55 ms), so every figure below starts
there. sh run.sh in bench/ repeats all of it.
n8n: the two languages go opposite ways
One request at a time, the whole workflow, usually · 1 in 100:
| work | stock, Python | Zygo, Python | stock, JS | Zygo, JS |
|---|---|---|---|---|
| trivial | 213 · 277 ms | 36 · 65 ms | 27 · 73 ms | 49 · 77 ms |
| cpu | 230 · 305 ms | 55 · 73 ms | 34 · 110 ms | 55 · 71 ms |
| items | 233 · 284 ms | 74 · 95 ms | 50 · 117 ms | 84 · 109 ms |
| deps | 214 · 248 ms | 37 · 63 ms | 29 · 69 ms | 45 · 72 ms |
200 requests at once from 32 connections, finished per second, and CPU per task for the whole machine’s share of it (n8n included):
| work | stock, Python | Zygo, Python | stock, JS | Zygo, JS |
|---|---|---|---|---|
| trivial, per second | 7.6 | 32.1 | 26.5 | 23.6 |
| items, per second | 6.8 | 18.1 | 20.6 | 15.5 |
| trivial, CPU per task | 254 ms | 54 ms | 64 ms | 78 ms |
| trivial, CPU on the runner side | 199 ms | 4.8 ms | 7.3 ms | 28 ms |
In Python, Zygo is about six times faster one at a time, four times faster under a burst, and uses a fifth of the CPU. In JavaScript it is slower: 22 ms more per request and 11% fewer requests a second in a burst. None of the 16,800 requests failed.
n8n: why
- Stock Python pays about 200 ms of CPU per task. Its runner starts a fresh process per task from a fork server and sets up its language-level sandbox there; its own log says 98 ms for the one-line node, and its task executor alone, measured apart, takes 3–7 ms. Where the rest goes was not found. A Zygo fork of a warm interpreter costs 4.8 ms of CPU.
- Stock JavaScript pays almost nothing per task. Every task runs in one
Node process, in a
vmcontext of its own: no process is made. - Zygo’s JavaScript pays for a process per task. Node cannot be forked, so the agent starts a fresh Node worker for each request (chapter 13): about 25 ms of CPU, which on two cores is what limits the burst.
- n8n itself costs about 50 ms of CPU per task in every stack. On two cores it is the ceiling Zygo’s Python meets.
- The box, n8n’s runners inside one Zygo sandbox, is 35% faster than the stock sidecar in Python and level in JavaScript. Modules load about 20% faster from Zygo’s root than from Docker’s overlay2 (44 ms against 55 ms for the runner’s own); the rest of the Python gap was not traced.
n8n: the first run, memory, and hostile code
The first run after the runner side restarts, and after 30 s idle, trivial JavaScript · Python, usually:
| stock | Zygo | box | |
|---|---|---|---|
| after a restart | 986 · 506 ms | 61 · 45 ms | 1,007 · 353 ms |
| after 30 s idle | 975 · 496 ms | 56 · 38 ms | 1,011 · 352 ms |
n8n’s launcher stops a runner that has been idle for 15 s, so a workflow run now and then pays the second row on nearly every run. The same launcher inside the box does the same. It is also why the stock runner side holds 2 MB at idle, against 74 MB for Zygo’s two warm pools, API and runner; under a Python burst, the other way round: 222 MB against 85 MB.
Six hostile Code nodes, once each (probes.sh):
| stock, as shipped | stock, modules allowed | Zygo | box | |
|---|---|---|---|---|
| network: n8n, broker, LAN, internet | blocked (module refused) | all reached | blocked | blocked (module refused) |
| 2 GB allocated | no limit: succeeds, or the kernel kills the whole runner | succeeded | the task dies alone | the whole box dies |
| … with three tasks beside it | JS: all three died with the runner | all three finished | all three finished | all three died |
1 GB written to /tmp | refused (fs, open) | Python: open still refused | stopped at 64 MB | refused (fs, open) |
| JavaScript loop forever | other JS tasks wait 55–60 s | same | nothing else waits | same as stock |
“Modules allowed” is n8n’s own runner with NODE_FUNCTION_ALLOW_BUILTIN=*
and N8N_RUNNERS_STDLIB_ALLOW=*, which is how most users get a library into a
Code node. The box ran n8n’s runner config as shipped; with modules allowed,
an earlier round’s box reached the broker and nothing else. Whether stock’s
2 GB succeeds depends on what the machine has free: in an earlier round it
did; in this one the kernel killed the JS runner and every task in it.
n8n: what these numbers are not
- Not a server. Two virtual CPUs are shared by n8n, its database writes and the runner, and a vCPU that sleeps between requests takes time to wake: a pool call spaced 0.1 s apart took 11 ms where back-to-back ones took 3 ms. Zygo’s path has more hand-offs between processes, so it pays that more often. x86_64 and bare metal were not measured.
- Not the whole Code node. The Zygo runner lacks
this.helpers(HTTP and binary data),$node,$workflow,$envand static data; see the example’s README. - One n8n version. n8n 2.38.7 and
n8nio/runners:2.38.7, Node 26.7 and Python 3.13.15 in both; the numbers age with each n8n release.
26. Why it is built this way
Some choices in Zygo look strange until you know the reason: why the vm
backend has no warm functions, why there is no Deno agent, why an upgrade
throws the warm sandboxes away. Each of these was written down as an
Architecture Decision Record (ADR): a short, dated note of a question, the
answer, and what would change the answer. This chapter explains the nine ADRs
in plain words; each section links to the full record in adr/.
The nine decisions at a glance
| ADR | The question | The answer, in one line |
|---|---|---|
| 0001 | Who is Zygo for? | Programs that embed it to run many scripts — not a person typing commands. |
| 0002 | Should vm and gvisor have warm functions? | No. Warm functions are an ns feature. |
| 0003 | Should there be Deno and Bun agents? | No, until an embedder asks with a measurement. |
| 0004 | Can an upgrade keep the warm sandboxes? | No. An upgrade drains, restarts and re-warms. |
| 0005 | One warm zygote per script version? What evicts it? | Yes for heavy scripts, a runtime pool for light ones; timeouts evict. |
| 0006 | What does mem bound in a warm function? | One request, on its own cgroup — never the function as a whole. |
| 0007 | Where does an Elixir client live, and what does it need? | Here, as sdk/elixir; Mint and NimblePool; one error with a kind. |
| 0008 | May a sandbox listen on its own loopback? | A function may; a runtime pool, shared between tenants, may not. |
| 0009 | May what the API serves reach a private address? | Only when zygo api --allow-private-net says so; never a request body. |
how the nine decisions depend on each other
─────────────────────────────────────────────────────────────────
0001 the product is the warm path, for an embedder
│
├──► 0002 keep one warm path (ns), do not build three
├──► 0003 keep the agent list small enough to test fully
├──► 0004 restarts re-warm; keep one copy of the state
├──► 0005 how a real embedder should use warm paths
├──► 0006 one tenant's request cannot take the others down
├──► 0007 an embedder reaches it through an SDK in its own language
├──► 0008 a function may listen; a pool shared by tenants may not
└──► 0009 the operator, never a request, opens private addresses
─────────────────────────────────────────────────────────────────
Each ADR ends with the facts that would reopen it. None of them is “forever”. They say “not until this is true”.
How to read an ADR
An ADR is a historical record. It is written once, at the time of the decision, and not rewritten later. If a decision changes, a new ADR replaces the old one. So an ADR can mention files or plans that have since moved. The sections below are today’s plain-English summary; the ADR itself is the exact wording.
ADR 0001: Zygo is the embedded script runtime
The question
Zygo can be described in two ways. One is “a faster Docker”: one static binary, no daemon, no root, OCI images, a sandbox in about 12 ms instead of 300–1000 ms. That is true, but the field is crowded — kern, nono, microsandbox and Docker’s own sandboxes all compete there. The other is “the runtime a workflow engine embeds”: a program with ten thousand scripts in a database, each run a few times a minute. The question was which of the two is the product.
The decision
The product is the second one. The target user is an embedder: a workflow engine (Windmill, n8n, Temporal, Kestra), a SaaS running plugins its customers wrote, or an agent platform running generated code. Four things follow from that, in order of how often they come up:
- The warm path is the product. A change that costs the warm path milliseconds needs a much better reason than one that costs the one-shot path the same.
- The API is the surface, not the CLI. The CLI stays for debugging and demos.
- Tenants are strangers to each other, so isolation between tenants gets the effort.
- One process per worker, one host. No scheduler, no control plane, no Helm chart — the embedder has those already.
Why
Nobody else serves the “ten thousand scripts, each run often” shape. A warm
fork is the tool built for it: each request is a fork() of a process that
never served a request, so it is as clean as a fresh container and as cheap as
a fork. That is the one part another project would have to rebuild rather than
just make faster. The one-shot sandbox still matters, but mostly because the
warm sandbox is built out of it.
What it costs
Some things are put aside on purpose, so nobody reopens them by accident:
services, ports, compose files and restart policies; warm functions on vm
and gvisor (see ADR 0002); the macOS
shim’s latency; Windows; and a hosted service, which would compete with the
very users Zygo is for. Work that is good for a developer at a terminal but bad
for an embedder now loses. The macOS hop is the standing example: a developer
notices it, a production embedder never sees it.
The gates, and how they were met
The ADR sets a gate for each phase of the roadmap, so that a phase cannot be declared done by whoever did the work.
phase done when … status
─────────────────────────────────────────────────────────────────────────────────
0 prove the wedge warm fork ≥ 10× under the best one-shot runner met
1 runtime zygotes 1 000 scripts, < 5 ms, memory flat met
2 embedder API a plugin host needs only the HTTP API
3 runtimes Python and JavaScript through one API
4 deployability kubectl apply → green probe + agent test passes
5 hardening no "untested" rows for multi-tenant claims
6 integrations one outside project runs it in production
─────────────────────────────────────────────────────────────────────────────────
Phase 0 was a real gate: without the 10× ratio, the ADR would be wrong, not
early. It passed at 25× on the Lima VM — and at 60× on Docker Desktop’s VM
before the bytecode layer, and 100× on a Raspberry Pi in an older run that
chapter 25 has not
repeated. Phase 1 passed on the kernel it was set on: on Docker Desktop’s
Linux 5.10 VM even the slowest 1 in 100 calls took 3.2 ms, and a thousand
scripts in one zygote used 29.4 MB. On the Lima VM’s Linux 6.8 the same 1 in
100 is 11.4 ms as the kernel comes, and 3.3 ms once zygo doctor --fix has
turned on favordynmods; a warm function with no pool at all shows the same
tail, so it is the kernel’s cgroup move, not the pool.
Chapter 25 explains
the tail and has both measurements.
What would reopen it
The ADR names no single trigger; it stands as long as its Phase 0 gate holds. If the warm fork stopped being many times cheaper than the best one-shot runner on an import-heavy script, the idea behind the product would be wrong. Full ADR 0001.
ADR 0002: Warm functions stay on ns
The question
Zygo has three isolation backends behind one flag: ns (namespaces, cgroups,
seccomp and Landlock), gvisor (a kernel written in user space) and vm
(libkrun, a real hardware boundary). All three run one-shot sandboxes from the
same spec. Only ns runs warm functions. The question was whether the other
two should get warm functions too, or whether the gap was just unfinished work.
The decision
Warm functions are an ns feature. vm and gvisor run one-shot sandboxes
and refuse warm modes with a reason. That refusal is the design, not a gap.
Networking on vm is refused for the same reason. The error messages point at
this ADR instead of saying “not yet”.
Why
Both gaps come from how the backends are built, not from missing time:
- The agent gets its control socket as an inherited file descriptor. An OCI
runtime such as
runsccloses everything except stdin, stdout and stderr, and a guest VM inherits nothing from the host at all.gvisorwould needrunsc execinstead ofsetns;vmwould need the protocol carried over vsock and a supervisor inside the guest. - Networking on
vmwould need the VM monitor inside Zygo’s own network namespace, and the allowlist applied to a guest interface. That is a second copy of the network code with none of the first copy’s tests.
Each is weeks of work. Worse, each makes a second warm path that must be kept correct, measured and defended — next to the one the whole product rests on.
one-shot warm function network
─────────────────────────────────────────────────────────────
ns yes yes yes
gvisor yes refused —
vm yes refused refused
─────────────────────────────────────────────────────────────
What it costs
--isolation vm is a hardware boundary for work that fits a one-shot sandbox:
an untrusted build, a single tool call, a job with an input and an output. It
is not for a warm function serving many requests. An embedder who needs a
hardware boundary per tenant is not served by Zygo today;
chapter 10 points at an alternative.
The effort goes instead into hardening ns, which stays one kernel away from
the host (chapter 23).
What would reopen it
An embedder who asks for a hardware boundary per tenant, with a workload that can afford about 100 ms of boot per request. That is a different product shape from the warm fork. It should be thought through as its own thing, not bolted onto this backend just because the flag already exists. Full ADR 0002.
ADR 0003: No Deno or Bun agent until an embedder asks
The question
Zygo ships two runtime agents, Python and Node. A third-party agent can be added
with agent = { agent = "/path/in/sandbox" }. Deno and Bun are the obvious
next two: both are popular, both start fast, and both would look good in a
table. The question was whether to write agents for them.
The decision
No Deno or Bun agent. Anyone who wants either has two supported paths. One is a warm-exec pool, which works today with no agent at all, because both start in a few milliseconds:
[runtime.deno]
image = "denoland/deno:alpine"
cmd = ["deno", "run", "--allow-none"]
The other is to write their own agent against the protocol and check it with
zygo agent test (chapter 18).
Why
An agent is cheap to write and expensive to keep. Each one is a fork boundary
with four rules that must be exactly right: the child never returns to the
parent’s loop, nothing runs before GO, a broken frame is reported and not
fatal, and every EXEC gets exactly one answer. Each rule has been broken at
least once in this repository, by someone who knew the language well. Each
agent also needs its own answer to the per-request seccomp filter; Node’s
fallback was once stricter than the filter it replaced, which the seccomp
matrix caught. And each agent must sit in make conformance, the seccomp
matrix and the image matrix — a test nobody runs is only a claim.
Meanwhile Deno and Bun both run JavaScript, which the Node agent already
serves. Someone who asks for Deno usually wants its permission model, its
standard library or deno.json, not “something other than V8”.
What it costs
The comparison table says two agents, not four — the honest number. The test
matrices stay small enough to run on every change. A Deno user starts with a
three-line cmd and no protocol. Each request is slower than a fork from a
warm heap by the cost of an execve, not by the cost of an interpreter
start-up. The warm-exec pool gives up streaming, progress(), workspaces and
per-request tenant limits.
What would reopen it
Any one of three facts — none of them a guess about the future:
- An embedder asks, with a workload where the warm-exec pool’s per-request
execveis measurably too slow. The measurement is the argument, not the runtime’s popularity. - A dependency set needs it: supporting
deno.jsonorbun.lockbinPOST /deps, which is smaller work than an agent. - Someone writes an agent and it passes
zygo agent test, including the child-filter checks. The project would rather link to it than rewrite it.
ADR 0004: A supervisor upgrade re-warms; there is no --reexec
The question
Upgrading Zygo replaces the supervisor process, and its warm sandboxes go with
it. Could the new binary exec over the old one and keep them, so an upgrade
costs nothing? exec is the right tool to ask about: it keeps the process id,
so the sandboxes’ init processes stay children of the same process, and nothing
dies just because the binary changed.
The decision
No zygo api --reexec. A supervisor upgrade is a restart: drain, exit, start,
re-warm. min_warm and a rolling update with maxUnavailable: 0 make it
invisible to callers, and both already exist
(chapter 16).
an upgrade, with two replicas and maxUnavailable: 0
──────────────────────────────────────────────────────────────────────
old supervisor serving ████████████ POST /drain ▓▓▓ finish ▓▓ exit
new supervisor start ░░ re-warm ░░ ready ████████ serving
~150–185 ms per Python zygote
callers see: no dropped request; at worst, slower ones while warm-up runs
──────────────────────────────────────────────────────────────────────
Why
What would have to cross the exec is much more than process ids:
- Every file descriptor, on purpose. Each warm function holds an agent
socket; each sandbox holds seven namespace descriptors, a
/run/secretsdescriptor and a cgroup descriptor. All are close-on-exec (CLOEXEC) by design — once after a real bug, where thirteen descriptors leaked into every tenant program. A hand-over turns “everything closes unless something says otherwise” into “everything closes unless this list says otherwise”, and the list changes per function, per sandbox and per release. - All the state that is not a descriptor: every function’s spec, counters, logs, secrets, script leases, queue counts and idle clocks. That means a second, versioned copy of the whole supervisor state, which could go wrong silently.
- The requests in flight. Their answers are owed to client connections
tracked by a thread that
execdestroys. Keeping them means handing over the client sockets, each request’s cgroup, child and deadline, and rebuilding the router. Anything less drops requests — the one thing the feature was for.
What it costs
A Python zygote re-warms in about 150–185 ms on a Raspberry Pi 5, the
supervisor’s start included, and in 34 ms on the Lima VM with a supervisor
already running (chapter 25). When this was
decided, before the bytecode layer, it was about 500 ms (502 ms for a pool,
508 ms for a function, from tests/linux/api_driver.py). A Node zygote is
ready in 15–43 ms. So an upgrade costs min_warm × warm-up of cold pool per replica,
once. A single-replica deployment has a short window where requests are slow,
not failed; two replicas remove it, and the Kubernetes example uses two. In
return, descriptors stay CLOEXEC with no exceptions, and Zygo keeps only one
copy of its state: the running one.
What would reopen it
- A warm-up of tens of seconds, not a fraction of one. Even then, the better fix may be to make that warm-up faster.
- A deployment that cannot have two replicas, with a latency budget a cold
start breaks.
--reexecis one answer; a second supervisor on the same host behind a load balancer is another, and needs nothing new.
What does not reopen it: wanting upgrades to be free. They are already free of dropped requests, which is the part that matters. Full ADR 0004.
ADR 0005: One warm zygote per script version, and what evicts it
The question
The shape that forced the question is a multi-tenant application: tenant code
that changes whenever someone presses Save, hundreds of projects, each with its
own mounts, network allowlist and per-run secrets. Such a product starts with
zygo run, a sandbox per event, and asks three things. Is one warm zygote per script
version the intended shape? What evicts warm zygotes when a server has
four hundred projects? And can secrets and mounts vary under one warm zygote?
What was measured
A hundred distinct Python handlers on the Lima VM (Ubuntu 24.04, kernel 6.8,
aarch64, 2 vCPU, 3.8 GiB), python:3.12-slim:
| per warm script | 100 scripts | |
|---|---|---|
| memory (RSS) | 21.4 MB | 2 143 MB |
| memory, shared pages split fairly (PSS) | 11.3 MB | 1 141 MB |
| time to warm | 109 ms | 10.9 s |
| The same hundred scripts in one runtime pool | |
|---|---|
| memory, whatever the count | 29.5 MB, one zygote |
| what one more script adds | 0 kB |
| second call over the API: usually / 1 in 100 | 1.95 ms / 2.38 ms |
| a warmed function on the same host: usually / 1 in 100 | 1.36 ms / 1.57 ms |
| the pool’s extra cost | +0.5 ms usually, +0.8 ms for the slowest 1 in 100 |
So about 300 warm Python scripts fit in 4 GB on that VM, and a thousand would need 11 GB. Chapter 25 explains RSS and PSS.
The decision, part 1: which shape
One warm zygote per script version is right when the script’s imports are worth paying once — an ML model, a large client library. It pays those once and about 1.4 ms per call, and costs 11 MB while warm. A runtime pool is right when the script is a few lines over the standard library, like most such scripts. It pays about 0.5 ms more per call and nothing to stay resident, because the script is not kept; it arrives with each request. (Chapter 25 puts the pool’s cost at +0.47 ms on the Lima VM and +0.46 ms on Docker Desktop’s; the ADR quotes 0.6 ms, from its own earlier run.)
does the script import something expensive?
─────────────────────────────────────────────────────────────
yes ──► one warm zygote per script version
pays the imports once · ~1.4 ms a call · ~11 MB warm
no ──► a runtime pool
+0.5 ms a call · 0 kB per extra script
─────────────────────────────────────────────────────────────
The decision, part 2: what evicts
Eviction is idle_timeout and cold_after, per function, and nothing else in
the product. A version nobody called for idle_timeout (default ten minutes)
is paused: frozen, still in memory, one write to wake. Past cold_after
(default an hour) it is cold: the sandbox is dropped, only the spec is kept,
and the next call pays the 109 ms plus imports. An LRU list of warm scripts is
the consumer’s own policy on top, choosing which versions to serve at all.
max_warm is a different knob: a pool’s ceiling on its own zygotes under
load. When the host is full, the answer is a 429, not a swap storm.
a script version's life
─────────────────────────────────────────────────────────────────────
warm ──(no call for idle_timeout, 10 min)──► paused ──(cold_after, 1 h)──► cold
▲ │ │
└──────────── one write to wake ◄─────────────┘ │
└──────────── 109 ms + imports to warm again ◄───────────────────────────┘
─────────────────────────────────────────────────────────────────────
The decision, part 3: secrets and mounts
Per-run secrets can vary under one zygote. Their names are declared on the
function; their values arrive with the request and exist as
/run/secrets/<name> only while it runs, never in the zygote. Mounts and the
network allowlist cannot. They are the sandbox’s namespaces, built once at
warm-up. So one script version used by two projects with different mounts is
two functions, named {project}-{digest} — which matches the adopter’s own model,
where a script belongs to a project.
What it costs, and what would reopen it
A busy consumer keeps as many warm zygotes as were called in the last
idle_timeout, and zygo ps shows how many. The density benchmark must be run
again when the interpreter, the image or the kernel changes. Left open for a
later ADR: a global warm budget (max_warm_total) that evicts the least
recently called function when the host nears its memory limit. Nothing
measured says it is needed before idle_timeout and a 429 do their job; a
consumer who shows that need would reopen it.
Full ADR 0005.
ADR 0006: The memory limit is each request’s, not the function’s
The question
A warm function or a runtime pool runs several requests at once in one
sandbox: a zygote, and one process per request under it. Its mem limit was
written on the function’s cgroup, the group that holds the zygote and every
request together. So mem was one shared budget, and the kernel’s “kill the
whole group” setting sat at that level too. In a pool at mem = 256M, one
request that asked for 2 GB was killed — and so were the zygote and the
requests sleeping beside it, in the same moment. In a runtime pool those
other requests can belong to other tenants. The book promised the opposite:
that a request has its own group and can be killed alone. The question was
where mem should really be written.
The decision
mem goes on the leaves — the smallest groups at the bottom of the tree —
and nowhere above them. Each request’s own cgroup gets mem, with the
group kill turned on there. The zygote’s leaf gets the same limit, so the
warm process is bounded on its own. The function’s cgroup keeps the limits
that really are one budget for the whole function: processes, CPU and swap.
Its memory limit is set to “none” on purpose, so a folder left over from an
older Zygo does not keep the old shared limit.
BEFORE AFTER
┌─ function: mem, group kill ──────┐ ┌─ function: pids, cpu, swap ──────┐
│ ┌────────┐ ┌─────────┐ ┌───────┐ │ │ ┌────────┐ ┌─────────┐ ┌───────┐ │
│ │ zygote │ │ req (a) │ │ req b │ │ │ │ zygote │ │ req (a) │ │ req b │ │
│ └────────┘ └─────────┘ └───────┘ │ │ │ mem │ │ mem │ │ mem │ │
└──────────────────────────────────┘ │ └────────┘ └─────────┘ └───────┘ │
(a) goes over: all three die └──────────────────────────────────┘
(a) goes over: only (a) dies
What it costs
A tenant’s narrower limits still go on the request’s cgroup, in place of the
function’s numbers. A whole function may now use up to (concurrency + 1) × mem in each sandbox, not mem, so a host sized as “functions × mem” was
sized for the old rule; chapters 13 and 20 say so. Memory a request shares
with the zygote from the fork stays counted on the zygote, so only what a
request allocates after it starts counts against its own mem. No extra
ceiling was added above the leaves: it could only fire on memory the zygote
holds, and a kill there would again reach the wrong process. make verify-oom-linux checks the promise, for the Python and the Node agent: a
request that goes over dies, and the requests beside it finish.
What would reopen it
- A kernel that moves a process’s memory charge with it when it changes cgroup. Then a ceiling above the leaves would be exact, and worth adding.
- An embedder who wants
memto mean the whole function’s budget again, with a measurement of what the per-request shape costs them.
ADR 0007: A third SDK, in Elixir, in this repository
The question
The first product built on Zygo from Elixir ran each script by starting the
zygo binary, and about a thousand lines of it worked around that: stdin
through a shell, output order lost on macOS, a warm-function table behind a
global lock, errors guessed from their text. The HTTP API already answers
all of it. The question was where a client should live, what it should
depend on, and how it should report failure.
The decision
It lives in this repository as sdk/elixir, and is zygo_sdk on Hex. The
Python and Node clients already have a contract here — a stand-in API, the
method table in chapter 17, a test per OpenAPI operation, one version for
everything — and a client elsewhere would have to follow it by hand.
It depends on Mint, which is an HTTP connection as a plain value and opens a unix socket, and NimblePool, which lends one of those values to one process at a time. Erlang’s own HTTP client can reach a unix socket, but it keeps its connections to itself, so the rules the other clients test — drop a connection idle for 20 seconds, send again only when nothing came back — could not be kept.
Failures are one exception, Zygo.Error, with a kind such as :busy or
:handler. Python needs eleven classes because it branches with except;
Elixir branches on data, with case.
Python: except zygo.Busy as e: Elixir: {:error, %Zygo.Error{kind: :busy}}
except zygo.HandlerError as e: {:error, %Zygo.Error{kind: :handler}}
What it costs, and what would reopen it
The Elixir client has two dependencies where the others have none, and Mint
brings a third small one. make test-sdk runs three suites; without Elixir the
third says it skipped. Publishing needs a Hex key. An :httpc that let a
caller hold its own connections would let the client drop Mint.
Full ADR 0007.
ADR 0008: A function may listen on its own loopback; a pool may not
The question
From Linux 6.7 Zygo refused every TCP bind in every sandbox, and under
network = "none" every connect too, on the reasoning that no mode has
ingress so nothing should listen. n8n’s runner launcher could not start in a
sandbox: each runner listens on a local health-check port. Neither could
anything else that talks to itself over 127.0.0.1 — Jupyter, Ray, PyTorch’s
distributed runtime, a headless Chrome. And only on 6.7+; on Debian 12’s 6.1
the same spec worked. The question was what the rule was protecting.
The decision
Ingress is pasta’s job, done on every kernel: it forwards no port into a
sandbox, and under none there is no interface but loopback. The bind rule
protected one thing — a runtime pool, whose requests belong to different
tenants and share a namespace, where a loopback listener is a channel between
them. So the rule stays exactly there, and goes everywhere else: a function or
a zygo run sandbox may listen on its own loopback, and a sealed function is
bounded by its empty namespace rather than by a rule.
function: one tenant's namespace pool: shared between tenants
bind ✓ connect to self ✓ bind ✗ (strict, then Landlock)
from outside: nothing gets in from outside: nothing gets in
What it costs, and what would reopen it
Under egress the connect half of talking to yourself still meets the
allowlist’s port rule, so a function names the port it listens on. Two
tenants’ requests in one namespace outside a pool would need the flag set,
not the rule revisited. Landlock gaining an address scope for bind would let
pools listen safely too.
Full ADR 0008.
ADR 0009: The API may allow private addresses when its operator says so
The question
An embedder’s pool scripts had to call back to the embedder’s own service,
on an address in a private range. Chapter 14’s answer for that is an allow
rule naming the address and --allow-private-net typed by a person. A spec
file served by hand could have it, but nothing served over the API could:
the API always sent allow_private_net: false, so that no request body
could widen the boundary.
The decision
zygo api --allow-private-net sets it for what deploy callers serve. A body
still cannot. The person starting the API types it, as they type
--allow-deploy, and it does nothing without that. A rule still names one
address and one port. GET /version says private_net.
Two things found on the way are refused now, where they used to be accepted
and then fail on every request: a private address written without a prefix,
and a network under seccomp = "strict", which has no socket.
What it costs, and what would reopen it
Every deploy caller of such a listener may allow private addresses, not only the one that needed it. Deploy rights are already a shell as the API’s user, so this adds little. A channel for asking the caller mid-run, with no network at all, would reopen it, and so would deploy rights handed to parties the operator does not trust. Full ADR 0009.
ADR 0001 — Zygo is the embedded script runtime
A record of the decision as it was taken; the numbers in it are as of its date. Today’s numbers are in chapter 25.
Status: accepted, 2026-09-21. This is the decision the roadmap is the plan for; the roadmap says what and when, and this says who for and what that costs.
Context
Zygo can be described two ways, and only one of them is a product.
“A faster Docker.” One static binary, no daemon, no root, OCI images, a sandbox in 18 ms instead of 300–1000. This is true, it is what the README led with, and it is a crowded field: kern does the same thing and starts a box in single-digit milliseconds, nono confines a process without any of the machinery, microsandbox gives a hardware boundary per call, and Docker itself now ships sandboxes. Zygo is not the fastest of these and has no reason to become it.
“The runtime a workflow engine embeds.” A process that has ten thousand
scripts in a database, runs each a few times a minute, and can afford neither
a container nor a cold interpreter per run. Nobody is serving that shape. The
warm fork — a request costs a fork() of a process that has never served one,
so it is as clean as a fresh container and as cheap as a fork — is the only
primitive built for it, and it is the only thing here that another project
would have to rebuild rather than out-optimise.
The second is the product. Everything about the first is a means to it: the one-shot sandbox exists because the warm one is built out of it.
Decision
The target user is an embedder: a workflow engine (Windmill, n8n, Temporal, Kestra), a SaaS running customer-written plugins, an agent platform running generated code. Not a developer running a command.
What that decides, in order of how often it comes up:
- The warm path is the product. The README leads with it, the benchmarks compare against what an embedder would otherwise do rather than against a budget, and a change that costs the warm path milliseconds needs a reason that a change costing the one-shot path milliseconds does not.
- The API is the surface, not the CLI. An embedder’s worker talks to
zygo apior linkszygo-core. The CLI stays, because it is how the thing is debugged and demonstrated, but it stops being what the design is shaped around. - Multi-tenancy is a first-class concern, because an embedder’s customers are not each other’s. Tenant-versus-tenant isolation gets the effort that a per-request hardware boundary does not.
- One process per worker, one host. No scheduler, no control plane, no Helm chart. The embedder already has all three.
What is deprioritised, and why
Written down so nobody re-opens them by accident. This is the same list as the roadmap’s, with the reasoning kept here.
- Services, ports, compose, restart policies. A different product. kern and Docker both do it; Zygo runs functions, not servers.
- Warm functions on
vmandgvisor. A second warm path to keep correct, benchmark and defend, against the one everything rests on. See ADR 0002. - macOS shim latency. A Mac is where Zygo is developed and tested; Linux is where it runs. The hop costs about 22 ms per command, which a developer notices and a production embedder never sees. Keep it working.
- Windows. Embedders deploy on Linux.
- A hosted service. It would compete with the people this is for.
Exit criteria
Each phase of the roadmap has one; they are the same criteria, here, so that a phase cannot be declared done by whoever is doing it.
| Phase | Done when |
|---|---|
| 0 Prove the wedge | the warm fork’s p50 is at least 10× under the best one-shot runner on an import-heavy script, measured on one host, published with the commands |
| 1 Runtime zygotes | 1 000 distinct scripts on one runtime, p99 under 5 ms after each script’s first call, resident memory flat in the number of scripts |
Met. p99 2.92 ms with a different script on every request (zygo bench warm --pool --scripts 1000), and 29.4 MB in one zygote for a thousand scripts — a slope of 0.0 kB per script, against 9.98 MB for a zygote each. Both in bench-embed.md, both reproducible by make bench and make bench-density ARGS="--pool --scripts 1000". Re-measured 25 September 2026: 3.20 ms on Docker Desktop’s Linux 5.10 VM, 11.4 ms on the Lima VM’s stock Linux 6.8 and 3.3 ms there with favordynmods; chapter 25 explains why the tail depends on the kernel, not the pool. | |
| 2 Embedder API | a plugin host can be written against the HTTP API alone — no sandbox.toml, no files on the Zygo host |
| 3 Runtimes | the same plugin host runs a Python and a JavaScript plugin through one API with one set of limits |
| 4 Deployability | kubectl apply of the example on a stock cluster gives a green readiness probe and a passing zygo agent test inside the pod |
| 5 Hardening | the threat model has no “untested” rows for any multi-tenant claim |
| 6 Integrations | one external project runs Zygo in production for user scripts, publicly |
Phase 0 is a real gate. If the ratio is not there, the wedge is not there, and this ADR is wrong rather than early.
Consequences
- The README, the comparison doc and the benchmarks were all written for the first framing and have been rewritten for the second. That work is done.
docs/bench-embed.mdis the number this decision rests on, and it has to be reproducible by a reader on their own host — hencemake bench-embed.- The current zygote-per-function shape does not serve ten thousand scripts.
make bench-densityis how much it does not, and Phase 1 is the answer. - Work that is good for a developer at a terminal and bad for an embedder now loses. The macOS hop is the standing example.
ADR 0002 — Warm functions stay on ns; vm and gvisor stay one-shot
A record of the decision as it was taken; the numbers in it are as of its date. Today’s numbers are in chapter 25.
Status: accepted, 2026-09-21. Supersedes the “not built yet” wording in
docs/comparison.md, the backends’ own refusal messages
and the ## Status section of the README.
Context
Zygo has three isolation backends behind one flag: ns (namespaces, cgroups,
seccomp, Landlock), gvisor (a userspace kernel), and vm (libkrun, a
hardware boundary). All three run one-shot sandboxes from the same spec. Only
ns runs warm functions.
Both gaps are structural rather than unfinished:
- The agent is handed its control socket as an inherited descriptor. An
OCI runtime closes everything but stdio, and a guest inherits nothing from
the host at all. Warm functions on
gvisorwould needrunsc execin place ofsetnsand a different way to reach the agent; onvmthey would need the protocol carried over vsock and a supervisor inside the guest. - Networking on
vmwould need the VMM inside Zygo’s own network namespace and the nftables allowlist applied to a guest interface, which is a second implementation ofnet/with none of the first one’s tests.
Each is weeks of work, and each would make a second warm path to keep correct, to benchmark and to defend — against the one that the entire product rests on.
Decision
Warm functions are an ns feature. vm and gvisor run one-shot sandboxes
and refuse warm modes with a reason, and that refusal is the design, not a
gap. vm networking is refused for the same reason.
This is the decision the roadmap records under “Not planned”, and it is written here so that the backends’ error messages can point at something rather than saying “yet”.
Consequences
--isolation vmis a hardware boundary for work that fits a one-shot sandbox: an untrusted build, a single tool call, a job with an input and an output. Not for a warm function serving many requests.- An embedder who needs a hardware boundary per tenant rather than per
request is not served by Zygo today, and should read
docs/comparison.md. - The threat model’s statement that
nsis one kernel away from the host stands, and hardening it — uid separation, tenant-versus-tenant escape cases, the Landlock network rules — is where the effort goes instead.
Reopening it
An embedder asking for a hardware boundary per tenant, with a workload where 100 ms of boot per request is affordable. That is a different product shape from the warm fork and should be reasoned about as one, not added to this backend because the flag already exists.
ADR 0003 — No Deno or Bun agent until an embedder asks
A record of the decision as it was taken; the numbers in it are as of its date. Today’s numbers are in chapter 25.
Status: accepted, 2026-09-22. Records the decision taken in Phase 3 of the roadmap, and what would reopen it.
Context
Zygo ships two runtime agents, Python and Node, and a third-party agent can be
dropped in with agent = { agent = "/path/in/sandbox" }. Deno and Bun are the
obvious next two: both are popular, both start fast, and both would look good
in a table.
Neither has an agent, and the reason is what an agent costs to keep. The protocol is only the beginning of it:
- Each agent is a fork boundary that has to be got exactly right — the child
never returns to the parent’s loop, nothing runs before
GO, a malformed frame is reported rather than fatal, everyEXECgets exactly one answer. Each of those four rules has been got wrong at least once in this repository, in a language whose semantics its author knew well. - Each needs its own answer to
ZYGO_CHILD_SECCOMP. Node’s is a forty-line shared object plus a fallback to Node’s permission model, and that fallback was once stricter than the filter it stood in for — a bug the seccomp compatibility matrix caught. Deno has a permission model of its own with different edges; Bun has neither a permission model nor a documented way to reachprctl. - Each needs a place in
make conformance, the seccomp matrix, and the image matrix — which is where the real cost is. A test that is not run is a claim, and a claim about a runtime nobody is using is a claim nobody checks.
Set against that: Deno and Bun both run JavaScript, and the Node agent
already serves it. An embedder who wants Deno usually wants a specific thing —
its permission model, its standard library, deno.json — rather than “not
V8”.
There is also a shape that costs nothing. Deno and Bun both start in a few milliseconds, so a warm-exec pool (Phase 3.4) serves them today with no agent at all:
[runtime.deno]
image = "denoland/deno:alpine"
cmd = ["deno", "run", "--allow-none"]
Each request’s script is written into the sandbox and named on the command
line, with the event on stdin. What that gives up is streaming, progress(),
workspaces and per-request tenant limits — and for a runtime with nothing to
amortise, an agent would be buying those four things rather than warmth.
Decision
No Deno or Bun agent. Anyone who wants either has two supported paths: a
warm-exec pool, or their own agent against spec/protocol.md, checked with
zygo agent test.
Consequences
- The comparison table says two agents rather than four, which is the honest number.
make conformance, the seccomp matrix and the image matrix stay at a size where they are all run on every change.- A Deno user’s first experience is a
cmd, which is three lines and no protocol — and slower per request than a fork from a warm heap, by the cost of anexecverather than the cost of an interpreter start-up.
Reopening it
Any one of these, and none of them is a guess about the future:
- An embedder asks, with a workload where the warm-exec pool’s per-request
execveis measurably too slow for them. That measurement is the argument, not the runtime’s popularity. - A dependency set needs it:
deno.jsonorbun.lockbsupport inPOST /depsis a reason to have a runtime that understands them, and that is a smaller piece of work than an agent. - Someone writes one and it passes. A third-party agent that clears
zygo agent test— including the child-filter checks, which are the hard part — is an agent this project would rather link to than reimplement.
ADR 0004 — A supervisor upgrade re-warms; there is no --reexec
A record of the decision as it was taken; the numbers in it are as of its date. Today’s numbers are in chapter 25.
Status: accepted, 2026-09-23. Records the time-boxed investigation that closed Phase 4 of the roadmap.
Context
Upgrading Zygo replaces the supervisor process, and its warm sandboxes go with
it. The question was whether the new binary could exec over the old one and
keep them — passing the zygotes’ descriptors and pids across — so that an
upgrade cost nothing.
exec is the right primitive to ask about: it keeps the pid, so the sandbox
init processes stay children of the same process and their
PR_SET_PDEATHSIG still points at it. Nothing dies merely because the binary
changed.
What would have to cross the boundary, though, is not the pids. It is:
- Every descriptor, deliberately. Each warm function holds an agent
socket; each sandbox holds seven namespace descriptors, a
/run/secretsdirectory descriptor and a cgroup directory descriptor. All of them areCLOEXEC, on purpose and in one case after a bug (B-03: thirteen descriptors renumbered withdup2, which clears the flag, were inherited by every tenant program). A hand-over means enumerating exactly which to un-flag, which inverts the property the launcher leans on — “everything closes unless something says otherwise” becomes “everything closes unless this list says otherwise”, and the list is per function, per sandbox, per release. - The state that is not in a descriptor. The resolved spec of every function and pool, each one’s counters, logs, secrets and script leases, the gate’s in-flight and queued counts, the idle tiering’s clocks. Serialisable, but a second representation of the supervisor’s whole state, versioned across releases, and wrong in a way nothing would notice until a limit was not applied.
- The requests in flight, and their callers. This is the part that does
not reduce. A request’s answer is owed to a control connection — another
descriptor — and the mapping from request id to that connection lives in a
reply-router thread that
execdestroys. Keeping them means handing over the client sockets too, plus each request’s cgroup, child pid and deadline, and rebuilding the router around replies that may already be sitting in a socket buffer. Anything short of that drops requests, which is the one thing the feature exists to avoid.
Against that, what a restart actually costs:
- A pool re-warms at about 500 ms per zygote (
python:3.12-slim, measured intests/linux/api_driver.py: “a runtime pool is warm (1 zygote, 502 ms)”; a function, 508 ms). That was before the bytecode layer; the same warm-up is now 154–185 ms on a Raspberry Pi 5, the supervisor’s start included, and 34 ms on the Lima VM with one running (chapter 25). A Node zygote announcesREADYin 15–43 ms. min_warmbounds the window: the replacement warms before it serves.POST /drainmeans the old process stops admitting, finishes what it is running and exits — so no request is dropped by the restart itself. The Kubernetes example rolls withmaxUnavailable: 0, which means the new pod is ready before the old one is asked to leave.
So the cost of a restart is not dropped requests. It is a fraction of a second of warm-up per zygote, on a schedule the operator chooses, with a drain in front of it.
Decision
No zygo api --reexec. A supervisor upgrade is a restart: drain, exit, start,
re-warm. min_warm and maxUnavailable: 0 are what make it invisible to a
caller, and both already exist.
Consequences
- An upgrade costs
min_warm × warm-upof cold pool per replica, once, while the replacement comes up behind a drain. - A single-replica deployment has a window where the pool is cold and requests are slow rather than failed. Two replicas remove it; the example uses two.
- The descriptors stay
CLOEXECwith no exceptions, which is the property every “did this leak into the tenant’s program?” argument rests on. - Zygo has one representation of its own state — the running one. There is no serialised form to keep in step with it.
Reopening it
- A warm-up measured in tens of seconds, not half of one. A pool whose dependency set takes thirty seconds to import changes the arithmetic, and the fix might still be to make that faster rather than to keep the process.
- A deployment that cannot have two replicas — a single machine with a
single pool and a latency budget that a cold start breaks. That is a real
shape, and
--reexecis one answer to it; another is a second supervisor on the same host and a load balancer, which needs nothing new. - Note what does not reopen it: wanting upgrades to be free. They are already free of dropped requests, which is the part that matters.
ADR 0005 — One warm zygote per script version, and what evicts it
A record of the decision as it was taken; the numbers in it are as of its date. Today’s numbers are in chapter 25.
Status: accepted, 2026-09-23. Answers the open question the first adoption
report — a consumer’s own write-up, not in this repository — ends on, with numbers from
tests/linux/bench_density.py on the 2-core, 4 GB Lima VM.
Context
The shape that forced this decision is a multi-tenant application: arbitrary tenant
code that changes whenever somebody presses Save, hundreds of projects, each
with its own mounts and egress allowlist, and per-run secrets. Such a product
reaches for zygo run first — a sandbox per event, 171 ms from a Mac shell of
which ~23 ms is the sandbox — because the mapping is obvious, and then asks
whether zygo serve is meant for its shape at all. Three things had to be decided:
- Is one warm zygote per script version the intended shape?
- What is the eviction policy when a server has four hundred projects?
- Can per-run secrets and per-project mounts vary under one warm zygote, or do they force one zygote per (script, project)?
What was measured
make bench-density on the Lima VM (Ubuntu 24.04, 6.8, aarch64, 2 vCPU,
3.8 GiB), python:3.12-slim, a hundred distinct Python handlers, each
served as its own function:
| per warm script | 100 scripts | |
|---|---|---|
| resident (RSS) | 21.4 MB | 2 143 MB |
| proportional (PSS: shared pages divided among sharers) | 11.3 MB | 1 141 MB |
time to warm (zygo serve, round trip) | 109 ms | 10.9 s |
PSS is the honest per-script figure: a hundred zygotes of one image share
nearly all of the interpreter’s pages, and the slope is measured between
checkpoints rather than from the total. So on this VM about 300 warm
Python scripts fit in 4 GB with nothing else running, and a thousand
would need 11 GB. The cost of evicting one and warming it again is the
109 ms above plus the handler’s own imports; a paused script (past
idle_timeout) keeps its pages resident and costs one write to wake.
The same hundred scripts through one runtime pool (--pool), which is
the shape Phase 1 of the roadmap built for exactly this question:
| one pool | |
|---|---|
| resident, whatever the count | 29.5 MB, one zygote |
| what one more script adds | 0 kB |
| second call, over the API, p50 / p99 | 1.95 ms / 2.38 ms |
| a warmed function on the same host, p50 / p99 | 1.36 ms / 1.57 ms |
| the pool’s cost over a warmed handler | +0.6 ms p50, +0.8 ms p99 |
Decision
Yes, one warm zygote per script version is the intended shape for a consumer whose scripts have imports worth amortising — and a runtime pool is the intended shape for one whose scripts do not. The two are not in competition; the numbers say which applies:
- A version whose handler imports something expensive (an ML model, a large client library) pays that once per zygote and 1.4 ms per call. Its cost is 11 MB PSS resident while warm, and it is warm only while called.
- A version that is a few lines over the standard library — most such scripts — pays 0.6 ms more per call in a pool and nothing to be resident, because it is not: the pool holds the interpreter and the dependency set, and the script arrives with the request.
A consumer with four hundred projects does not have four hundred warm
zygotes; it has as many as were called in the last idle_timeout, and
zygo ps shows how many that is.
The eviction policy is idle_timeout and cold_after, per function, and
nothing else in the product. A version nobody has called for
idle_timeout (default ten minutes) is paused: frozen, resident, one
write to wake. Past cold_after (default an hour) it is cold: the
sandbox is dropped and only the spec kept, and the next call pays the
109 ms plus imports. An LRU over warm scripts, as examples/workflow-engine
keeps, is the consumer’s policy layered on top — it decides which
versions to serve at all, and if_changed=True makes asking free — and
the product does not second-guess it with a global cap. max_warm is a
pool’s ceiling on its own zygotes under load, a different knob. What the
product owes the consumer is the number to set the timeouts by, which is
the table above, and a 429 rather than a swap storm when the host is
full, which it has (capacity is a per-host budget).
Per-run secrets vary under one zygote; per-project mounts and allowlists
do not. Secrets are already per request by construction: the names are
declared on the function, the values arrive with the request and exist as
/run/secrets/<name> only while it runs, never in the zygote. Mounts and
the network allowlist are the sandbox’s namespaces, built once at warm-up,
so a project’s mounts and allowlist are the function’s, and one script
version shared by two projects with different mounts is two functions:
{project}-{digest}. That is the naming the guide’s worked example uses,
and it is what the adopter’s model already implies — a script belongs to a
project.
Consequences
- The guide documents the warm path as the production shape for a multi-tenant consumer, with the worked example, next to the one-shot section that consumers find first.
bench-densityis the number to re-measure when the interpreter, the image or the kernel changes; the ADR carries this VM’s, and a Linux host with more memory scales it linearly.- Not decided here, and worth an ADR of its own if a consumer needs it: a
global warm budget (
max_warm_total) that evicts the least-recently called function when the host is near its memory limit. Nothing measured above says it is needed beforeidle_timeoutand a429do their work.
ADR 0006 — The memory limit is each request’s, not the function’s
A record of the decision as it was taken; the numbers in it are as of its date. Today’s numbers are in chapter 25.
Status: accepted, 2026-09-26. Prompted by a defect found in the Lima VM (kernel 6.8) with two runtime pools at default limits.
Context
A warm function or a runtime pool serves several requests at once from one
sandbox: a zygote, and a process per request under it. Its limits were
written on the function’s cgroup — memory.max = mem, memory.oom.group = 1,
pids.max, cpu.max — and every generation, the zygote and every request sat
below that. The per-request cgroup carried only a tenant’s narrower limits,
when a tenant had any.
So mem was one budget for the zygote and every concurrent request
together, and the group kill sat at the level that held all of them. One
request allocating 2 GB in a pool at mem = 256M was killed by the kernel —
inside its own cgroup, as the log said — and so were the zygote, the Node
agent’s parked workers and the three requests sleeping beside it, in one
event. For a runtime pool those requests belong to different tenants. The
book said the opposite: “each request has its own group, so it can be killed
alone”.
Decision
mem is written on the leaves and nowhere above them:
- each request’s own cgroup gets
memory.max = mem(andmemory.highwhen swap is allowed,memory.swap.max,memory.oom.group = 1); - the zygote’s leaf gets the same, so the warm process is bounded on its own;
- the function’s cgroup keeps
pids.max,cpu.maxand the swap ceiling, which are one budget for the function, and hasmemory.max = maxandmemory.oom.group = 0written explicitly, so a function directory left by an earlier Zygo does not keep the shared limit it used to carry.
A tenant’s narrower limits still go on the request’s cgroup; they replace the function’s numbers there rather than nesting under a function ceiling.
What a whole function may use is therefore at most (concurrency + 1) × mem
per sandbox. The tenant budget above bounds the sum, as before. No
generation-level ceiling was added: with both leaves bounded it could only
fire on memory charged to the zygote before a forked child was moved into
its request cgroup, and a kill there would again be one that reaches the
wrong process.
Consequences
- A request over
memdies alone. The zygote stays warm; the requests beside it finish.make verify-oom-linuxruns three sleepers beside a 2 GB hog for the Python agent and the Node agent and checks exactly that. - The Node agent’s parked workers are moved into the request’s cgroup before
they run (
FORKEDcarries the pid, and nothing runs beforeGO), so a worker over the limit is the one killed; the parked ones, in the zygote’s leaf, are not. - Memory charged to the zygote’s leaf before a child is moved — the shared,
copy-on-write pages of a fork — stays charged there. Only what a request
allocates after admission counts against its own
mem. - A host sized as functions × mem was sized for the old budget. The new
bound per function is
(concurrency + 1) × mem; chapters 13 and 20 say so.
What would reopen this
- A kernel that lets a moved process’s charge follow it, which would make a generation ceiling exact and worth adding.
- An embedder that wants
memto mean the whole function’s budget again, with a measurement of what the per-request shape costs them.
ADR 0007 — A third SDK, in Elixir, in this repository
A record of the decision as it was taken; the numbers in it are as of its date. Today’s numbers are in chapter 25.
Status: accepted, 2026-09-26. Prompted by the first Elixir embedder, a
content-management product whose two drivers shelled out to the zygo CLI.
Context
The first product built on Zygo from Elixir ran every script by starting the
zygo binary: about a thousand lines across two drivers. Most of those lines
worked around being on the far side of a command line. Stdin went through
/bin/sh. On macOS the order of stdout and stderr was lost in the shim.
Whether a zygote had gone was guessed from the text of an error. A table of
which functions were warm lived in ETS behind a global lock, and every event
went through a payload file and a chmod.
zygo api already answers every one of those. It returns structured JSON, a
status and a code, a request id and Retry-After. It cancels, holds limits
and secrets per tenant, and has runtime pools — one pool, ten thousand
scripts, which is exactly that product’s shape. What was missing was a client.
Three questions followed: where it lives, what it depends on, and how it reports failure.
Decision
It lives in this repository, as sdk/elixir, and is published to Hex as
zygo_sdk. The Python and Node clients already set a contract here: a
stand-in API in place of a sandbox, the method table in chapter 17, one test
per OpenAPI operation, make test-sdk, make bump, and a release job that
refuses an SDK whose version is not the tag’s. A client in another repository
would have to follow all of that by hand. The cost is that its version is
Zygo’s (0.1.x), as the other two already are.
It has two dependencies, Mint and NimblePool. The other SDKs have none,
because their standard libraries have an HTTP client that a unix socket fits
under in thirty lines. Erlang’s :httpc can reach a unix socket, but only as
a setting of a whole profile — a process under inets, shared by every
request made through it — and its connection reuse happens inside it, out of
the caller’s sight. The rules the other two clients keep and test (drop a
connection idle for 20 s; send again only when nothing of an answer came
back) could not be held there. Mint reaches a unix socket per connection, and
it is HTTP/1.1 as a data structure rather than a process. A data structure can be lent, and
NimblePool is what lends it: one caller at a time, the socket’s ownership
moving with it, a caller that crashes taking its socket down with it. Both
are from the same authors as Finch; Mint brings one more small package,
hpax. req and finch were left out as too heavy for a library, and both
want a process started for them. JSON is the standard library’s, so the
floor is Elixir 1.18.
It has one exception, Zygo.Error, with a kind. Python has eleven
classes because except Busy: is how Python branches. Elixir branches on
data, with case and pattern matching, so the eleven become eleven atoms —
:busy, :handler, :timeout and the rest — in the same order and with the
same fields. Every function returns {:ok, value} or {:error, error}, and a
! twin is generated for each so the two cannot drift.
Consequences
- Chapter 17’s method table has an Elixir column, and its error table an
Elixir
kindcolumn. A test reads the error table and checks every row, so the book and the client cannot disagree without a failure. make test-sdkruns three suites. Without Elixir on the machine the third says it skipped rather than failing; CI has a job that installs Elixir 1.18 and a current release and runs it on both.- There is no function handle, as there is in Python (
client.fn(name)):fnis a keyword in Elixir, and&Zygo.call(client, name, &1)is one line. - A stream is a lazy
Streamwhose last item is{:result, _}or{:error, _}, not an iterator that raises. A caller that wants it to raise usesZygo.stream!/4. - Publishing needs a
HEX_API_KEYsecret, and the first version has to be pushed by hand to claim the name.
What would reopen this
- An
:httpcthat lets a caller hold and reuse its own connections, which would let the client drop Mint. - A second Elixir client worth more to its users than this one — at which point it is better to link to it than to keep two.
ADR 0008 — A function may listen on its own loopback; a pool may not
A record of the decision as it was taken; the numbers in it are as of its date. Today’s numbers are in chapter 25.
Status: accepted, 2026-09-26. Prompted by the n8n task-runner round, in which n8n’s own runner launcher could not start inside a sandbox because each runner listens on a local health-check port.
Context
Since Landlock’s network rules (ABI v4, Linux 6.7) Zygo has handled the
bind right in every sandbox with a network namespace and granted it
nowhere, so no sandbox could open a TCP listener — not on the network, and
not on its own loopback either. Under network = "none" the connect right
was handled and never granted too, so a sealed sandbox could not even connect
to itself. The reasoning was “no mode has ingress, so nothing should listen”.
Three things about that turned out to matter.
Ingress was never Landlock’s job. Nothing from outside reaches a port a
sandbox opens: pasta is started with --tcp-ports none --udp-ports none,
so it forwards no port in, and under none there is no interface but
loopback. That holds on every kernel. Landlock’s bind rule added nothing to
it — and only on 6.7 and later; a Debian 12 host (6.1) never had it.
The rule was TCP only. Landlock’s network rights cover TCP. A UDP listener and a unix socket were always allowed. So the rule was not “a sandbox does not listen”; it was “a sandbox does not listen on TCP”.
It broke programs that talk to themselves. Anything that uses a localhost
TCP port for its own plumbing — Jupyter kernels, Ray and Dask workers,
PyTorch’s distributed runtime, a headless Chrome’s DevTools port, a Java
debugger, n8n’s runner launcher, many test suites — failed inside a sandbox
on 6.7+ and worked on 6.1. The book described none as “an empty network
namespace with only loopback”, which a reader takes to mean loopback works.
What the rule did buy was in one place: a runtime pool. Its requests
belong to different tenants and share one network namespace, so a listener
on loopback there is a channel from one tenant’s request to another’s.
strict, the pool default, already removes the socket calls; Landlock’s
bind refusal is the layer under that, for a pool served with another
profile.
Decision
The bind right is handled — and never granted — only where the namespace
is shared between tenants: a runtime pool. A function’s own namespace,
and a zygo run sandbox, may bind and listen on TCP.
network = "none", function: Landlock handles neitherbindnorconnect. The empty namespace is the boundary. A program may listen on loopback and connect to itself; anything off loopback isENETUNREACH.network = "egress", function:bindis allowed;connectis limited to the allowlist’s ports as before, and that rule applies to loopback too. A function that talks to its own listener therefore names the port inallow(127.0.0.1:5681needs--allow-private-net), or it usesfullornone.network = "full", function:bindis allowed; Landlock handles nothing.- A pool, in every mode:
bindhandled and never granted, as before; undernoneconnecttoo.SandboxConfig::shared_namespacecarries the distinction from the resolved pool to the ruleset.
Nothing changes about ingress: pasta still forwards no port in, and under
none there is still nothing but loopback.
Consequences
- Stock software with local health-check or debugging ports runs inside a function’s sandbox. n8n’s runner launcher is the case that found this.
- A sealed function behaves the same on 6.1 and 6.8: its loopback works, the rest is unreachable. Before, it depended on the kernel.
- Two tenants’ requests in a pool still cannot open a channel to each other
through a port:
make escape-linuxcase 20 attempts it understrict(refused by seccomp) and underdefault(refused by Landlock, 6.7+). - Under
egress, self-connect is the one awkward corner; the book says so and gives theallowrule that opens it.
What would reopen this
- A function shape that runs several tenants’ requests in one namespace
without being a pool. Then
shared_namespaceis the flag to set, not the rule to revisit. - Landlock gaining an address scope for
bind, which would let a pool’s scripts listen on ports nobody else in the pool can reach.
ADR 0009 — The API may allow private addresses when its operator says so
A record of the decision as it was taken; the numbers in it are as of its date. Today’s numbers are in chapter 25.
Status: accepted, 2026-09-26. Prompted by the Elixir embedder of ADR 0007,
moving its scripts from the zygo CLI to runtime pools over the API.
Context
That product’s scripts read their own project’s tables while they run. A script asks, the application answers, and the script carries on. Under the CLI the question and the answer were files in a directory both sides could see. A runtime pool has no such directory: the script arrives with the request and nothing of the host is mounted into it. The answer that fits a pool is an HTTP call from the script to the application.
Chapter 14 already says how a sandbox reaches a service on its own host. The
service listens on an address the host really has, an allow rule names
that address and port, and --allow-private-net is typed by a person,
because every such address is in a private range. A spec file served by
hand can do all three. The API could do the first two and never the third:
PUT /fn/{name} and POST /runtimes sent the supervisor
allow_private_net: false whatever happened. The comment beside it gave the
reason: a caller that could widen the boundary over HTTP would make the flag
on the server meaningless.
Two findings came with it. An address written without a prefix,
192.168.1.70:8765, was read as a host name and passed the private-range
check, and the firewall then refused every connection to it. And a pool,
strict by default, had no socket at all, so a pool with an allowlist
reached nothing, allowed hosts included.
Decision
zygo api --allow-private-net sets allow_private_net for what deploy
callers serve, on PUT /fn/{name}, POST /runtimes and POST /run. The
reason in the old comment still holds and is kept: a request body cannot set
it, and deny_unknown_fields still refuses a body that tries. What changes
is that the server’s own command line is also “where the sandbox is
declared”. The person starting the API types the flag, as they type
--allow-deploy.
It widens less than it sounds. It is inert without --allow-deploy, and a
deploy caller can already serve any image with any mount as the API’s user,
which chapter 17 calls a shell. A rule still names one address and one port,
and the rest of every private range stays shut. GET /version reports it as
private_net, so an embedder can say “reads are off on this server” instead
of failing its first query.
A private address without a prefix is refused like a private CIDR. And
a network under strict is refused when the sandbox is declared. Both
used to be accepted and then failed on every request with nothing to say
why.
Consequences
- Chapters 14, 17 and 19 describe the flag, and chapter 17’s
/versiontable hasprivate_net. - A pool whose scripts call out names
seccomp = "default". It already had to in practice; now it is told so when it is served. - A spec that paired
network = "egress"withstrict, or allowed a bare private address without the flag, is refused where it used to be accepted. Neither ever worked.
What would reopen this
- A channel for a request to ask its caller something mid-run, through the
supervisor and the stream, with no network at all. That would suit a
network = "none"pool better than any allowlist. It is a protocol change across three agents, three SDKs and the API, and one embedder asking is not yet enough to design it well. - A deployment where deploy rights are handed to parties the operator does not trust. There this flag would need to be per token rather than per listener.
Glossary
One line each. The chapter in brackets explains the term in full; the numbers follow the book’s contents.
| Term | Meaning |
|---|---|
| ADR | “Architecture decision record”: a short note of one design choice, why it was made, and what it costs. [26] |
| agent | The small program inside a warm sandbox that loads your handler, forks per request and speaks Zygo’s protocol. [6] |
| backend | Where Zygo draws the wall: ns (host kernel), gvisor (user-space kernel), vm (virtual machine). [6] |
| bind mount | Showing an existing file or folder at a second place; how your code gets into a sandbox. [4] |
| blob | A tar file stored by the API under its hash, sent into a request as its workspace. [17] |
| blue/green | Replacing a running thing by warming the new one first, then switching; zygo up does this. [16, 19] |
| BPF | A tiny, safe program format the kernel can run; seccomp filters are written in it. [4] |
| capability | One named piece of root’s power, such as CAP_NET_ADMIN; a sandbox drops them all. [1, 4] |
| cgroup | A group of processes the kernel counts and limits together. [3] |
cgroup.kill | A file that kills every process in a cgroup in one write. [3] |
| chroot | The old way to change a process’s root folder; easy to escape, replaced by pivot_root. [4] |
| clone3 | The syscall that starts a child process, optionally in new namespaces. [2] |
| container | A process with namespaces, a cgroup and filters on it, plus a tool’s records about it. [5] |
| controller | The part of cgroups that handles one resource: memory, CPU, pids, I/O. [3] |
| copy-on-write | Sharing memory pages after a fork and copying one only when it is written. [1] |
| daemon | A program that runs in the background waiting for requests, such as dockerd. [5] |
| delegation | Giving a normal user one branch of the cgroup tree to manage. [3] |
| dependency set | A venv built once from lock files sent to POST /deps, for a runtime pool to use. [17] |
| deploy rights | Permission to create, change or destroy sandboxes through the API, not only call them. [17] |
| digest | sha256:…, the hash that names an image, a layer, a script or a blob exactly. [5, 15] |
| dynamic linking | A program that loads shared libraries such as libc.so from the machine when it starts; it needs those files inside the sandbox. [6, 12] |
| egress | Traffic going out of the sandbox; Zygo’s egress mode allows only listed names. [4, 6] |
exec (execve) | Replacing a process’s program with a new one from disk. [1] |
| favordynmods | A cgroup2 mount option that makes moving a process between cgroups cheap, at a small cost to every fork and exit; zygo doctor --fix can turn it on. [22, 25] |
| fork | Making a copy of the calling process. [1] |
| gVisor | A kernel written in Go that runs in user space and answers a sandbox’s syscalls. [10] |
| Hex | The package registry for Elixir and Erlang, as PyPI is for Python; the Elixir client is zygo_sdk there. [17] |
| image | A file system stored as layers, plus a little metadata; the OCI standard defines it. [5] |
| jail | FreeBSD’s kernel object that confines a group of processes. [9] |
| KVM | The Linux feature that lets it run virtual machines using the CPU’s hardware support. [10] |
| Landlock | A Linux feature that lets a process limit its own file and network access. [4] |
| layer | One tar file of changes in an image, named by the hash of its content. [5] |
| layer (of a spec) | One sandbox.toml table — [defaults], [fn.NAME] or [runtime.NAME] — with every field optional, merged over the one below; what the API’s PUT /fn/{name} and POST /run bodies carry. [17, 20] |
| Lima | The tool Zygo uses to run a Linux virtual machine on a Mac. [11] |
| MCP | Model Context Protocol: how an AI agent host starts and talks to a tool server; zygo mcp is one. [17] |
| microVM | A very small virtual machine that boots fast, such as Firecracker’s. [10] |
| namespace | A separate copy of one kind of kernel table — mounts, pids, network — for a group of processes. [2] |
no_new_privs | A flag that stops a process and its children from ever gaining power, even through setuid. [4] |
| OCI | The Open Container Initiative, and its standards for images, registries and runtimes. [5] |
| OOM killer | The part of the kernel that kills a process when memory runs out; with cgroups, only inside the group. [3] |
| operator | Whoever runs the Zygo host; an operator token may act for any tenant. [17] |
| OTLP | OpenTelemetry’s protocol for sending metrics to a collector. [17] |
| outcome file | The JSON zygo run --outcome writes, saying why a sandbox ended. [21] |
| overlayfs | A file system that stacks folders and shows them as one; how image layers become a root. [4] |
| p50 / p99 | The median time, and the time 99 requests in 100 beat. [25] |
| pasta | A program that connects a network namespace to the host’s network in user space, without root. [4] |
| paused / cold | A warm function after idle_timeout (frozen, still in memory) and after cold_after (dropped). [13] |
| pid | A process’s number. [1] |
pivot_root | Swapping a mount namespace’s root for a new one, so the old root can be removed. [4] |
| prefork pool | Worker processes forked from one that has already loaded the code, such as Python’s forkserver or gunicorn --preload; fast, with no sandbox of its own. [10] |
| PSS / RSS | Memory a process uses, with shared pages split between sharers (PSS) or counted in full (RSS). [7, 25] |
| registry | A server that stores images, such as Docker Hub or GitHub’s. [5, 15] |
| rlimit | An old per-process limit, such as the number of open files. [4] |
| root | uid 0, the user that passes most permission checks. [1] |
| rootless | Running without root at any point, thanks to user namespaces. [2, 6] |
| runtime pool | Warm zygotes holding an interpreter and no code; each request brings its own script. [13] |
sandbox.toml | The file that describes a project’s functions, pools and API. [20] |
| SBOM | “Software bill of materials”: the list of every library inside a binary, with versions, published with each release. [11] |
| seccomp | A filter on which syscalls a process may make. [4] |
| session sandbox | A sandbox that stays up for minutes or hours with its state kept between commands, as hosted agent platforms sell; the opposite of a call. [10] |
| setns | The syscall that joins a namespace that already exists. [2] |
| setuid | A mark on a program that makes it run as its owner, often root. [4] |
| static linking | A program that carries all its library code inside its own file, so it runs on almost any image. [6, 12] |
| supervisor | Zygo’s process that keeps zygotes, hands out requests and enforces deadlines, under your user. [6] |
| syscall | A request from a program to the kernel. [1] |
| tap | A GitHub repository of Homebrew formulas; brew install mhmtskrc2/zygo/zygo installs Zygo from one. [11] |
| task runner | n8n’s name for the process that runs a Code node’s code apart from n8n itself; n8n’s task broker hands it the tasks. [10, 25] |
| tenant | One customer of whoever embeds Zygo; has its own scripts, secrets, limits and tokens. [14, 17] |
| tmpfs | A file system in memory that disappears when no longer used. [4] |
| token | A secret a caller sends to the API to prove who it is: operator or tenant. [17] |
| uid | A user’s number; uid_map translates uids between a user namespace and the host. [1, 2] |
| user space | Everything outside the kernel: all normal programs. [1] |
| warm-exec | Zygo’s mode where the sandbox is kept ready and each request is a new process entered into it. [6] |
| WebAssembly (Wasm) | A portable instruction format that runs inside a runtime’s own process and can reach only what the runtime hands it; code must be compiled for it. [10] |
| workspace | A folder of files sent in with one request, and optionally returned with the result. [17] |
zygo.lock | The file zygo up writes to record which image digests and package versions were used. [20] |
| zygote | A process that has done its start-up and is forked for every request; the name comes from Android. [6] |