Set up gVisor
For: cluster operators.
Configure gVisor for container tools or node-owned sandboxes.
gVisor
gVisor runs a container's system calls in its own kernel, the Sentry, in user space, and its file access through a separate process, the gofer. A tool that escapes its MCP server reaches the Sentry's system calls, not the node's kernel. It is not a hardware boundary, and some software hits what gVisor does not implement (Tencent measured compatibility gaps in 1.7% of 74,379 agent tasks); test your images under it.
Tier 2 with gVisor covers tools from container images (MCP servers from container images) on a container engine, and harness sessions and command tools under the node's own runsc (gVisor without Docker).
Set up a node
On a Linux node with rootful Docker or Podman:
- Install gVisor's
runscfrom gVisor's own install instructions (on gvisor.dev), checking its SHA-512. Recent releases ship agvisor-binfolder besiderunsc; keep them together. - Register it and reload the engine:
On Docker below 24.0 (API 1.43), which cannot pass annotations, register a second runtime for tools with an allowlist:Terminal
sudo runsc install # adds a runsc runtime to /etc/docker/daemon.json sudo systemctl reload dockerFor Podman, putTerminalsudo runsc install --runtime=runsc-oz -- --host-uds=openrunscon the node'sPATHor name it:OZ0_OCI_GVISOR=/usr/local/bin/runsc oz0-node run. - Restart the node. It logs
containers: gVisor runtime=runsconce its test container ran, and the edge labels itisolation.gvisor=ready.
OZ0_OCI_GVISOR=off oz0-node run turns gVisor off on a node. Nothing in OZ0 downloads or updates runsc: the operator installs it.
| Node | gVisor |
|---|---|
| Rootful Docker 24.0 or later | yes, through the runsc runtime and an annotation |
| Rootful Docker below 24.0 | only with the second runsc-oz runtime |
| Rootful Podman | yes, through runsc on PATH or OZ0_OCI_GVISOR |
| Rootless Docker | no: runsc does not work there yet (gVisor issue 12575) |
| Rootless Podman | no: it runs runsc only by ignoring cgroups, so the container's memory, process and CPU limits would not hold |
| macOS, Windows, Docker Desktop | no |
How a tool runs under it
Everything a container gets on tier 1 of the container path stays (no network, a read-only root, no capabilities, no-new-privileges, the node's user, the limits; see How it runs). gVisor adds:
| Setting | Value |
|---|---|
| Runtime | --runtime runsc (or runsc-oz for a tool with an allowlist on old Docker) |
| Egress | only for a tool whose plugin declares egress: the annotation dev.gvisor.flag.host-uds=open, which lets the gofer connect to the egress proxy's socket and nothing wider; without it the gofer refuses host sockets |
| Limits | the tool's memory plus 64 MiB and its processes plus 64, since the Sentry and the gofer count against the container's cgroup |
| Label | org.oz0.isolation=gvisor |
The node creates the container, checks that the engine will run it under runsc (Docker's HostConfig.Runtime, and that the runtime of that name is runsc), and only then starts it. If runsc refuses the annotation (flag override disabled, an old runsc), the engine does not know the runtime, or names another one, the start fails and says so. It never retries without gVisor.
A node whose test containers fail at start gets a health finding named gVisor with the fix, and no label. One test container runs the node binary; the other, with an allowlist, reaches a socket of the node through the same bridge a tool uses for the egress proxy, so a runsc that ignores the annotation fails here and not in your tools.
The node checks again whenever a tool's container seems to fail because of the node rather than the tool: the engine no longer knows the runtime, runs another one, or runsc refuses the annotation (say, after an upgrade or a change to daemon.json). It then runs its own test containers again, and only if they fail too does it withdraw gVisor: the label goes, the gVisor finding appears, and oz0 node show says gvisor withdrawn, usually within five seconds.
A tool that fails for its own reasons withdraws nothing, even when what it prints or the name of its program looks like runsc's errors. After a test that passed, the node waits 15 s before it looks at the next such failure.
A withdrawn gVisor is tested again by itself: five minutes after the withdrawal, then after ten, twenty, forty minutes and every hour while the test fails. When a test passes, the node offers gVisor again and the label comes back. Each test updates the gVisor finding and the Tier 2 line (gvisor withdrawn: ... ; probed again at <time>), so you see when the next one runs. Fix runsc and wait for the next test, or restart the node, which tests at once.
The node also ignores a runtime entry that would make gVisor weaker than reported, when it starts and before every container: runtimeArgs with --ignore-cgroups or --rootless (no limits), --allow-flag-override, --network=host, a --TESTONLY-* flag, or a --host-uds other than open; and a containerd shim entry ("runtimeType": "io.containerd.runsc.v1") with options, since its flags are in a runsc.toml the node does not read. Its reason names the entry. A tool without an allowlist never runs with --host-uds=open, so a Docker that has only the runsc-oz entry offers no gVisor until a plain runsc entry is registered too (sudo runsc install).
A tenant that requires it
A tenant admin can require gVisor for the tools from images of every job an outside event starts (a webhook, a routine's event), also for tools whose manifests ask for no isolation (one that asks for another keeps it):
oz0 tenant sandbox-isolation acme --oci gvisor
# The OCI tools of tenant acme's outside-started jobs run under gvisor.
# 2 of the tenant's accepted nodes offer it, 2 of them with sandbox tier 1.
oz0 tenant sandbox-isolation acme --oci none
Such jobs run their tools on the job's tier-1 node. One that offers no gVisor fails those calls with must run under gVisor, and this node offers none, and never runs them without it, so give the tenant's tier-1 nodes gVisor first; the command counts them. The setting is audited (tenant.sandbox_isolation) and reaches the nodes with their plugin set within seconds.
gVisor without Docker
A node can also run harness sessions and command tools under gVisor, without a container engine: orchestrator-zero-node sandbox run --isolation gvisor runs the command in rootless runsc instead of bubblewrap. An agent asks for it in its definition, a command tool in its manifest:
runtime: harness:claude-agent-sdk
sandbox:
isolation: gvisor # its sessions, their terminals and its verify script run under gVisor
tools:
- id: runner
mcp: [python, -m, runner.server]
isolation: gvisor # a command tool: under the node's own runsc
Such agents, and the agents that call such tools, require isolation.gvisor-sandbox=ready. A node offers it when it has sandbox tier 1 with bubblewrap on Linux, a runsc (on its PATH, or named with OZ0_SANDBOX_GVISOR=/path/to/runsc; off turns it off), and a way to hold sandboxes to their limits (a delegated cgroup or a systemd scope, as sandbox check shows): rootless runsc holds no limits of its own, so without them a gVisor sandbox would be weaker than tier 1, and the node does not offer it. It logs gVisor sandboxes ready once a test command ran under it.
| Under gVisor without Docker | |
|---|---|
| Files | the same binds as tier 1: the system's folders and the read paths read-only, the work and write folders writable, the protected paths read-only, /tmp and /run empty. The root is an empty read-only folder |
| Network | loopback only (--network=none); the egress proxy and the forwarded ports are host sockets the node binary's bridge reaches (--host-uds=open), as under bubblewrap |
| Limits | the whole of runsc (the Sentry, the gofer and the command) runs inside the sandbox's cgroup or systemd scope, with its memory plus 64 MiB and its processes plus 64; a session that runs out of memory is stopped with all of it |
| Environment | exactly the session's or the tool's variables and the proxy's, handed to the bridge on a descriptor: none in a file, none on a command line; OZ0_SANDBOX_KIND=gvisor |
| User | gVisor's root without capabilities, which the node sees as its own user, or on a node that runs as root as the plugin's user of its own. A tool that refuses to run as root needs to be told it is in a sandbox |
| Secrets | on a file descriptor, from the host API inside the sandbox, as on tier 1 |
Every sandbox of such an agent's session runs under gVisor: the session, each terminal the agent opens through ACP (where its shell commands run), and the verify script and git reads that run over what it wrote. A remote agent (runtime: remote:) cannot ask for it.
What differs from bubblewrap: gVisor's Sentry is the system-call filter (tier 1's seccomp profile is bubblewrap's), and devices and a sandbox on the host's network (the plugin sandbox) are not supported: such a session or tool does not start. On a node that runs as root, a plugin's gVisor sandbox runs runsc as the plugin's user, rootless, with the sandbox's own folders given to that user; the node's own sessions run as gVisor's root, which the host sees as root.
Protected paths are bound from the files the node checked, as bubblewrap's are. runsc opens its mounts itself and takes no descriptor, so the node binary stages them first: a process of its own, in a user and mount namespace of its own (its user mapped to itself), opens each protected path beneath the work folder without following a link, attaches a read-only copy of that very file's mount to a staging path in the sandbox's private folder, and only then starts runsc, which binds the staging paths.
A process that swaps a protected path for a link while the sandbox starts gives it the file that was checked, never the link's target.
This needs Linux 5.12 or later (open_tree, move_mount, mount_setattr). The node's test of its gVisor sandboxes protects a path too, so on an older kernel the test fails and the node offers no gVisor sandboxes (no isolation.gvisor-sandbox=ready label), rather than take sessions it could not start.
A gVisor session's terminals still protect only the paths the session holds read-only from its start, which nothing inside it can rename, remove or replace, so a .git path the session creates later is protected in neither (as in the session itself).
A runsc that cannot make or start the sandbox fails it closed, never running it under bubblewrap instead, and the node tests its gVisor sandboxes as it tests a container engine's: a failed test withdraws them (finding gVisor sandboxes, the label goes), and they are tested again on the same backoff.
What is tested
OZ0_E2E_GVISOR=1 make e2e-oci-tools runs the container test again with a node on a Docker daemon in a docker:dind container, with a pinned runsc release (checked against gVisor's published SHA-512) registered in it: the node probes and reports gVisor (including the socket check), a tool with an allowlist reaches the allowed host through the egress proxy and is refused another, a tool without one has no network, a node with only runc leaves the tools out, and a container with the proxy's socket but without the annotation cannot reach it.
A withdrawal is followed by the node's own test again, which offers gVisor once the runtime is back.
A second node runs the same runsc without Docker: a harness session and the terminal it opens run under it (OZ0_SANDBOX_KIND=gvisor), a session that allocates far past its limit is stopped by the node's scope, a runsc that fails withdraws the gVisor sandboxes, and the node's test offers them again when it works.