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’sRUN 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. Runouterlayer 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.
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.
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:processbuild 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 covershost.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 startpowershell.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:processrun 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. Onlybuiltin: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:vmand 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 listsdocker daemonas 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 itsghread token, and send them to any public host the tunnel allows. List the hosts it needs inbuild.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.