8.6 KiB
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-runnerbinary and every step of the workflow. The runner registers with--labels "macos-arm64:host"— the:hostexecution 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:
- Constrain who can dispatch jobs to the label (below) so a rogue runner has a small pool of jobs to intercept.
- 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.
- Rotate the token by changing
GITEA_RUNNER_REGISTRATION_TOKENand restarting Gitea. Updategitea.registrationTokenFileon the host at the same time; in-flight VMs that have already registered are unaffected. - 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.adminTokenFileandgitea.registrationTokenFilerather thanadminToken/registrationTokeninline inconfig.json. For the admin token this is not merely a preference in one direction: config validation requires exactly one ofadminTokenandadminTokenFile, so a stale inline token cannot sit unnoticed beside a live token file. -
chmod 600every token file and keep it owned by the runner user:chmod 600 ~/.config/gitea-macos-runner/admin-token \ ~/.config/gitea-macos-runner/registration-tokengitea-macos-runner doctorchecks this: itstoken file permissionscheck warns, and prints the exactchmodto 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-fileform passes a path instead, so the secret never enters the command line. -
Do not commit
config.jsonor any token file. If your config lives in a dotfiles repo, keep the token files outside it. -
The
guest.passwordis 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 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 buildaway — and every job automatically picks up the new image.