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) butsudoonly 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
processbackend 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_limitcan 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. Usemax_runnersas 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:
ZOOMIES_ENCRYPTION_KEY(preferred for containers)security.encryption_key_file: a0600file; Zoomies refuses to read a key file that is group- or world-readablesecurity.encryption_keyinzoomies.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 initon 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_ADMINwith 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) andbootstrap.ignoredsays so until they are removed. Every path writes anauth.bootstrapaudit 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 takesoidc.link_by_username. Anemailclaim is used as a username only when the provider saysemail_verified. - API tokens:
zoo_<prefix>_<secret>, sent asAuthorization: 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, whensecurity.mcp_oauthis on. They are accepted on/mcpand 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
/mcpalone. 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;/mcpreaches 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_egressis 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: falsecloses 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_allwhen 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.contextwhen the connection's owner chooses the repositories it may read source from and write notes to, andmcp_connection.callfor 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. Setserver.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 withsecurity.mcp_oauthon 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_stepis 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
POSTwith a body under 5 MiB; - only acts on
workflow_jobandping; - 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.
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 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.
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
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
mainto 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¶
- 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 fromZOOMIES_BOOTSTRAP_ADMINand 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. - Run the controller as a dedicated unprivileged user
(
zoomies initcreates one). - 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,memoryandpidscontrollers 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 needssystemd.unified_cgroup_hierarchy=1on the kernel command line and a reboot first, setup warns rather than guessing at that for you. - Terminate TLS with a certificate GitHub trusts: either in Zoomies
(
tls.mode: files) or in a reverse proxy, and then settrusted_proxies. - Keep
ephemeral: trueanddocker_mode: noneon every pool you can. - Set
max_runnerson every pool. It is your only backstop against a runaway workflow. - Give automation scoped API tokens with expiry, not admin tokens. A coding
agent on
/mcporzoomies mcpgets aviewertoken unless it is meant to act: it reads workflow logs, which anyone who can open a pull request can write. - Put the encryption key in
ZOOMIES_ENCRYPTION_KEYor a0600file, and back it up somewhere that is not the same backup as the database. - Watch the audit log.
zoomies audit tailand the Audit page both work. - Keep the runner image current: it carries the
actions/runnerrelease 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.