> ## Documentation Index
> Fetch the complete documentation index at: https://docs-stg.outerlayer.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Security and limits

> What each way of isolating a build protects, how the host key and the item key limit a build, what a host sends, and what isolation does not cover.

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.

| Hooks | Records | What it protects |
| - | - | - |
| `builtin:vm` | `vm` | The build runs in a Firecracker microVM with its own kernel, so it does not share the host's kernel. It has no network device: its connections go to the runner's tunnel. It gets the same tokens as a container build. Its `build` limits work differently: see [The `build` block](/container-builds#the-build-block). With the root helper, Firecracker runs under its jailer. The guest sees no VMX or SVM, so a build cannot run its own virtual machines or reach the host's nested-virtualization code. See [MicroVM builds](/container-builds#microvm-builds) and [What a microVM escape reaches](#what-a-microvm-escape-reaches). |
| `builtin:container` | `container` | The build runs in its own container, never as root, with none of the host's files mounted into it. Its files are copied into `/outerlayer/job`. It holds neither the item key nor Claude's credential, and can push only to the branch the claim names. See [What a build gets](/container-builds). |
| `builtin:process` | `shared-user` | Nothing. The build runs as the runner's user and has the host's network. It can read that user's files, including the runner key, the host key and the Claude credential. See [The process fallback](/write-your-own-hooks#the-process-fallback). |
| Your own hooks | What `provision.out` names, else `shared-user` | Whatever your hooks build. See [Write your own hooks](/write-your-own-hooks). |

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](/run-a-host-as-a-service#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 build image before a container or microVM build starts. It builds
it 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](/run-a-host-as-a-service#image-builds).

## The microVM helper

For `builtin:vm` hooks on Linux, `outerlayer runner install --service` also installs a second unit, named `<unit>-helper`, beside the runner's. The helper runs as `root`. Its one job is to run each microVM build under Firecracker's jailer. It starts, stops and reports microVM builds, and lists the builds it holds. It does nothing else.

A request names a build id, the kind of microVM and the runner's time limit. The helper works out the rest:

* It takes the build's files only from under the runner's `builds` directory. It opens each file without following a link.
* It runs the jailer and Firecracker only from root-owned copies. Each must match the SHA-256 this CLI release pins. The helper checks both before each start.
* It picks the build's user from a range of ids that no account on the host uses. Each running build gets a different user.

The jailer runs Firecracker as that user, in a chroot that holds only the build's kernel, disks, configuration and sockets. The build's unit, `outerlayer-build-<build id>`, has the build's memory limits and no network. The unit runs outside the runner's service.

The unit also has a runtime limit. It is `runner.timeLimitMinutes` plus 10 minutes. The runner ends a build at `runner.timeLimitMinutes`, and the extra 10 minutes are for its cleanup. The limit ends a build that no runner manages.

The helper also writes the CPU template that hides VMX and SVM from each guest.

The helper listens on a Unix socket, `/run/<unit>-helper/helper.sock`. Only the runner's account can open it. The helper also checks who connects, and refuses any other account, root included.

The helper's program, the jailer and Firecracker are in `/usr/local/libexec/outerlayer/<unit>-helper/`. Its config is in `/etc/outerlayer/`. Root owns all of them. The install checks that the runner's account cannot write them, and it refuses the helper if the account can.

The runner finds the helper through `OUTERLAYER_HELPER_SOCKET` in the unit. A runner started by hand, or from a unit written before the helper existed, has no helper. It runs Firecracker itself, as the runner's account. To turn the helper off, set `OUTERLAYER_HELPER_SOCKET=` in `runner.env` and restart the service.

To update the helper, run `outerlayer runner install --service --replace` again after you update the CLI. The runner's self-update cannot do this, because the helper needs root. A runner works with older helpers whose protocol it knows. For any other helper, the runner logs that the helper needs reinstalling and builds without it. `outerlayer doctor` says the same on its `MicroVM jailer` line. A helper from an earlier release can lack the time limit and the CPU template; see [A microVM helper from an earlier release](/upgrade#a-microvm-helper-from-an-earlier-release).

`outerlayer runner uninstall --service` removes the helper with the service.

## 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.

**With the helper.** The helper starts each microVM build under Firecracker's jailer. Firecracker then runs as a user made for that one build, as [The microVM helper](#the-microvm-helper) describes.

* 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`.

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).

## What the keys limit

<Frame>
  <img className="block dark:hidden" src="https://mintcdn.com/outerlayer-stg/vtkewxrpx_NzS0BQ/images/key-chain-light.svg?fit=max&auto=format&n=vtkewxrpx_NzS0BQ&q=85&s=e3df63db0838911b378c64b7f70c7653" alt="Three keys. A member creates the runner key on Settings, API keys, with the Runner preset. It claims items, sends heartbeats, syncs sessions, emits evidence, and reads and updates work. The runner makes a host key on the host's first run, and the runner key's first claim binds the two. From then on, the gateway refuses any runner key request the host key did not sign; Clear host key resets the binding. Each claim gives the build an item key for that one item. It works while the claim lasts, at most 24 hours, and is refused once the claim ends. It can read its item, comment, sync its sessions, emit evidence and open its pull request. It cannot claim items or act on any other item. A container or microVM build holds only a placeholder, and the runner adds the real key." width="660" height="470" data-path="images/key-chain-light.svg" />

  <img className="hidden dark:block" src="https://mintcdn.com/outerlayer-stg/vtkewxrpx_NzS0BQ/images/key-chain-dark.svg?fit=max&auto=format&n=vtkewxrpx_NzS0BQ&q=85&s=71f614a32d54b31c007de60b068dbbc2" alt="Three keys. A member creates the runner key on Settings, API keys, with the Runner preset. It claims items, sends heartbeats, syncs sessions, emits evidence, and reads and updates work. The runner makes a host key on the host's first run, and the runner key's first claim binds the two. From then on, the gateway refuses any runner key request the host key did not sign; Clear host key resets the binding. Each claim gives the build an item key for that one item. It works while the claim lasts, at most 24 hours, and is refused once the claim ends. It can read its item, comment, sync its sessions, emit evidence and open its pull request. It cannot claim items or act on any other item. A container or microVM build holds only a placeholder, and the runner adds the real key." width="660" height="470" data-path="images/key-chain-dark.svg" />
</Frame>

* **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. It does not protect a
  runner key from a build that can read the host key: a `builtin:process`
  build, or one under hooks with no isolation of their own. See
  [The host key](/run-a-host-as-a-service#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](/reference/build-reference#item-key), and [Concepts](/concepts) for
  claims.

## 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](/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

The runner 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](#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](/run-builds-on-windows).

## What a host sends

This is the runner's part of
[What leaves your machine](/what-leaves-your-machine): what a runner and
its builds send, and to whom.

* **`outerlayer runner`, releasing a claim,** sends the runner's name, the outcome, the step the attempt ended in (`stage`), the exit code of the process that ended it, a reason that is scrubbed and cut to 200 characters, and what the attempt ran in (`environment`, listed below).
  * The reason comes from the runner itself or from a runner's cleanup or report hook, never from the command's output.
  * The runner's own reasons include a failed image build, an out-of-memory kill, the item's evidence verdict, GitHub refusing a workflow change, and a command that died of a signal the runner did not send.
* **The runner log a release sends** is always sent. Nothing turns it off. It holds the lines the runner logged for that item while the attempt ran, such as claiming, provisioning, starting and ending the command, syncing, cleaning up and releasing. When the attempt ended in provision, sync, cleanup or report, it ends with the last 200 lines of that stage's log. It never holds the command's output, which the build's session carries.
  * The runner log goes through the same scrub as the build's session, including the patterns in the runner's `scrub` config, plus the attempt's item key and the runner key as exact strings.
  * The scrub removes secrets. It does not remove paths, host names or repository names, which the lines can hold. The release `reason` and `environment` already send those.
  * The runner keeps the last 64 KiB and says in the first line how many bytes it cut. The gateway stores it with the attempt, and anyone who can read the work item can read it with `outerlayer work logs` or the MCP tool `get_runner_log`.
* **The `environment` a release sends** holds:

  * the isolation (`container`, `vm` or `shared-user`)
  * the recipe's source commit, key and path, and the build image's id
  * the recipe properties the runner ignored
  * the CLI and agent versions
  * who authored the commits (the GitHub App or the host), and whether the person who asked for the build was credited
  * for a container build: the hosts the build reached (up to 200, with a count of the rest), and the first 200 addresses the tunnel refused with their range (with a count of the rest)
  * the variable names the runner's config passed in, the names of the destinations the build called, and which kinds of repository token the build used (`read`, `push`)

  It never sends a token or a variable's value. It never sends a destination's address, header or secret. A destination's secret is read on your machine and used there. Only its name is recorded. It sends no part of any request the build made beyond the host name.
* **`outerlayer runner init`**, when the config file holds no runner key and you are logged in, sends `GET /v1/me/factories` with your login's token, to find the factory's name for the link it prints. It creates no key. You create the key in the dashboard and paste it. The command then checks the pasted key with `GET /v1/runner/host-key`, unsigned, so the key is not yet bound to any host.
* **`outerlayer runner init --vm`** on a Mac runs `limactl`. Lima downloads a Linux VM image, and inside the VM `apt` downloads Node and `npm` downloads the CLI.
* **`outerlayer runner check`** asks the gateway (`GET /v1/runner/host-key`) whether the runner key can claim work.
* **`outerlayer runner image`** fetches the repository's default branch with the host's own git credentials. Docker pulls base images and features with the host's own `docker login`. The build also downloads pinned `gh` and `tini` releases from GitHub.
* **`outerlayer session-sender`** runs only inside a runner's build. It streams the sessions that build launched to its own work item, with the build's item key. It sends no other session on the machine.
* **`outerlayer runner`, before a build's command starts,** runs `git ls-remote` and `git fetch` in the job's workdir with the host's own git credentials, to put it on the branch the claim named. Nothing about the job is sent to OuterLayer that way.
* **`outerlayer runner`, for a container build,** asks the gateway for a read token and a push token for each repository its claim names, each limited to that one repository (`POST /v1/work-items/{workItemId}/claim/tokens`). It sends git's fetch and push requests for the build to github.com itself, with those tokens. The build's git traffic goes from the runner, not from the build. When the attempt ends, the runner revokes each token at GitHub. No token goes to OuterLayer. The primary repository's read token is written to the build's `gh/hosts.yml` on your machine so `gh` can use it, and is deleted with the build. The push token stays in the runner's memory.
* **`outerlayer runner`, for a container build,** asks the gateway which repository governs the item's repository (`GET /v1/context/source`). It then asks the gateway for a read-only token for that repository (`POST /v1/work-items/{workItemId}/claim/tokens` with `target: control_plane`) and fetches it on the host with that token. A gateway with no GitHub App issues none, and the fetch uses the host's own git credentials. The request names the item's repository and nothing else. The token reaches git in its environment for that one fetch, and the runner revokes it when the attempt ends. The build receives that repository's files, never a credential for it.
* **`outerlayer runner`, for a build it provisions itself,** serves two built-in destinations: `gateway` and `claude`. The build sends its requests to your gateway and to Claude's API (`api.anthropic.com`) through the runner. The runner adds the attempt's item key and the host's Claude credential on the way out. The credential goes to Anthropic only. The runner reads it from its own environment, or from the credentials directory a service gives it. A container or microVM build never has it in its environment or files. A `builtin:process` build runs as the runner's user, so it can read the credential from the runner's files.
* **`outerlayer runner`, for a microVM build (`builtin:vm`),** downloads a pinned Firecracker release, a guest kernel and a small guest helper from github.com the first time it needs them. It checks each against a sha256 pinned in the CLI. The runner sends nothing in those requests. The build's connections leave the microVM over vsock to the runner's tunnel, which applies the same refusals as for a container build. In a build with Docker, its containers and image build steps leave through the same tunnel as the build, by proxy variables. The microVM has no network device of its own.
* **`outerlayer runner`, when a build's command exits 0,** runs the repository's host checks. It sends each one's result (`POST /v1/emitted-results`): the check's name, pass or fail, and for a fail the exit status and the last line of its output, scrubbed. It then reads the work item once more, for its evidence verdict, and sends nothing but that request.
* **On a runner host,** any command that uses the saved runner key signs each request with the host key. The signature covers the request's method, path, query and body, and adds nothing else.
* **`outerlayer runner start`, when the gateway names a higher CLI version than the one running and no build is in flight,** calls the public npm registry (`registry.npmjs.org`). It asks for `@outerlayer/cli`: that version's metadata, its tarball and its provenance attestation. Those requests carry no credentials and nothing about the host or the factory. It then runs `npm install` and `npm audit signatures`. These fetch the package's dependencies too, and they use this user's npm configuration, including any registry token in `~/.npmrc`. Set `runner.autoUpdate` to `false` to stop them.

### The heartbeat

`outerlayer runner` sends a heartbeat to the gateway on every poll. It holds
these fields:

| Field | What it holds |
| - | - |
| Name | The runner's name (`runner.name`, which defaults to this machine's hostname). |
| Version | The CLI version, and the runner protocol: which version of the gateway contract the CLI was built for. |
| Poll interval | The poll interval. |
| Slots | The slots in use and the total. |
| Work request | The result of that poll's own request for work — success, or an error code and message. The error is only what the platform already returned to this runner, or a fixed sentence when no error came back from the platform. |
| `lastUpdate` | Sent once the runner has updated itself. It holds the two CLI versions, how it ended (`updated`, `rolled_back`, `verification_failed`, `not_published` or `retrying`), a short reason and the time it ended. |
| `lowDisk` | Sent only while the runner holds off new work for want of disk. It holds the free and needed disk space in bytes. |
| `blocked` | Sent while a check of the runner's own stops it claiming work. It holds one sentence of at most 500 characters that names the cause. |

`blocked` names one of these causes:

* a missing `/dev/kvm` for a microVM build
* no Claude credential
* a command still set to the placeholder `runner init` writes
* builds that do not fit in memory
* an image builder that is not ready or not usable

Depending on the cause, that sentence can include the
host's usable memory in GiB, the `/dev/kvm` path and Docker's own error text.
It is not scrubbed.

Nothing else about the work item or the machine is sent.

## What isolation does not cover

* With executable hooks, a child process that leaves the process group
  (for example with `setsid`) keeps running after the build stops.
* A container build has no Docker inside it. A container build's recipe can
  ask for Docker. Then the attempt lists `docker daemon` as an ignored
  property from that feature, and the runner's log says the build has no
  Docker daemon. A build that needs Docker needs `builtin:vm` and the
  docker-in-docker feature; see [MicroVM builds](/container-builds#microvm-builds).
* The runner checks which branch a push updates, not what it contains. The
  review covers the diff.
* 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.

Other limits are on the pages that cover them:

* `builtin:process` and hooks with no isolation of their own reach the host's
  files, the runner key and the host key: see
  [What each kind of build protects](#what-each-kind-of-build-protects).
* A build that escapes its microVM on a host without the root helper reaches
  the runner's account: see
  [What a microVM escape reaches](#what-a-microvm-escape-reaches).
* On macOS, only a Linux VM or the runner image isolates a build. In a Lima
  VM, a build that escaped its container would reach the runner key: see
  [Docker Desktop](#docker-desktop).
* A container build reaches only public addresses: see
  [The tunnel](/container-builds#the-tunnel).
* A build can send every value in `build.variables`, and its `gh` read
  token, to any public host the tunnel allows: see
  [Limiting the hosts a build reaches](/container-builds#limiting-the-hosts-a-build-reaches).
* Which pushes the runner forwards on a branch under `outerlayer/` and on
  any other branch: see
  [The git remote and the build's tokens](/container-builds#the-git-remote-and-the-builds-tokens).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.