Nucleic: Gitea Runner macOS VM Support

This commit is contained in:
2026-08-07 00:44:36 -07:00
parent 749f0be4fb
commit 33f299396a
47 changed files with 13159 additions and 0 deletions
+150
View File
@@ -0,0 +1,150 @@
# Security model
This document states what `gitea-macos-runner` protects, what it does not, and the operational
choices that follow. Read it before pointing the runner at an instance where untrusted code can
open a pull request.
## Summary
Workflow code runs **as the guest admin user, with passwordless sudo, and with no container
isolation**. The only isolation boundary is the virtual machine, and that VM is destroyed after a
single job. This is the same trust posture as GitHub's hosted macOS runners: the job owns the
machine, and the machine is thrown away.
## Trust boundary
```
┌─ host Mac ─────────────────────────────────────────────┐
│ daemon, admin PAT, registration token, base images │ ← trusted
│ ┌─ ephemeral VM ─────────────────────────────────┐ │
│ │ gitea-runner + workflow code (root-capable) │ │ ← untrusted
│ └────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────┘
```
**The VM is the boundary.** Everything inside it is treated as untrusted and disposable;
everything outside it is trusted. A guest that escapes Virtualization.framework and compromises the
host is **out of scope** — the design assumes Apple's hypervisor holds. If your threat model
includes hypervisor escape, this tool is not sufficient on its own; isolate the host at the network
level and treat it as a machine that may be compromised.
## What runs where
- **In the guest:** the `gitea-runner` binary and every step of the workflow. The runner registers
with `--labels "macos-arm64:host"` — the `:host` execution mode means steps run directly on the
guest OS rather than inside a container. There is no second layer of isolation, by design;
container isolation is not meaningfully available for macOS builds, and it would defeat the point
of a real macOS environment.
- **On the host:** the daemon, the Gitea admin PAT, the registration token, base images, and the
clone/boot/destroy lifecycle. **The admin PAT is never copied into a guest.**
Because the guest admin has passwordless sudo, a job can install software, load kernel extensions
the guest permits, read every file in the image, and reconfigure the guest arbitrarily. None of
that persists: the clone is deleted when the job ends and the next job starts from the untouched
base image. Nothing a job writes is visible to any later job.
## Ephemeral registration is server-enforced
Each VM registers a fresh runner with `--ephemeral`. Ephemerality is enforced **by the Gitea
server**, not by the runner or by this daemon:
- After the runner accepts one job, Gitea **refuses to dispatch a second job** to that
registration.
- Gitea **deletes the registration** once that job completes.
The security consequence matters: a runner token exfiltrated from inside a running job cannot be
used to fetch additional jobs, because the server has already spent that registration. The worst an
attacker can do with it is nothing. This is why a compromised job does not become a persistent
foothold in your CI queue — the compromised VM is destroyed and its credential is already dead.
## The shared registration token and its blast radius
The registration token is a different matter, and it is the sharpest edge in this design.
Gitea's registration tokens are **reusable by construction**, and creating a new token for a scope
**invalidates the previous one**. There is no API for minting a single-use, per-VM registration
token. A per-VM token is therefore impossible — not merely unimplemented.
So every VM presents the same registration token, and that token is necessarily present inside an
untrusted guest for the duration of registration.
**Blast radius if the token leaks:** an attacker can register arbitrary runners against the scope
the token covers (instance-wide, if you seeded `GITEA_RUNNER_REGISTRATION_TOKEN` on the server).
Such a runner can advertise your labels and thereby **receive and execute jobs**, which means it
can read whatever secrets those jobs are given and return forged results. It does not by itself
grant API access to Gitea, read repositories the runner is not assigned jobs from, or confer admin
rights.
Mitigations, in order of effectiveness:
1. **Constrain who can dispatch jobs to the label** (below) so a rogue runner has a small pool of
jobs to intercept.
2. **Register at org or repo scope** rather than instance-wide when only a few repos need macOS.
The token's reach is then limited to that scope.
3. **Rotate the token** by changing `GITEA_RUNNER_REGISTRATION_TOKEN` and restarting Gitea. Update
`gitea.registrationTokenFile` on the host at the same time; in-flight VMs that have already
registered are unaffected.
4. **Keep secrets out of macOS jobs where possible.** Prefer short-lived, narrowly scoped
credentials injected per job over long-lived org-level secrets.
## Limiting who can use the runner
By default an instance-wide runner will execute any job from any repo that writes
`runs-on: macos-arm64`. On an instance where untrusted users can push branches or open PRs that
trigger workflows, that is effectively arbitrary code execution on your CI Mac's VM.
Restrict it:
- **Register at repo or org scope** instead of instance-wide — the runner is then only offered jobs
from that repo or org.
- Use Gitea's per-repo and per-org **Actions runner settings** to control which repositories may
use a shared runner.
- Configure Gitea so that **workflows from forked-repository pull requests require approval** before
running. This is the single most important setting if your instance accepts outside
contributions.
Decide this deliberately. Nothing in the daemon restricts job origin; that control lives entirely
in Gitea.
## Secrets hygiene
- **Prefer the file forms of every credential:** `gitea.adminTokenFile` and
`gitea.registrationTokenFile` rather than `adminToken`/`registrationToken` inline in
`config.json`. For the admin token this is not merely a preference in one direction: config
validation requires **exactly one** of `adminToken` and `adminTokenFile`, so a stale inline token
cannot sit unnoticed beside a live token file.
- **`chmod 600` every token file** and keep it owned by the runner user:
```sh
chmod 600 ~/.config/gitea-macos-runner/admin-token \
~/.config/gitea-macos-runner/registration-token
```
`gitea-macos-runner doctor` checks this: its `token file permissions` check warns, and prints the
exact `chmod` to run, if any configured token file is group- or world-readable.
- **Token *files* keep secrets off `ps`.** Any local user can read another process's argument
vector; a token passed as `--token …` is visible there for the life of the process, and often in
shell history and logs too. The `--token-file` form passes a path instead, so the secret never
enters the command line.
- **Do not commit `config.json`** or any token file. If your config lives in a dotfiles repo, keep
the token files outside it.
- **The `guest.password`** is a credential for a throwaway machine, but it appears in the host's
config file and grants SSH into running guests. Treat the config file itself as sensitive
(`chmod 600`) and do not reuse a password from anywhere else.
- **Rotate the admin PAT** on the normal schedule you use for admin credentials. Its scope
(`read:admin` + `write:admin`) is genuinely powerful — it can enumerate and delete runner
registrations instance-wide — so it is the highest-value secret on the host. It lives only on the
host and never enters a VM; keep it that way.
## Host hardening notes
- Use a **dedicated Mac** for CI. The auto-login recommendation in
[setup.md](setup.md#25-install-the-service) means the disk is unlocked at boot, which is
acceptable for a purpose-built CI machine and not for a workstation holding other data.
- Run the daemon as a **non-administrator user** where practical. It needs a GUI session and
virtualization entitlements, not host root.
- Place the Mac on a **network segment that cannot reach production**. Guests get NAT'd egress
through the host; anything the host can reach, a job can reach.
- Keep the base image current. It is rebuilt from an IPSW, so refreshing macOS and the toolchain is
an `image build` away — and every job automatically picks up the new image.