Skip to content

Zoomies 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:

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
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 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, 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 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 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 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

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

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.

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

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 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 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 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, 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.
  2. Run the controller as a dedicated unprivileged user (zoomies init creates one).
  3. 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.
  4. Terminate TLS with a certificate GitHub trusts: either in Zoomies (tls.mode: files) or in a reverse proxy, and then set trusted_proxies.
  5. Keep ephemeral: true and docker_mode: none on every pool you can.
  6. Set max_runners on every pool. It is your only backstop against a runaway workflow.
  7. 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.
  8. 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.
  9. Watch the audit log. zoomies audit tail and the Audit page both work.
  10. 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 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.

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.