Skip to main content
Read this before you choose a runner’s hooks or build repositories you do not fully trust.

What each kind of build protects

Each build records the kind of isolation it ran with. A container build reaches the runner through docker exec. The runner starts a relay process in the container. It carries the build’s connections to the tunnel, the gateway, Claude and your destinations over that process’s standard input and output. No socket file is shared between the runner and the container. The relay runs as its own unprivileged user, never the agent’s, so the agent cannot stop it or connect to its joining point. The runner treats everything the build sends as untrusted. It refuses a request for a service the build does not have. A build holds at most 1,024 open connections. Nothing from the runner’s disk is mounted into a container build. The runner writes the build’s files in its own directory. Then they are copied into /outerlayer/job in the container through docker exec. The files are gh/ with the read token, git-hooks/, the staged context/, and a check’s eval-input/. The folder is an in-memory filesystem owned by the relay’s user. The agent can read the files and cannot change them. If a copy fails, the build ends before its agent starts, and the reason names the step and the error Docker gave. A copy that fails during a token renewal is logged and the build keeps running.

What a microVM build reaches while no runner is up

A microVM build keeps running when its runner restarts or crashes. The runner serves the build’s sockets, and its service manager keeps those sockets while the runner is down. While no runner is up, a microVM build reaches nothing outside its microVM. A call to the gateway, to Claude or to an outside host waits in the socket’s queue. No process opens a connection for it. When the runner starts again, it serves the same sockets. It applies the build’s allow-list and its tokens to each call that waited. A call to a host outside the allow-list is refused. The runner adds no proxy process for each build, so no process outside the runner holds the build’s tokens. A microVM build outlives its runner only on a host with the root helper and systemd 254 or later. See A microVM build outlives its runner.

What an image build reaches

The runner’s tunnel does not carry an image build. The runner builds the repository’s image before a container or microVM build starts. It builds the image in its own builder, a BuildKit container on Docker’s default bridge network. So the recipe’s RUN steps and features reach the network as any container on the host does. They can reach the addresses that the tunnel refuses to a build: the host’s own, private, link-local and Tailscale addresses. build.allowHosts does not limit an image build either. See Image builds.

What a microVM escape reaches

A Firecracker or KVM escape is rare. If one happens, the build leaves its microVM. It then runs as the user that started Firecracker. What that user can reach depends on the root helper. With the current root helper, the guest of a microVM sees no VMX or SVM. These are the CPU’s hardware-virtualization flags. A Firecracker CPU template clears them for every microVM. So a build cannot run its own virtual machines or reach the host kernel’s nested-virtualization code. A root helper from an earlier release does not apply the template, so its guests still see VMX or SVM. Run outerlayer runner install --service --replace to install this release’s helper. With the helper. outerlayer runner install --service installs a small helper that runs as root. The helper starts each microVM build under Firecracker’s jailer. Firecracker then runs as a user made for that one build.
  • That user holds no credentials and shares no group with the runner’s account.
  • It has no network. It sees only that build’s kernel, disks, configuration and sockets.
  • Two builds that run at once run as different users.
  • The runner stays an ordinary account.
Without the helper. The runner starts Firecracker itself. A build that escaped runs as the runner’s account. It can read the Claude credential in claude.env, the runner key, the host key and the host’s gh login. Firecracker has an empty environment, so it holds no Claude credential and no destination secret in memory. The account is often in the docker group, which gives root on the host in practice. A host has no helper in two cases:
  • Its service was installed before the helper existed.
  • The runner was started by hand.
The runners on these hosts keep building as before. outerlayer doctor reports the gap on its MicroVM jailer line. To add the helper, run outerlayer runner install --service --replace. See The microVM helper. The helper protects against an escape. It does not change what a build can send through the runner’s tunnel. See What the keys limit.

What the keys limit

  • The host key ties the runner key to one machine. The runner key’s first claim binds it to the host key. From then on, a leaked runner key is useless without that host key. Until that first claim, anyone holding the runner key can bind it to their own machine. See The host key.
  • The item key is what a build uses in place of the runner key. It acts only on the claimed item and stops working when the claim ends. A container or microVM build never holds it. A builtin:process build can read it from the attempt’s job file. See Item key, and Concepts for claims and leases.

The Docker socket in a runner container

A runner in a container holds the Docker socket, /var/run/docker.sock. The socket gives the runner control of the host’s Docker daemon, as it does for other CI runner images. The runner key and the Claude credential are in the container’s environment. Anyone who can run docker inspect on the container can read them. That person already controls the daemon. Builds never get the socket. A build is a sibling container. Its only network is the runner’s tunnel, and nothing of the host is mounted into it. This is the same as under a service runner. A build’s isolation is the same whether the runner runs as a service or in a container. See Run the runner in a container. The host key lives only in the runner’s volume. Deleting the volume makes the runner a new host. The tunnel refuses the host’s own addresses. In a container, it reads the container’s addresses, not the host’s. So the tunnel does not refuse a public IPv4 or global IPv6 address that the host holds. A build could reach a service that listens on such an address. A host behind a router rarely holds such an address. A server with a public address does.

Docker Desktop

A runner in the image can run on Docker Desktop. Its builds are containers, as on Linux. Their only network is the runner’s tunnel. The tunnel refuses loopback, link-local, private and several special-purpose addresses. That covers host.docker.internal, Docker Desktop’s own address range and the Mac’s home network. A build mounts nothing from the Mac. So a build’s container on Docker Desktop cannot reach the Mac’s files, its local services or its home network. An image build is the exception. It runs on Docker Desktop’s network, outside the tunnel. From there it can reach the Mac’s local services through host.docker.internal, and the Mac’s home network. See What an image build reaches. All builds on Docker Desktop share the kernel of Docker Desktop’s VM. That matches container builds on Linux, which share the host’s kernel. It is a weaker boundary than a microVM build. Docker Desktop’s VM is shared with every other container the person runs. It also shares the Mac’s folders, by default /Users, /Volumes, /private, /tmp and /var/folders. A build mounts none of them. A build that escaped its container on Docker Desktop would sit beside your own containers. It would also be on the VM’s side of those shared Mac folders. The Lima VM that outerlayer runner init --vm makes holds only the runner and its builds. The Lima VM shares no Mac folders. It has Lima’s default network, because runner init --vm does not change it. On that network, Lima forwards host.lima.internal (192.168.5.2) to the Mac’s loopback address. A build’s container cannot connect to that address, because the tunnel refuses private addresses. A build that escaped its container there would reach the runner key. It would also reach the Mac’s loopback services and the Mac’s home network, but none of the Mac’s files. An image build in the VM can reach the same services and network, because it runs outside the tunnel.

WSL2

On Windows, the runner runs in a WSL2 distribution. By default, WSL2 lets a process in the distribution start Windows programs, and it mounts the Windows drives. A build that escaped its container as an account other than root could then start powershell.exe as you, and read your files under /mnt/c/Users/. outerlayer runner install --service turns off interop, the Windows PATH and drive automount in the distribution’s /etc/wsl.conf. They apply when you restart the distribution. Root in the distribution can still mount a Windows drive. See Run builds on Windows.

Honest limits

  • Executable hooks and builtin:process run the command as the host’s own user, with the host’s files in reach. That includes the runner key and the host key, so the host key does not protect a runner key from these builds. Only builtin:vm, builtin:container, or hooks with isolation of their own isolate a build.
  • With executable hooks, a child process that leaves the process group (for example with setsid) keeps running after the build stops.
  • On a host without the root helper, a build that escaped its microVM reaches the runner’s account. See What a microVM escape reaches.
  • On macOS, a runner installed on the Mac isolates no build. Only a Linux VM, or the runner image on Docker Desktop, does. In a Lima VM, the runner key lives inside it, so a build that escaped its container would reach the key.
  • A container build has no Docker inside it. A repository whose tests start Docker needs builtin:vm and a recipe that adds the docker-in-docker feature, or its own hooks. A container build’s recipe can ask for Docker. Then the attempt lists docker daemon as an ignored property from that feature. The runner’s log says the build has no Docker daemon.
  • A container build reaches only public addresses. A local, private or Tailscale address is unreachable from inside it.
  • A build can read every value in build.variables, and its gh read token, and send them to any public host the tunnel allows. List the hosts it needs in build.allowHosts.
  • The runner checks which branch a push updates, not what it contains. The review covers the diff.
  • On a branch under outerlayer/, the runner forwards any push, a force-push included. On any other branch it forwards only a push that creates the branch or moves it forward.
  • A container build’s disk limit is checked every thirty seconds, so the build can pass it briefly. The limit does not count a deleted file that a process keeps open. A microVM build’s disk is a fixed size.
  • A claim made immediately before the runner restarts may not be recovered. It expires on its own within fifteen minutes.
  • outerlayer sync’s upload checkpoint lives in the user’s home directory, one per gateway and factory. Two runners under one account, for the same factory, share it.