---
title: "Set up gVisor"
description: "Configure gVisor for container tools or node-owned sandboxes."
canonical_url: "https://docs.oz0.ai/fleet/sandbox-tier-2/gvisor"
---
# Set up gVisor

> Configure gVisor for container tools or node-owned sandboxes.

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](https://docs.oz0.ai/build/plugins/container-images)) on a container engine, and harness sessions and command tools under the node's own runsc ([gVisor without Docker](https://docs.oz0.ai/fleet/sandbox-tier-2/gvisor#gvisor-without-docker)).

### Set up a node

On a Linux node with rootful Docker or Podman:

1. Install gVisor's `runsc` from gVisor's own install instructions (on gvisor.dev), checking its SHA-512. Recent releases ship a `gvisor-bin` folder beside `runsc`; keep them together.
2. Register it and reload the engine:
```bash [Terminal]
sudo runsc install                 # adds a runsc runtime to /etc/docker/daemon.json
sudo systemctl reload docker
```


On Docker below 24.0 (API 1.43), which cannot pass annotations, register a second runtime for tools with an allowlist:
```bash [Terminal]
sudo runsc install --runtime=runsc-oz -- --host-uds=open
```


For Podman, put `runsc` on the node's `PATH` or name it: `OZ0_OCI_GVISOR=/usr/local/bin/runsc oz0-node run`.
3. Restart the node. It logs `containers: gVisor runtime=runsc` once its test container ran, and the edge labels it `isolation.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.

<table>
<thead>
  <tr>
    <th>
      Node
    </th>
    
    <th>
      gVisor
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Rootful Docker 24.0 or later
    </td>
    
    <td>
      yes, through the <code>
        runsc
      </code>
      
       runtime and an annotation
    </td>
  </tr>
  
  <tr>
    <td>
      Rootful Docker below 24.0
    </td>
    
    <td>
      only with the second <code>
        runsc-oz
      </code>
      
       runtime
    </td>
  </tr>
  
  <tr>
    <td>
      Rootful Podman
    </td>
    
    <td>
      yes, through <code>
        runsc
      </code>
      
       on <code>
        PATH
      </code>
      
       or <code>
        OZ0_OCI_GVISOR
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Rootless Docker
    </td>
    
    <td>
      no: runsc does not work there yet (gVisor issue 12575)
    </td>
  </tr>
  
  <tr>
    <td>
      Rootless Podman
    </td>
    
    <td>
      no: it runs runsc only by ignoring cgroups, so the container's memory, process and CPU limits would not hold
    </td>
  </tr>
  
  <tr>
    <td>
      macOS, Windows, Docker Desktop
    </td>
    
    <td>
      no
    </td>
  </tr>
</tbody>
</table>

### 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](https://docs.oz0.ai/build/plugins/container-images#how-it-runs)). gVisor adds:

<table>
<thead>
  <tr>
    <th>
      Setting
    </th>
    
    <th>
      Value
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Runtime
    </td>
    
    <td>
      <code>
        --runtime runsc
      </code>
      
       (or <code>
        runsc-oz
      </code>
      
       for a tool with an allowlist on old Docker)
    </td>
  </tr>
  
  <tr>
    <td>
      Egress
    </td>
    
    <td>
      only for a tool whose plugin declares <code>
        egress
      </code>
      
      : the annotation <code>
        dev.gvisor.flag.host-uds=open
      </code>
      
      , which lets the gofer connect to the egress proxy's socket and nothing wider; without it the gofer refuses host sockets
    </td>
  </tr>
  
  <tr>
    <td>
      Limits
    </td>
    
    <td>
      the tool's memory plus 64 MiB and its processes plus 64, since the Sentry and the gofer count against the container's cgroup
    </td>
  </tr>
  
  <tr>
    <td>
      Label
    </td>
    
    <td>
      <code>
        org.oz0.isolation=gvisor
      </code>
    </td>
  </tr>
</tbody>
</table>

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](https://docs.oz0.ai/fleet/nodes#findings) 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):

```bash [Terminal]
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:

```yaml [agents/coder.yaml]
runtime: harness:claude-agent-sdk
sandbox:
  isolation: gvisor                   # its sessions, their terminals and its verify script run under gVisor
```

```yaml [oz0-plugin.yaml]
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.

<table>
<thead>
  <tr>
    <th>
      
    </th>
    
    <th>
      Under gVisor without Docker
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Files
    </td>
    
    <td>
      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, <code>
        /tmp
      </code>
      
       and <code>
        /run
      </code>
      
       empty. The root is an empty read-only folder
    </td>
  </tr>
  
  <tr>
    <td>
      Network
    </td>
    
    <td>
      loopback only (<code>
        --network=none
      </code>
      
      ); the egress proxy and the forwarded ports are host sockets the node binary's bridge reaches (<code>
        --host-uds=open
      </code>
      
      ), as under bubblewrap
    </td>
  </tr>
  
  <tr>
    <td>
      Limits
    </td>
    
    <td>
      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
    </td>
  </tr>
  
  <tr>
    <td>
      Environment
    </td>
    
    <td>
      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; <code>
        OZ0_SANDBOX_KIND=gvisor
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      User
    </td>
    
    <td>
      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 <a href="https://docs.oz0.ai/concepts/security/plugins#plugins-are-trusted-code">
        user of its own
      </a>
      
      . A tool that refuses to run as root needs to be told it is in a sandbox
    </td>
  </tr>
  
  <tr>
    <td>
      Secrets
    </td>
    
    <td>
      on a file descriptor, from the host API inside the sandbox, as on tier 1
    </td>
  </tr>
</tbody>
</table>

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.


## Sitemap

See the full [sitemap](https://docs.oz0.ai/sitemap.md) for all pages.
