# Security of self-hosted GitHub Actions runners

Canonical page: https://zoomies.sh/security/

This document says what Zoomies protects, what it does not, and what every
dangerous setting actually costs you. Nothing here is hypothetical; each toggle
below is a real setting, and each one produces a named warning at startup and in
the UI's problems drawer when it is on.

---

## 1. The fundamental problem

**A self-hosted runner executes code from your repositories.** Anyone who can
cause a workflow to run (which, on a public repository, is anyone who can open
a pull request) can execute arbitrary code inside your runner.

GitHub's own guidance is blunt about this: do not use self-hosted runners with
public repositories. Zoomies does not change that. What it does is make the
blast radius of each execution as small as it can reasonably be:

* one job per runner, then the container is destroyed (ephemeral by default);
* no long-lived credential on the runner: a JIT registration is single-use;
* no Docker daemon reachable from the job unless you explicitly ask for one;
* a non-root user inside the container;
* capabilities dropped to a build-shaped minimum. The user keeps passwordless
  `sudo` (a great many workflows assume it, matching a GitHub-hosted runner)
  but `sudo` only ever regains that same minimum, never full root.

## 2. Threat model

Three places, and every credential that crosses between them:

```mermaid
flowchart TB
    subgraph net["the network"]
        gh["GitHub"]
        ops["operators, the CLI, coding agents, Prometheus"]
    end

    subgraph ch["the controller host"]
        ctrl["zoomies controller"]
        db[("SQLite -- App keys, webhook and OIDC<br/>secrets sealed with a key kept outside it")]
    end

    subgraph rh["a runner host -- the blast radius of one job"]
        ag["zoomies agent"]
        run["runner container<br/>non-root, capabilities dropped,<br/>no docker.sock unless you asked for one"]
    end

    ops -->|"session cookie or zoo_ token, over TLS"| ctrl
    gh -->|"webhook, HMAC-SHA256, constant-time"| ctrl
    ctrl -->|"App JWT, installation token"| gh
    ctrl <--> db
    ag -->|"agent token -- reaches /api/v1/agent/* and nothing else"| ctrl
    ag -->|"a single-use JIT config, in the environment"| run
    run -->|"registers once, then runs your workflow"| gh
```

The runner container is the only place untrusted code runs, and the credential
it is given registers one runner and then expires. The host's own credential
reaches `/api/v1/agent/*` and nothing else: an agent can claim tasks and report
on its own runners, and cannot read pools, jobs, users or the audit log.

### In scope

| Threat | Mitigation |
| --- | --- |
| A malicious job tries to read another job's secrets or source | Ephemeral runners: the container that ran the previous job no longer exists |
| A malicious job tries to reach the host | Container isolation, non-root user, dropped capabilities, no `docker.sock` by default |
| A malicious job steals the runner registration credential and registers its own runner | JIT configurations are single-use and expire; there is no reusable PAT on the host. The entrypoint also unsets both the JIT configuration and the registration token before it starts `run.sh`, so neither is in the environment a workflow step inherits, which matters most on a pool with `ephemeral: false`, where the credential is an organisation-scoped registration token good for an hour rather than a single-use one |
| Someone forges a webhook to make Zoomies create runners | HMAC-SHA256 signature verification on every delivery, constant-time comparison |
| Someone reaches the controller's API | Authentication required by default; the listener binds to loopback unless told otherwise |
| Someone reaches a freshly deployed controller before its owner does | The first-run endpoint needs the setup token printed in the controller's log, not merely an empty database |
| A host enrolled with a join token tries to become a different host, or to advertise labels that win it another pool's work | Taking over an existing host by name needs that host's own agent token; join-token labels win over the agent's |
| A host enrolled with a join token names itself so that a problem shows an administrator a command to copy | A host's name may not hold a backtick, a control character or more than 128 characters. The join refuses one before it redeems the token, and a rename is held to the same rule. A name stored before that is shown with its backticks as apostrophes in every sentence about the host, so it cannot open a command either |
| A stolen browser session | Sessions are hashed at rest, `HttpOnly` + `SameSite=Lax` + `Secure` (when the external URL is https), and expire |
| A stolen API token | Tokens are stored as SHA-256 hashes, scoped by role, optionally expiring, individually revocable |
| Someone reads the database file or a backup | GitHub App private keys, webhook secrets and OIDC client secrets are AES-256-GCM sealed with a key held outside the database |
| An operator does something destructive | Every mutating action writes an audit row with actor, target, and a before/after diff with secrets redacted |
| A coding agent connected over MCP is steered by text a workflow wrote; a log line, a step or branch name | The agent gets the token's authority and nothing more; its tools return a log in a separate block marked untrusted, and the server tells the model at connection that workflow-written text is data, not instructions. The fleet-changing tools are offered only to a token whose role reaches them, and `drain_runner` never confirms a busy runner's drain, so the worst an agent can be talked into is re-running a failed job, retiring an idle runner, or resizing a pool or host, which `update_pool` and `update_host` do by named field only, and `apply_remedy` does only for a change the controller itself is proposing, never with `confirm` (the controller still refuses a change that leaves a pool with nowhere to run) and never to a capacity of zero, and every call is an audit row under the token's name. The administrator tools (`edit_host`, `clear_host_throttle`, `get_settings`, `update_settings`) are a separate step: they are offered only to an administrator's token and only while an administrator has turned on `security.mcp_admin_tools`, and `update_settings` refuses every key outside the scheduling, retention, limits and display tuning ones, among them `updates.mode` and `updates.soak`, so a connected assistant can never be the one that turns on unattended updating |
| The controller edits its own fleet (`security.auto_apply_remedies`) | Shadow by default, which records what it would change and changes nothing; `on` makes the changes. Changeable only by an administrator at the Settings page: `update_settings` over MCP refuses it, as it refuses every `security.` key. When on, it applies only the change a problem is proposing now, by asking `POST /problems/apply` as an operator-level `zoomies autopilot` identity, so the route's role check, its refusal of a change that leaves a pool with nowhere to run, and its audit row all apply. A proposal must stand unchanged for two hours first, a pool or host is changed at most once a day, one change is made per pass, and an undone proposal is never made again. Each change is audited as `problem.remedy_auto_applied` with the values it replaced, listed under Problems, and undone with `POST /problems/auto-applied/{id}/undo` unless the target was edited since. A sidecar memory share it changed is taken back by the controller itself, as `problem.remedy_undone`, if a job in the pool is killed for memory within a week of it (never over an edit) |
| A page the operator visits drives the MCP endpoint with the operator's own session | `/mcp` takes a bearer token only and refuses the session cookie, and answers a foreign `Origin` with a 403 |
| An OAuth code or token for `/mcp` is intercepted, replayed or sent to the wrong place | Codes go only to a redirect the client registered, work once, for five minutes, and need the PKCE verifier; a replayed code or refresh token revokes the connection; access tokens last an hour, are bound to `/mcp`'s address and are refused everywhere else, see [OAuth for MCP](#oauth-for-mcp) |
| A compromised agent | Agents can only claim tasks and report on their own runners; they cannot read pools, jobs, users or the audit log |

### Out of scope

* **A malicious job escaping the container.** Container isolation is the boundary.
  If you need a stronger one, run one agent per trust domain on separate hosts,
  or use the `process` backend inside a VM you are willing to lose.
* **Multi-tenancy between untrusted organisations.** One Zoomies instance is one
  team's fleet. Pools are not a security boundary between tenants.
* **Compromise of the GitHub App itself.** If the App's private key leaks, the
  attacker can register runners against your org. An App that was created with
  the [migration wizard's](https://zoomies.sh/migration/) permissions (which it asks for only
  when you say you want the wizard) could also commit to a branch and open a
  pull request on the repositories it is installed on. It cannot merge one.
  Rotate the key in GitHub and re-enter it in Zoomies; an App that has those
  three permissions and will never migrate anything can drop them on its
  **Permissions & events** page. An App that was also given **Administration:
  read**, for the [repository settings checks](https://zoomies.sh/configuration/#repository-settings-checks),
  could read each repository's collaborators and configuration, which Kennel Club
  does not use. GitHub offers no narrower permission for the Actions settings
  and the branch protection those checks read, which is why it is asked for separately and only if you
  turn them on, and why a test holds Kennel Club to the list of requests its
  page names.
* **Denial of service.** `repository_scale_up_limit` can best-effort throttle
  new runner creation attributed to one repository, but it is not a security
  boundary or strict concurrency quota: GitHub can assign that repository's
  jobs to compatible idle runners. Use `max_runners` as the pool-wide backstop;
  strict separation requires repository-specific pools and workflow labels.

---

## 3. Credentials and how they are stored

| Credential | Where it lives | Protection |
| --- | --- | --- |
| GitHub App private key (PEM) | `installations.private_key_enc` | AES-256-GCM, key from env or key file |
| Webhook HMAC secret | `installations.webhook_secret_enc` | Same |
| OIDC client secret | `instance_settings` | AES-256-GCM, sealed with the same key, then base64 |
| Registry credential | `instance_settings` | Same |
| Capacity-demand signing secret | `instance_settings` | Same |
| User passwords | `users.password_hash` | argon2id, 64 MiB × 2 passes × 4 lanes, 16-byte salt |
| Session cookies | `sessions.token_hash` | SHA-256 of a 32-byte random token |
| API tokens | `api_tokens.token_hash` | SHA-256; the plaintext is shown exactly once. Revoked with the account they belong to: disabling or deleting a user revokes their tokens, and one whose owner is disabled or gone is refused even if it was not. A token never carries more than its owner's current role, so demoting an account demotes its tokens |
| Agent tokens | `hosts.token_hash` | SHA-256; issued once at join |
| Join tokens | `join_tokens.token_hash` | SHA-256, single-use, short TTL. One minted for a rented machine is scoped to that machine's name as well, so a token read out of a guest cannot enrol anything else |
| Provider credentials | `providers.credentials_enc` | AES-256-GCM, key from env or key file. Unsealed only for the life of one API client, and never sent to a guest, an API response, an audit row or a log line |
| Provider private connection address | `providers.tailcat_address_enc` | Same. The address of a `zoomies gateway` is a lasting capability to open connections to the hypervisor's API, so it is handled exactly as the credential beside it: every read reports `connection: tailcat` instead |
| JIT runner configs | Never stored | Passed to the agent in a task and to the container in its environment |

### The instance encryption key

32 bytes, base64 or hex, supplied by one of:

1. `ZOOMIES_ENCRYPTION_KEY` (preferred for containers)
2. `security.encryption_key_file`: a `0600` file; Zoomies refuses to read a key
   file that is group- or world-readable
3. `security.encryption_key` in `zoomies.yaml`: **warned about**, because
   anything that can read your config (backups, configuration management, a
   support bundle) can then decrypt every stored secret

**Back this key up.** Losing it means re-entering the GitHub App private key, the
webhook secret, and any credential held as a setting; the single sign-on client
secret, the registry credential, the capacity-demand signing secret. It does not
mean losing the fleet's state: pools, runners, jobs, the audit log and every
setting that is not a credential are not encrypted.

Rotation: change the key and restart; Zoomies will fail to decrypt the existing
installation rows and tell you which ones to re-enter. There is no automatic
re-encryption in v1.

### What a rented machine is given, and what it is not

A machine Zoomies rents from an infrastructure provider receives exactly one
credential: a join token, single use, valid for minutes, and scoped to that one
machine's name. All it can do is create one host row with labels an operator
chose. It cannot read the provider's API, enumerate machines, or delete
anything.

The provider credential stays on the controller. It is sealed in
`providers.credentials_enc`, unsealed only inside the call that builds one API
client, and there is no code path that copies it into a guest; the type that
describes what goes into a machine has no field it could travel in, and a test
plants a recognisable token in the provider row and scans every byte of every
bootstrap payload for it.

That matters because the guest is the least trusted thing in this picture. It
runs other people's workflow jobs. A credential that could destroy virtual
machines must never be one job's `cat` away, which is also why the enrolment
token is written into the guest through the hypervisor's own guest agent rather
than into the VM's cloud-init metadata, where any Proxmox user with audit rights
could read it back.

The privileges the credential itself holds are yours to scope, and
[Proxmox VE](https://zoomies.sh/proxmox/#the-api-token) lists exactly which ones are needed and
why each one is there.

### An installation archive

`zoomies export --installation` is the one path by which a GitHub App's private
key leaves the instance. It leaves only when the exporter gives a passphrase,
sealed under a key derived from it with argon2id, and never as the
instance-sealed column; a copy of that ciphertext would outlive a purge and be
one leaked instance key away from readable. The export is admin-only
(`installations.export`) and audited, the archive is written mode 0600, and the
import seals the key again under the receiving instance's own key before it
writes a row. Treat an archive with a passphrase as you would the key itself,
and keep the passphrase somewhere else.

### What the agent owns on a host

Running the enrolment command on a machine hands Zoomies a small, fixed part
of it, and nothing else. The agent does not upgrade packages, change a
firewall, reboot, open a port or accept a connection; it only ever dials out
to the controller. The one thing on this list that can change the host's
Zoomies is the update helper, and that is root's and is not the agent: it is
there only where the host's owner installed it, by hand, and all the service
can do is ask it for a release. This is the whole list, as the code does it:

| What | Where | What the agent does with it |
| --- | --- | --- |
| Its service and user | The `zoomies-agent` service (a systemd unit on Linux), running as the `zoomies` system user when the command runs as root, otherwise as the user who ran it | Created by `zoomies agent join`, removed by `zoomies uninstall`. The unit is sandboxed: `NoNewPrivileges`, `ProtectSystem=strict`, and write access to its state directory only. Where the container socket belongs to a group, the service joins that group rather than running as root. |
| `agent.work_dir` | `work/` under its state directory, `/var/lib/zoomies/work` for a root install | Its credentials (`agent.json`, mode `0600`) and the runners' working directories. |
| Containers carrying its labels | Every container it creates is labelled `io.zoomies.managed=true`, with its runner and pool | It lists, reaps and removes only containers with that label; a container without it is never touched. Removing one also removes that container's anonymous volumes. |
| Its per-pool cache | A named volume `zoomies-cache-<identity>`, or a directory `<source>/<identity>` when a pool's cache source is an absolute path, with scope, immutable pool ID and repository hashed into the versioned identity | A cache directory with a size limit is trimmed back under it, oldest entries first, before a runner starts. A named cache volume is never deleted. |
| Its kept tool cache | A directory `cache/tools/<identity>/image-<digest>` in the host's shared folder, `/var/lib/zoomies/shared` (separated by cache scope and resolved runner image) when the pool sets `cache.tools` | Never trimmed or deleted by Zoomies; it is a cache, so emptying it costs the next job its downloads and nothing else. Created writable by every account, under a shared folder only Zoomies' own account can enter. After a toolchain scan Zoomies fills it too, in a container of the pool's image as its runner user, from each toolchain's publisher (nodejs.org, go.dev, Adoptium and GitHub's `actions/python-versions`) with the publisher's checksum checked where it lists one. Runners get it read-only, beside a folder of their own in `cache/tool-runs/<runner>` that is removed with the runner, so a job cannot change what the next job runs. |
| The Docker builder cache | The Docker daemon the agent uses | See below. |
| The update helper, only where its owner installed it | `zoomies-update.path` and `zoomies-update.service`, systemd units that run as root, and root's own folder `/var/lib/zoomies-update` for the helper's limits and its record of where the update folder is | Not the agent, and not installed by it: only the host's owner puts it there, with `sudo zoomies updates helper install` or a yes to the question `zoomies init`, `zoomies agent join` and `zoomies upgrade` ask at a terminal (never `--yes`), or `--update-helper` typed by the person at the host (`update_helper: true` in the answer file they wrote for `zoomies init`), and `sudo zoomies updates helper remove` or `zoomies uninstall` takes it away. For a request it runs `zoomies upgrade --version <tag> --non-interactive` for the release named, never with `--yes`, at most twice for one release and once in ten minutes. Its unit loads no environment file the service could write, and keeps home directories read-only. |
| The update folder, only with the helper | `update/` under the state directory (`/var/lib/zoomies/update` for a root install), or under the shared folder for a container deployment, owned by the account Zoomies runs as | Made by the helper's install and by nothing else. The service writes `request.json` there, naming one release and nothing more; the helper reads it as untrusted input, removes it, and answers in `result.json`. `helper.json` says the helper is installed. |

It never removes an image, a volume it did not create with a container, or a
container without its label, and a test in the agent's own suite fails if
that ever widens.

**The builder cache is the one thing that is not only Zoomies'.** With the
`docker` backend, the agent asks the daemon every five minutes, and at start,
to prune **unused** builder cache down to `agent.docker_build_cache_mb`
(default `0`, disabled). Docker never prunes cache that a build in progress is
using, and the prune is bounded by that target, but it is daemon-wide: on a
daemon something else also builds with, the cache it trims is partly somebody
else's. On a shared daemon, set `agent.docker_build_cache_mb: 0` (or
`ZOOMIES_AGENT_DOCKER_BUILD_CACHE_MB=0`) and the agent prunes nothing. Podman
and the `process` backend have no builder cache for it to touch.

Enrolment itself does one more thing, once, on a host whose rootless Docker or
Podman cannot apply a CPU, memory or pids limit: run as root on a cgroup v2
systemd host, it writes
`/etc/systemd/system/user@<uid>.service.d/zoomies-delegate.conf` to delegate
those controllers to that user's slice and restarts that user's daemon. It
says so when it does, and a daemon that already applies limits is left alone.

---

## 4. Authentication and authorisation

### Identities

* **Local users**: argon2id passwords. The first account is created by
  `zoomies init` on the console, or by the one-time bootstrap endpoint, which
  refuses to run once any user exists **and** requires the setup token the
  controller prints in its log while the instance is empty. "No account exists
  yet" is a condition a stranger can satisfy too, so on its own it would hand a
  freshly deployed controller to whoever loaded the page first; the token is
  proof that the caller can read the controller's log. It is minted per process,
  so a restart prints a new one, and the line stops appearing once an account
  exists. An unattended deployment creates the account from the environment
  instead: `ZOOMIES_BOOTSTRAP_ADMIN` with a password or token file, read once
  at startup on an empty database. Whoever sets a process's environment already
  operates it, so that path needs no token; the files must not be readable by
  group or other, a supplied token is stored only as its hash, and once any
  account exists the variables are ignored (they can never add a platform
  account to an instance somebody has claimed) and `bootstrap.ignored` says
  so until they are removed. Every path writes an `auth.bootstrap` audit row
  naming how the account was made.
* **OIDC users**: optional. Linked by `sub`, and by username only for an
  account that has no local password: adopting one that does would let whoever
  holds that username at the identity provider take over the local account, so
  it takes `oidc.link_by_username`. An `email` claim is used as a username only
  when the provider says `email_verified`.
* **API tokens**: `zoo_<prefix>_<secret>`, sent as `Authorization: Bearer`.
  Carry a role and optionally a narrower scope list. A token can mint tokens,
  but never one wider than itself: the role is capped at the caller's, a
  scoped token can only mint within its scopes, and the result belongs to the
  same account, so a leaked token narrowed to one resource cannot be turned
  into an unscoped one that outlives its revocation. Anybody signed in may
  create, list and revoke their own tokens, never at a role above their own;
  an administrator sees and revokes everybody's, and every one of those acts
  is audited (`token.create`, `token.revoke`, `token.delete`). A token also
  never carries more than its owner's current role: demoting an account
  demotes the tokens it holds, as it does its MCP connections. The same tokens
  open `/mcp`, the endpoint a coding agent connects to; see below.
* **MCP connections**: `zoomcp_` access tokens from the OAuth flow a client
  such as Claude runs against `/mcp`, when `security.mcp_oauth` is on. They
  are accepted on `/mcp` and nowhere else; see
  [OAuth for MCP](#oauth-for-mcp) below.
* **Agents**: a separate credential class that can only reach `/api/v1/agent/*`,
  and only for their own host: an agent may report on its own runners and write
  into its own log relay, and gets the same "no such stream" answer for anybody
  else's. A join token enrols a machine; it does not let that machine take over
  an existing host by claiming its name, which needs the agent token the
  previous registration was issued. Labels pinned by the join token win over
  labels the agent declares for itself, because host labels are what decide
  which pools' work, and which pools' runner registrations, a host is offered.

### Roles

| Role | May |
| --- | --- |
| **viewer** | Read pools, runners, jobs, hosts, the audit log and metrics. Never sees a secret value, including a pool's `env`, where a registry or proxy credential ends up: a viewer is sent the variable names with empty values, on the API and on the event stream alike. |
| **operator** | Everything a viewer may, plus act on the fleet: create and edit pools, drain/delete/restart runners, cordon hosts, and ask a host's agent to run its read-only OS checks once (scope `hosts:check`). A viewer never can: the check spends the host's CPU and starts a few dozen processes there. |
| **admin** | Everything an operator may, plus manage users, API tokens, installations, join tokens and settings, take a support bundle, and ask a host's agent to update itself (scope `hosts:update`) or start, resume and cancel a rollout that updates the hosts one at a time (scope `updates:rollout`). The update replaces software that runs as root on a machine the controller does not own, and a failed one leaves the host without an agent until somebody reaches it, which is why an operator, who may cordon and drain the host, may not. |
| **platform** | Everything an administrator may, plus what belongs to whoever runs the process rather than the fleet: lifting the recovery fence; taking, downloading and restoring backups; updating the controller itself (`updates:apply` on a token); and changing `updates.mode` and `updates.soak`, which decide whether the controller may update anything at all. A backup is the whole database (every account's password hash and every sealed credential, under the key this host holds) so `backups:read` on a token is the instance, not merely the fleet. The role exists for the instance where one team runs the controller and another uses it; where one team does both, the account that installed it holds this role and nothing looks any different. |

Reading repository source through [AI Context](https://zoomies.sh/ai-context/) is not part of
any role. It needs explicit membership of the repository and, for an MCP
connection, its owner's explicit consent. An administrator can also make a
person an **installation owner**, who may enable AI Context for that one
installation and add only themselves as a reader. The viewer-level
`context.manage` action is only the coarse gate: every handler behind it checks
the ownership again, and tokens and OAuth connections never qualify.

When the managed workflow's run fails, Zoomies reads that run and the log of its
failed job, with the Actions read permission the App already has, to say why. The
log's pre-signed address is requested without the installation's token, and is
refused if it names this machine or a private network unless the GitHub API
itself is on one. The log is matched against fixed phrases and read for nothing
else: no line of it is stored or shown, because what a run prints is written by
whatever the run executed.

The mapping from every individual API action to its minimum role is a table in
`internal/auth/rbac.go`, and a test walks the full action list, so a new
endpoint cannot be added without deciding who may call it.

The API refuses to remove or demote the last enabled admin. Nobody may grant a
role above their own, or change, disable, delete or reset the password of an
account that outranks them, so an administrator cannot make a platform
account, or become one.

### How one request is authorised

```mermaid
flowchart LR
    req["a request"] --> who{"which credential?"}
    who -->|"session cookie"| csrf["Origin and Sec-Fetch-Site<br/>must be same-origin"]
    who -->|"Bearer zoo_ token"| scope["role, plus any scopes<br/>the token was narrowed to"]
    who -->|"agent token"| only["/api/v1/agent/* only"]
    who -->|"none"| pub["the handful of<br/>unauthenticated routes"]
    csrf --> rbac{"the minimum role<br/>for this action"}
    scope --> rbac
    rbac -->|"met"| h["the handler runs, and a mutating<br/>one writes an audit row"]
    rbac -->|"not met"| deny["403 naming the role you are missing"]
```

The action-to-role table is `internal/auth/rbac.go`. A mutating handler that
succeeds writes an audit row naming the actor, the target and a redacted
before/after; a refused login writes one too, because a burst of those is
something you want to see. Redaction goes by field name and by value: a string
that holds a PEM private key is blanked whatever field it was pasted into,
because every signed-in viewer can read the audit. A login refused for rate
limiting writes one row per address per minute rather than one per request, so
an anonymous loop cannot fill an audit log that is never pruned; the rest are
counted in the controller's debug log.

A coding agent's request to `/mcp` is authorised the same way, one tool call
at a time. The endpoint resolves the bearer token as any route does (and
only a bearer token: a session cookie is refused there) and then hands each
tool call back to the router as the documented route it names, carrying that
token. So the call meets the same role check, the same scopes, the same audit
row and the same access-log line it would have met from the CLI, and a tool
cannot do anything its route would refuse. Which tools are offered follows the
same table: `rerun_job`, `drain_runner`, `update_pool`, `update_host` and `apply_remedy` are listed only for a token whose
role reaches `POST /jobs/{id}/rerun`, `POST /runners/{id}/drain`, `PATCH /pools/{id}` and `PATCH /hosts/{id}`.

### OAuth for MCP

With `security.mcp_oauth` on (the default where authentication is on and the
controller is reached over https) the controller is its own OAuth 2.1
authorisation server for `/mcp`, following the
[MCP authorization specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization),
so Claude can be added by URL and sign somebody in through the browser. What
that adds, and what it is built not to add:

* **A connection is a person's consent, at a role they choose.** The consent
  screen names the client, shows the host the code will be sent to, and warns
  when that address is on the person's own machine, where any program can
  listen. It judges the address this sign-in will send the person to, not the
  client's whole list, so a client that also registered an https address is
  still warned about on its loopback one. The role is viewer or operator and
  never above the person's own; a connection whose person is later demoted is
  demoted with them, and one whose person is disabled or deleted stops.
* **The token works on `/mcp` alone.** An MCP access token presented to the
  REST API is refused with a sentence saying so. The person approved an agent's
  tools, not a general credential; `/mcp` reaches the routes it needs in
  process, as the connection, and never passes the token on, which the
  specification forbids a resource server to do. The token is bound to its
  audience, `<external URL>/mcp`: one issued for another address is refused.
* **Nothing is stored in the clear.** Codes, access tokens, refresh tokens and
  client secrets are SHA-256 hashes in the database, as API tokens are.
* **Short and single-use.** A code lasts five minutes and works once, with S256
  PKCE and the exact redirect it was issued for. An access token lasts an
  hour. A refresh token rotates on every use; presenting a spent code or refresh
  token again revokes the whole connection, since only a copy explains it, and
  is audited as `mcp_connection.replay`.
* **Redirects are exact.** A code is only ever sent to a redirect URI the client
  has registered, https, or http to loopback, where the port is allowed to differ
  as RFC 8252 asks. A client or redirect that cannot be trusted is refused on
  the controller's own page and never followed. The list is read again when a
  sign-in is approved and when its code is exchanged, so editing a client's
  redirect URIs takes effect at once: the sign-ins still waiting for an address
  that was removed, and any code issued for it that has not been exchanged, are
  refused, with a sentence saying to start again. Connections already made keep
  working; an edit does not end them.
* **Registration is open, access is not.** A client may register itself or name
  itself by a client ID metadata document, and gets nothing until a person signs
  in and approves it. A client that registers again describing itself exactly as
  before (the same name, client URI and redirects) is handed the registration
  it already has rather than a new one. New registrations are limited to a
  hundred an hour per address (a client named by a metadata document is not
  counted against that) and a client that never completes a sign-in,
  self-registered or named by a document, is removed after a day. A revoked one
  is kept, so the revocation holds. The document fetch is fenced (https, no
  redirects, public addresses only
  unless `security.allow_private_egress` is on, five seconds, five kilobytes)
  and the addresses it refuses are judged after resolution by the same ranges as
  every other outbound URL, NAT64 and 6to4 spellings of a private address included,
  because it is a request this process makes on an unauthenticated caller's
  say-so. `security.mcp_open_registration: false` closes both, leaving only the
  clients an administrator creates, which may be confidential and carry a secret.
* **Audited.** `mcp_client.register`, `mcp_client.create`,
  `mcp_client.update`, `mcp_client.secret_rotate`, `mcp_client.revoke`,
  `mcp_connection.grant`, `mcp_connection.deny`, `mcp_connection.revoke`,
  `mcp_connection.replay`,
  `mcp_connection.revoke_all` when a password change, an administrator's
  password reset or **Sign out other sessions** ends every connection an
  account holds (a connection outlives any session, and whoever does either
  suspects a credential of theirs is loose),
  `mcp_connection.context` when the connection's owner chooses the repositories
  it may read source from and write notes to, and
  `mcp_connection.call` for every tool call that changed or tried to change the
  fleet. The route the call reached writes its own row as well, with the
  connection as the actor.
* **Over https only.** Turning it on where the controller is reached over plain
  HTTP raises `mcp_oauth.plain_http`, because the codes and tokens would cross
  the network readable. Set `server.external_url`: without it the addresses the
  metadata advertises come from each request's Host header. On a controller
  that listens off this machine that is an error, `mcp_oauth.no_external_url`,
  and the controller will not start with `security.mcp_oauth` on until it is
  set; left unset, the setting does not turn itself on there.

People see and disconnect their own connections under **Settings → MCP
connections**; an administrator sees everybody's there, and the clients.

### Sessions

`HttpOnly`, `SameSite=Lax`, `Secure` when the external URL is https or TLS is
terminated by Zoomies. Default lifetime seven days. Changing a password
invalidates every other session for that user.

Login is rate limited per source address (default 10/minute) and, more loosely,
per account, so a pool of addresses does not buy an unbounded budget against one
person. An unknown username runs the argon2 KDF anyway, so response timing does
not enumerate accounts either. Nor does the answer: "this account is disabled"
is only said to somebody who has already given the right password, and an
SSO-only account (which has no password to prove anything with) is refused
exactly as an unknown username is. The reason is in the log and the audit trail
for whoever is diagnosing it.

A failed sign-in records the submitted username only when it names an account
that exists. An unrecognised one is stored as a short fingerprint instead: a
password typed into the username field is a common slip, and the audit log is
readable by every viewer on the instance.

Single sign-on binds its `state` to the browser that started the handshake with
a short-lived cookie, so a callback obtained by an attacker signing in as
themselves cannot be replayed into somebody else's browser.

### Two-step verification

A local account can add a second step to its sign-in: a six-digit code from an
authenticator app (RFC 6238, SHA-1, six digits, thirty seconds, the default
every app implements). It is off until the account's owner turns it on from
**Settings → Account**, and an administrator can require it of every password
account with `security.require_two_step`. [Two-step verification](https://zoomies.sh/two-step/)
is the walkthrough; this is what it protects and what it costs.

**What it protects.** A password that has leaked (reused elsewhere, phished,
read over a shoulder) is no longer enough to sign in. The code is good for its
thirty-second step and one either side, and only **once**: the step each
accepted code belongs to is recorded, and a code for that step or an earlier
one is refused, so a code relayed by a phishing page or seen on a screen is
worth nothing the second time.

**How it is stored.** The authenticator key is sealed with the instance
encryption key, like a GitHub App private key; a copy of the database without
the key file does not yield it. The QR code is drawn on the controller as SVG,
so the key never passes through a third-party script or image service.

**The sign-in.** After a right password the controller answers `202` and sets
a pending-sign-in cookie (`HttpOnly`, `SameSite=Strict`, scoped to
`/api/v1/auth`) instead of a session. It lasts five minutes, is bound to the
browser that typed the password, is not a session anywhere else, and ends after
five wrong codes, after which the password has to be typed again. Every code
attempt is charged to the same per-address and per-account limits as a password
attempt, so the code is no cheaper to guess than the password in front of it.

**Recovery codes.** Turning it on shows ten single-use recovery codes, once.
Only their SHA-256 hashes are stored. Any one of them stands in for the app at
sign-in, or when turning two-step off, and is spent by it; the account page says
how many are left, and issuing a new set (which takes the password and a current
code) voids the old one.

**Turning it off** takes the current password and a current code, so a stolen
session cookie cannot remove the second factor from the account it stole.
Turning it on ends every other session of the account: they were signed in with
less than the account now asks for.

**The reset procedure.** Somebody who has lost their phone and their recovery
codes asks an administrator, who resets it from the account's row on
**Settings → Users**, or with `zoomies users reset-two-step <user-id>` and an
admin API token. The key and codes are removed, the account's sessions end, and
the reset is recorded as `user.two_step_reset`, naming who did it. The owner
then signs in with their password alone, or, where two-step is required, sets
it up again at that sign-in. Check who is asking before you reset: a reset is
exactly what somebody holding only a stolen password would ask for. If the only
administrator has lost theirs and holds no API token, stop the controller and
remove the row with `sqlite3 <database> "DELETE FROM user_two_step WHERE
user_id = 'usr_…'"`, which is the same thing done by hand; whoever can do that
already has the database.

**What it does not cover.**

* *Single sign-on accounts* are never asked. They sign in at the identity
  provider, and that is where a second factor for them belongs, require it
  there. A second prompt here would enforce nothing the provider does not.
* *API tokens* are never asked. A token is already a long random secret the
  caller holds, something you have, and the automation it is made for has
  nobody to type a code. Keep tokens scoped and expiring, and revoke one the
  moment it may have leaked.
* *Sessions that already exist* when `security.require_two_step` is turned on
  carry on until they expire or their owner signs out; the requirement applies
  at the next sign-in.
* *Authentication switched off* (`security.disable_auth`) asks for nothing at
  all.
* *A public view* (`security.public_view`) asks nothing of somebody who only
  reads the fleet; anything else still needs an account, and its second step.

---

## 5. Webhooks

Every delivery is verified with HMAC-SHA256 against the installation's secret,
compared in constant time. A delivery with a bad signature is recorded as
`rejected` and shows up on the Installations page; a burst of them means
somebody is probing you, and you should be able to see that.

The webhook endpoint is the one unauthenticated route that mutates state. It:

* accepts only `POST` with a body under 5 MiB;
* only acts on `workflow_job` and `ping`;
* records every delivery, accepted or not;
* never trusts a repository or label value beyond matching it against pools you
  configured.

If your controller is not reachable from GitHub, turn on `github.poll_fallback`
(it is on by default). Polling is slower but it is not less safe.

---

## 6. The dangerous toggles

Each of these is off by default and is named when it is on, and each is listed
here with what it actually costs. The instance-wide settings are warned about at
startup and shown in the UI's problems drawer -- except `server.bind` without
TLS, which is printed at startup and by `zoomies config check` but kept off the
drawer, because it is true of every fleet behind a TLS-terminating proxy and a
count that is always amber is a count nobody reads. The per-pool settings are
not startup matters at all: they are shown on the pool's own page and in the
drawer for as long as the pool has them.

This section is about what each dangerous setting *costs*.
[Problem codes](https://zoomies.sh/problem-codes/) is the other half: every code Zoomies can
raise, dangerous or not, with its severity and what to do about it.

### `pool.docker_mode: host-socket`

Bind-mounts the host's `docker.sock` into every runner in the pool.

**Any job on this pool can start a privileged container, mount the host's root
filesystem, and become root on the host.** It also sees, and can stop, every
other container on the host, including other runners and Zoomies itself.

Use it only when every repository that can reach this pool is as trusted as the
host. Prefer `dind`.

The runner is not root in its container, so Zoomies also adds the group that
owns the socket to it. That is what makes the mount usable, and it is worth
knowing that it is the whole of the access control here: nothing else stands
between a job and that daemon.

### `pool.docker_mode: dind`

Runs a privileged `docker:dind` sidecar per runner, sharing a network namespace.

The job gets a real Docker daemon it can build with, and it cannot see the
host's containers. But the sidecar itself is `--privileged`, so a container
escape *from the sidecar* reaches the host. This is a real improvement on
`host-socket` and still not a security boundary you should bet a production
host on.

Either mode gives the job a daemon; the client comes from the image, and a pool
on the stock runner image is switched to its Docker variant as it asks for one.
An image of your own has to carry the client itself. See [Jobs that build
container images](https://zoomies.sh/configuration/#jobs-that-build-container-images).

A fleet built to produce container images decides about that privileged sidecar
once, and a row per pool per pass about a decision already taken is what teaches
an operator to skim the problems list, so `security.docker_in_docker_expected`
stops `dind` being listed among the dangerous settings. It changes nothing about
the runners, and it silences that sentence alone: `host-socket` and persistent
runners still say what they cost, and the setting itself is listed as
`dind.expected` so the fleet's own record says what it chose not to be told.

### `pool.ephemeral: false`

Runners persist across jobs.

Job N+1 inherits everything job N left behind: cloned source, build caches,
environment variables, credentials written to disk, background processes. This
is the single largest isolation regression available in the product. It exists
because some workloads genuinely need a warm cache.

### `pool.cache.scope: repository` under an organisation installation

The pool names the repository its cache is for, but its runners register to
the organisation, and GitHub gives a runner any queued job whose `runs-on`
matches its labels. A job from another repository that asks for this pool's
labels lands on one of its runners and reads and writes the cache; the
sharing the scope exists to prevent, held off only by a discipline kept in
other people's workflow files.

Give such a pool a branded label that only that repository's workflows use.
Zoomies warns about the combination on the pool's page and in the problems
panel, because the cache's privacy depends on something it cannot see. A
repository-targeted installation registers runners that only that
repository's jobs can reach, so there the cache is as private as it looks.

### `pool.run_as_root: true`

The job runs as UID 0 inside the container. Combined with any Docker mode, or
with a container escape, this is materially worse than the default.

### `agent.backend: process`

No container at all. Workflow steps run directly on the host as the agent's
user, sharing its filesystem, package manager, network and SSH agent.

If the agent runs as root, every workflow step from every matched repository
runs as root on that host. Zoomies warns about this combination specifically.

On Windows this is the only backend, and the service runs as LocalSystem unless
you reconfigure it, so the same sentence applies with "root" read as
"LocalSystem". File modes mean nothing there, so what keeps another local
account away from the agent's credentials is the ACL `zoomies agent join` sets
on `%ProgramData%\zoomies`: SYSTEM and Administrators only. There is no Windows container to destroy: what "ephemeral" means
there is a fresh work directory and a single-use registration on a machine that
keeps its state, and a drain is a kill of the runner's process tree rather than
an interrupt.

### `server.bind: 0.0.0.0` with `server.tls.mode: off`

Session cookies, API tokens and the GitHub App private key you paste during
setup all cross the network in cleartext.

This is legitimate *behind a TLS-terminating reverse proxy*, which is why it is
a warning rather than an error. If that is your setup, also set
`server.trusted_proxies` so audit entries record the real client address rather
than your proxy's. The word `cloudflare` stands for Cloudflare's published
ranges when Cloudflare is the proxy.

### `server.allowed_origins: ["*"]`

Switches the origin check off. Browser requests that change state are normally
accepted only from this controller's own origin (or an origin listed here), so
that a page on some other site an operator happens to visit cannot use their
session cookie to create pools, drain runners or mint tokens. `"*"` accepts any
origin, which is exactly that cross-site request forgery. List the origins that
actually host the UI instead. An `http://` origin on an `https://` controller
is warned about for a related reason: a plaintext page can be rewritten in
transit, and whatever rewrites it inherits the permission the entry grants.

### `server.trusted_proxies: [0.0.0.0/0]`

Believes `X-Forwarded-For` from every address. It is what makes a header-based
setup "just work", and what it costs is that any client can choose the address
the audit log records for it and defeat login rate limiting by rotating the one
it claims. List your proxy's own range, or the word `cloudflare`.

### `security.mcp_oauth: true` over plain HTTP

Raises `mcp_oauth.plain_http`. The authorisation code in the browser's
address bar, and the access and refresh tokens the token endpoint answers,
cross the network readable, and whoever reads one uses the fleet as the person
who connected until it expires or is revoked. Serve the controller over https;
where that cannot be done, leave OAuth for MCP off and give an agent an API
token over a network you trust.

### `security.rate_limit_logins: 0`

Turns the sign-in rate limit off, so a password can be guessed from one address
as fast as the controller answers. The default of ten attempts per address per
minute is generous for a person and hopeless for a dictionary.

### `oidc.link_by_username: true`

Lets the first single sign-on login by a username take over an existing local
account of that name, password and role included. Off, a username alone links
only to an account created for SSO, one with no password, so an identity
provider whose users can influence their own username claim cannot hand someone
the local `admin` account. Turn it on for the migration from local passwords to
SSO, when every account is known and the provider is trusted to spell names
correctly, and turn it off again afterwards; or link the accounts by hand and
leave it off.

### `oidc.hide_password_login: true`

Takes the password form off the sign-in page while single sign-on is working,
and refuses a correct password from anybody below administrator, so the only
way in for most people is the identity provider, where their second factor,
their leaving date and their password policy already live. It never hides the
form while single sign-on is down: the page would then offer nobody a way in.

The break-glass door is **`/login?password`**: an administrator opens it, gets
the password form, and signs in as usual, two-step verification included, on
the day the identity provider is the thing that is broken. Keep at least one
administrator with a password and an authenticator for that day. With single
sign-on off, the setting raises `oidc.password_login_hidden_without_sso`.

### `oidc.issuer: http://…`

Discovery, the token exchange and the client secret all travel to the identity
provider over plaintext HTTP, where anything on the path can read or replace
them and sign in as anyone. Use the issuer's `https://` address; a loopback
issuer for local development is not warned about.

### `security.disable_auth: true`

Every request is treated as an administrator.

Zoomies **refuses to start** with this set unless the controller looks
unreachable from anywhere but this machine, which means a loopback bind *and*
no `server.external_url` *and* no `server.trusted_proxies`. A loopback bind on
its own is not enough: loopback behind a reverse proxy is the deployment this
documentation recommends, and it is reachable by the whole internet. With
nothing in front of it, this is a warning, because it is genuinely useful for
local development.

### `security.public_view: true`

A request with no credential is a visitor: the viewer role narrowed to reading
pools, hosts, runners, jobs, usage, runner logs, installations, webhook
deliveries, Kennel Club and the problems list. It is a fixed list of read
scopes, so a read added to the product later is hidden from visitors until
somebody adds it to the list, and every change is refused with 403. The audit
log, accounts, tokens, settings, AI context and Eli are not on it, and the event
stream sends a frame only to somebody who could fetch what it carries.

A token that is presented and wrong is still refused with 401. A session cookie
that no longer checks out becomes a visitor and is cleared, because the person
holding it could not otherwise sign out of a session that has ended.

This is a **warning** wherever it is on, never a refusal: a public fleet is a
deliberate choice. Turn it off anywhere a stranger should not see your runner
names, repositories and logs. [Public view](https://zoomies.sh/public-view/) explains what a
visitor sees and what running one costs.

### `server.allowed_origins: "*"`

The cross-origin check is off. Any site a signed-in operator visits can make
state-changing calls to this controller with their session; the `SameSite=Lax`
cookie still refuses most of them, but it becomes the only thing left. List the
origins you actually serve the UI from instead.

### `agent.allow_insecure_http: true`

The agent talks to a remote controller over plain HTTP. Its token and the JIT
runner configuration in every create task (a live registration credential for
your runner group) cross the network in the clear. Without this, a plaintext
controller URL that is not on loopback is refused outright.

### A backup remote with no `passphrase`

The archive the fleet uploads is the whole of it: every repository and job it
has seen, every account and password hash, every API token's hash, and the
GitHub App's private key and webhook secrets, sealed with the instance
encryption key, which is the one thing in the file that is not readable, and
only until somebody who has the bucket also has that key. In a bucket, with no
passphrase, it is a file that opens for whoever can read the bucket: the
provider, anyone holding a key to it, and anyone who finds it left public.

Setting a passphrase on the remote seals the archive with argon2id and
AES-256-GCM before it leaves the host, so the bucket holds something its owner
cannot open. Keep the passphrase where you keep the encryption key; nothing on
the controller can recover a lost one, which is the same trade the encrypted
download makes. The startup validator raises `backup.remote_plaintext` for as
long as a remote has none.

### A backup remote reached over `http://`

The access key, the request signature and the archive itself cross the network
in the clear. Whoever is on the path reads the fleet and, holding the key,
writes to the bucket afterwards, which includes replacing a backup with one of
their own choosing, for somebody to restore later. Loopback and a network you
own end to end are the only endpoints this is reasonable for, which is why the
warning (`backup.remote_insecure`) excludes loopback and nothing else.

### `security.allow_private_egress: true`

Six settings make the controller send a request to a URL an administrator
typed: `oidc.issuer`, `github.api_base_url`, `capacity_demand.destination_url`,
`agent.runner_download_url`, a backup remote's endpoint and a provider's. Left
unchecked, each is a way to aim this process at its own neighbourhood (the
cloud metadata service at `169.254.169.254`, an admin port bound to loopback, a
service on the LAN that trusts anything inside it) and read the answer back
out of an error message. Those are the platform's resources, not the fleet's,
so by default each of them is refused when it is written through the API
(`PATCH /settings`, a settings import, a backup remote, a direct provider or
an installation's own API base URL) if it names loopback, an unspecified address, link-local (IPv4 and `fe80::/10`),
RFC 1918, carrier-grade NAT (`100.64.0.0/10`), IPv6 unique-local
(`fc00::/7`), the deprecated site-local and IPv4-compatible ranges, reserved,
broadcast and multicast addresses, an IPv4 address carried inside IPv6
(`::ffff:10.0.0.1`, NAT64, 6to4), `localhost` and anything under
`.localhost`, or an address in a spelling other parsers read as IPv4
(`2130706433`, `0x7f000001`, `127.1`).

The same value in `zoomies.yaml` or a `ZOOMIES_*` variable is only a warning
(`egress.private_target`), and never stops the controller starting. With the
switch on that check has nothing left to refuse, so the switch itself is named
instead (`egress.private_allowed`, also a warning) at startup and in the
problems drawer. The file
and the environment belong to whoever runs the process, who is already trusted
with everything this guard protects; the person the guard is for is somebody
with settings rights typing a URL into a form. And an upgrade must not stop an
install that has been talking to a LAN Enterprise Server or identity provider
for a year: it starts, warns, and names the key.

The switch is platform-scoped: it changes what the process may dial from its
own machine, so on an instance one team runs for another the fleet's
administrators cannot turn it on. On a single-team instance whose identity
provider, Enterprise Server or Proxmox cluster is on the LAN, or whose backups
go to a MinIO beside the controller, turning it on is the expected answer, and
the refusal names it.

Know its limits. The check reads the address it is given and **resolves
nothing**: a validator cannot answer DNS honestly, because the answer at
startup need not be the answer at the next request, and a name somebody else
controls can be pointed at `10.0.0.1` afterwards. Any host name other than the
local ones above is admitted on trust, and the clients that dial these URLs do
not re-check the address they connect to. The refusal happens where a value
is written, so a destination saved in the database before this check existed
is not refused until it is next edited. A provider
reached through a [Tailcat gateway](#tailcat-private-connections) is not
checked, because its endpoint is dialled from the gateway's network, which is
the private network it was installed to reach. Where the controller runs
somewhere with a metadata service worth protecting, a host firewall rule that
drops its traffic to `169.254.169.254` is the complement that also covers
names.

### `assistant.allow_private_provider: true`

The assistant's providers have a switch of their own, because a model on this
machine (Ollama at `http://localhost:11434`) is the ordinary case there and
the blanket `security.allow_private_egress` would open every other URL with
it. Off, saving a provider whose address names this machine or a private
network is refused with a 422 that names this switch in the `base_url` field
error; the same address in a provider row an upgrade brought along is left
alone until it is re-saved. On, that address is accepted, and the switch is named at startup and
in the problems drawer as `egress.private_provider_allowed`, a warning, for
the reason the blanket switch is: an administrator who can add a provider can
aim the assistant's requests at the cloud metadata service or an admin port on
loopback and read the answer out of a failed check. It is platform-scoped, as
the blanket switch is. The check reads the address and resolves nothing; the
limits above apply.

`assistant.local_only` is the opposite kind of switch and is not listed here:
on, the assistant's dialer refuses any connection that resolves to a public
address, after resolving it, so nothing the assistant is told can leave this
machine or the LAN whatever a provider row says. A hosted provider cannot be
reached while it is on, and the Eli AI Assistant settings page says so beside it.

### `provider.insecure_skip_verify: true`

Zoomies talks to a hypervisor without verifying its certificate. The API token
(which can create, configure, start, stop and destroy virtual machines, and
run commands inside them) crosses the network to whoever answered. It is a
per-provider setting, warned about on the provider and in the problems drawer
for as long as it is on.

There is almost never a reason to leave it on. A Proxmox cluster signs its own
certificates with a CA at `/etc/pve/pve-root-ca.pem`; paste that into the
provider's CA field and the connection is verified properly. See
[Proxmox VE](https://zoomies.sh/proxmox/#tls).

### `oidc.link_by_username: true`

A successful SSO login adopts an existing account that still has a local
password. Whoever controls a username at your identity provider then controls
the local account of the same name, `admin` included. Turn it on for the one
migration where that is what you mean, then turn it off again.

### `metrics.public: true`

`/metrics` served without authentication. No repository or workflow name is a
label (the code stopped putting them there) but pool names, backend kinds,
runner and host states, the id of each GitHub App installation a call was made
for, and the build's version and commit are, and together they tell a stranger
what you run and how busy it is. Prefer giving Prometheus a viewer API token.

The host series are among them. `zoomies_host_os_checks`,
`zoomies_host_reboot_pending` and `zoomies_host_health_report_age_seconds` carry a
host's id and counts, so a stranger can see which of your hosts has a failing check
or is waiting for a reboot, though never which check. The Kennel Club counters,
such as `zoomies_kennel_findings_opened_total`, carry the code of the check that
raised each finding, for the whole fleet and not per repository, so they say how
many findings of each kind you have, including exposure ones. None of them names a
repository or quotes a finding; all of them say something about how exposed you
are.

### `status.mode: public`

The fleet status at `/status`, the badge at `/status.svg` and the projection at
`/api/v1/status` answer anyone who can reach the controller. It exists for the
developer whose job has queued and who has no account: GitHub tells them only
"queued", and the controller knows whether it is capacity, labels, a lost
permission or a webhook secret that stopped verifying.

What it discloses is how the fleet is doing, not who it is. It carries no pool,
host, repository, runner or job name (a test searches its body for every name
a fixture fleet has) only the fleet's state, the release the controller runs,
banded counts of queued and running jobs, the queue wait rounded to the minute,
and the codes of the fleet's current problems with a fixed sentence each.
Problems about the controller itself (its backups, its lease, its own release)
are never in it. Together that still tells a stranger that a Zoomies fleet is
here, which version it runs and when it is struggling, so it is a warning for
as long as it is on.

On a listener that is not on loopback and does not terminate TLS it is refused
at startup: `public` invites people to an address that would carry every
sign-in in the clear. `authenticated` shows the same page to anyone signed in,
whatever their role, and costs nothing that the rest of the interface does not.

### `server.allow_indexing: true`

`robots.txt` invites search engines into the interface, advertises
`/sitemap.xml`, and the page's own directive changes from `noindex, nofollow` to
`index, follow`.

Nothing behind authentication becomes readable (the API still refuses a request
without a session) but the sign-in page, this controller's address and the fact
that it is a Zoomies fleet all become public knowledge, findable by anyone
searching for exactly that. Leave it off unless the instance is deliberately
public.

### `agent.allow_unverified_runner_download: true`

**What it does.** Lets the process backend install an `actions/runner` archive
whose SHA-256 Zoomies does not know.

**What it costs.** The runner is downloaded and then executed on the host as the
agent's user. Without a digest to check, anything between the host and the
download source -- a mirror, a proxy, a compromised network -- can substitute its
own archive, and the agent will run it. Zoomies ships the digests for the
release it pins, so the setting is only ever needed for a release it does not
know about.

**Do this instead.** Pin the release with `github.runner_version` and put its
digest, from the actions/runner release notes, in `agent.runner_sha256`.

### `agent.insecure_skip_verify: true`

The agent does not verify the controller's TLS certificate. Anything on the
network path can impersonate the controller and hand the agent arbitrary
containers to run. Pin the CA with `agent.ca_file` instead.

### `agent.extra_ca_file`

**What it does.** Mounts a PEM root CA read-only into every container runner
on the host, and into its docker-in-docker sidecar, and adds it to what they
trust, so jobs work behind a proxy that re-signs TLS.

**What it costs.** Whoever holds that CA's private key can read and alter every
TLS connection a job makes, including its traffic to GitHub. Behind an
intercepting proxy that is already true of the network, and this only makes
the runners agree to it; with any other CA it is an interception you have
invited. It is reported as information (`agent.extra_ca`) rather than warned
about, because on the networks that need it there is no safer alternative.
[The configuration page](https://zoomies.sh/configuration/#agentextra_ca_file-behind-a-proxy-that-re-signs-tls)
has the mechanics.

---

## 7. What is tested

The claims above are not assertions of intent. Each of these is a test that was
run against the code with its rule removed and confirmed to fail before it was
kept; a rule that could not be made to fail did not ship.

| Claim | Where |
| --- | --- |
| Every route refuses the roles below it, and every scoped token refuses the routes outside its resource | `TestRouteAuthorisation`, `TestScopedTokenRouteAuthorisation` (`internal/api`) |
| A scope narrows a role and never widens it | `TestScopesAreNarrowerThanRoles` (`internal/api`) |
| All five agent routes refuse a user credential, an anonymous caller and a revoked host | `TestAgentRoutesRefuseAUserCredential`, `TestADeletedHostsAgentTokenIsRefused` (`internal/api`) |
| A host cannot report a runner, a task result or a log chunk that is not its own | `TestAHostCannotReportOnAnotherHostsRunner`, `TestAHostCannotReportATaskResultForAnotherHostsRunner`, `TestLogRelayRefusesAnotherHostsStream` (`internal/controller`) |
| Secrets are absent from responses, error bodies, audit rows, event frames, metrics and the controller's log | `TestSecretsAreNeverInAResponse`, `TestSecretsAreNeverInAFailure` (`internal/api`) |
| A task carries the runner's registration credential and no controller secret | `TestACreateTaskCarriesNoControllerSecret` (`internal/controller`) |
| A `process` runner's environment is built, not inherited | `TestProcessChildEnvironmentIsBuiltNotInherited` (`internal/backend`) |
| Disabling an account ends its sessions, not merely its access | `TestDisablingAUserEndsItsSessionsAtTheAPI` (`internal/api`) |
| A cross-origin sign-in is refused, and a non-browser client is not | `TestCSRFRefusesACrossOriginLogin` (`internal/api`) |
| `X-Forwarded-Proto` is believed only from a trusted proxy | `TestForwardedProtoIsBelievedOnlyFromATrustedProxy` (`internal/api`) |
| Enrolment is rate limited, on a counter of its own | `TestAgentJoinIsRateLimited` (`internal/api`) |
| A live stream ends within one heartbeat of its credential being revoked, and keeps running while it stands | `TestAStreamEndsWhenItsCredentialIsRevoked`, `TestALogStreamEndsWhenItsCredentialIsRevoked`, `TestALiveStreamSurvivesItsOwnHeartbeat` (`internal/api`) |
| A name carrying markup is rendered as text, and a runner's output cannot retitle the page, clear it or open a dialog | `web/tests/hostile-input.spec.ts` |
| A link a runner printed is followed only when it is http or https, and then with `noopener` | `followableLink` in `web/src/lib/logs/LogViewer.svelte` (see the note in ZF-104's fourth pull request) |
| What decides a finding is pure: the evaluator imports nothing impure, reads no clock and touches neither the filesystem nor the environment | `TestTheEvaluatorImportsNothingImpure`, `TestTheEvaluatorNeverReadsAClock`, `TestTheEvaluatorTouchesNoFilesystemOrEnvironment` (`internal/kennel`) |
| The workflow parser the collector and the offline check share imports nothing from this module, and reads no clock, file or environment, so a stranger's workflow file is judged by the same code wherever it is read; what it keeps of a file is numbers and positions, and the fuzz target fails on any run of the file's text in them | `TestTheParserImportsNothingFromThisModuleOrTheOutsideWorld`, `TestTheParserNeverReadsAClockTheFilesystemOrTheEnvironment`, `FuzzInspect` (`internal/kennel/workflow`) |
| Kennel Club calls only the GitHub endpoints [its page](https://zoomies.sh/kennel-club/#every-github-request-it-may-make) lists, in the reader and in the controller's live request log | `TestKennelClubCallsOnlyTheEndpointsItDocuments` (`internal/github`), `TestEveryRequestKennelClubMakesIsOneItDocuments` (`internal/controller`) |
| A finding never repeats what a repository, or an operator, wrote, and an event name that is not a known one is ignored | `TestAFindingNeverRepeatsWhatARepositoryOrAnOperatorWrote`, `TestAnEventNameThatIsNotOneOfTheKnownIsIgnored` (`internal/kennel`) |
| A repository's evidence reaches an assistant in a block of its own that says it is untrusted | `TestARepositorysEvidenceArrivesInABlockOfItsOwnThatSaysItIsUntrusted` (`internal/mcp`) |
| A body over the limit is refused, and the log relay is exempt | `TestOversizeRequestBodyIsRefused`, `TestARunnerThatPrintsMoreThanTheBodyLimitIsNotCutOff` (`internal/api`) |

### The supply chain

The [OpenSSF Scorecard](https://scorecard.dev/viewer/?uri=github.com/eyupio/zoomies)
on the README is the outside view of the same discipline, re-run weekly by
`.github/workflows/scorecard.yml`. Most of what it measures is in this
repository and checked by it: every action pinned to a commit, workflow tokens
that grant nothing at the top, Dependabot on all three ecosystems, CodeQL on
every pull request, `govulncheck` on every change, a fuzz workflow, and a
release that ships its build provenance twice; a Sigstore bundle for
`gh attestation verify` and the same statement as `.intoto.jsonl` for SLSA
tooling. `osv-scanner.toml` at the root is the one place an advisory is
ignored, and only when `govulncheck` has already shown the package is not in
the import graph and no version fixes it; an entry there names why.

Three of its checks are repository settings rather than files, so no pull
request can change them. They are recorded here so the next person to read the
score knows what it is measuring:

* **Branch-Protection** wants `main` to refuse a direct push and a force push,
  and to require the CI status checks before a merge.
* **Code-Review** counts merged pull requests approved by someone other than
  their author. A project with one maintainer scores zero here until it has
  two, and the score is honest about that.
* **CII-Best-Practices** looks for a badge earned at
  [bestpractices.dev](https://www.bestpractices.dev/projects/14604), a
  questionnaire a maintainer fills in about the practices this document
  describes. The project holds the passing badge; Scorecard picks it up on
  its next weekly run.

---

## 8. Hardening a production install

1. Create the first account before anyone else can. If you deployed with
   the compose file rather than the installer, the controller is listening the
   moment it starts: read the setup token out of its log
   (`docker compose logs zoomies`) and finish the first-run page, or, when no
   person is at the console, have the provisioner create the account from
   `ZOOMIES_BOOTSTRAP_ADMIN` and a 0600 token or password file, so the
   instance is never reachable without an owner. Keep the
   origin firewalled to your proxy as well, as the compose file's comments say.
1. Run the controller as a dedicated unprivileged user
   (`zoomies init` creates one).
2. Use a **rootless** Docker or Podman socket. The installer detects and prefers
   one, and on a systemd, cgroup v2 host it delegates the `cpu`, `cpuset`, `io`,
   `memory` and `pids` controllers to that daemon's user slice for you, so a
   pool's CPU quota or memory limit is not refused there. A host still on
   cgroup v1 needs `systemd.unified_cgroup_hierarchy=1` on the kernel command
   line and a reboot first, setup warns rather than guessing at that for you.
3. Terminate TLS with a certificate GitHub trusts: either in Zoomies
   (`tls.mode: files`) or in a reverse proxy, and then set `trusted_proxies`.
4. Keep `ephemeral: true` and `docker_mode: none` on every pool you can.
5. Set `max_runners` on every pool. It is your only backstop against a runaway
   workflow.
6. Give automation scoped API tokens with expiry, not admin tokens. A coding
   agent on `/mcp` or `zoomies mcp` gets a `viewer` token unless it is meant to
   act: it reads workflow logs, which anyone who can open a pull request can
   write.
7. Put the encryption key in `ZOOMIES_ENCRYPTION_KEY` or a `0600` file, and back
   it up somewhere that is not the same backup as the database.
8. Watch the audit log. `zoomies audit tail` and the Audit page both work.
9. Keep the runner image current: it carries the `actions/runner` release and
   its .NET dependency, and GitHub deprecates old runner versions.

## 9. Reporting a vulnerability

Open a [private security advisory][advisory] on the repository rather than a
public issue. Please include the version (`zoomies version`), the configuration
with secrets removed (`zoomies config print` produces it already blanked) and
what an attacker gains.

`SECURITY.md` in the repository root says the same thing, and is what GitHub
reads to offer "Report a vulnerability" on the repository's own security tab.
It also draws the line this document is the long form of: a dangerous setting
behaving dangerously is not a vulnerability, and a way to reach one without
setting it is.

[advisory]: https://github.com/eyupio/zoomies/security/advisories/new

## Tailcat private connections

Private tunnels expose only the agent API and retain Zoomies join-token and
agent-token authentication. No administrator route, SSH service, arbitrary
port forwarding or subnet routing is enabled. Tunnel identities are encrypted
in SQLite; agent capability addresses live in mode-0600 agent credentials.
Enrolment commands contain secrets and must not be published. Disabling
`server.tailcat_enabled` and restarting closes private connectivity; it does
not rotate the saved identity. See [private host credential handling and relay
limitations](https://zoomies.sh/private-hosts/#restarts-and-credential-protection).
