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