- Mon 10 August 2026
- server admin
- Gaige B. Paulsen
- #apple, #gitlab, #macintosh, #programming, #security, #server admin
Why my Mac CI nodes kept forgetting they were allowed to read files, and the 30-kilobyte app that now remembers for them
Every so often a machine in my Mac build fleet would come back from a reboot —
or from a routine gitlab-runner upgrade — and the first job scheduled on it
would simply stop. Not fail. Stop. The log would show the job starting, and
then nothing, until the timeout eventually killed it an hour later. Retry on a
different node and everything was fine.
The cause, once I went looking, was sitting on the console of the machine
itself: macOS was showing a dialog asking whether gitlab-runner could access
files in a protected folder. There is nobody logged in at those machines to
click "OK." So the job waited. Forever, or near enough.
The fix is a small signed application called ci-runner-launcher whose entire
job is to start gitlab-runner and then do nothing else for the rest of its
life. That sounds absurd until you know how TCC works, so let's start
there.
Two facts about TCC
TCC — Transparency, Consent, and Control — is the macOS subsystem behind every "…would like to access your Desktop folder" prompt. Two of its design decisions combine into this problem.
First: TCC keys a grant to code identity, not to a path. For a properly
signed binary, "code identity" means the designated requirement — roughly,
the signing identifier plus the team ID — which stays constant across rebuilds.
But for an unsigned or ad-hoc-signed binary, TCC has nothing durable to hold on
to, so it falls back to the cdhash: a hash of the code itself.
gitlab-runner is distributed as a bare, unsigned binary. Every upgrade is a
new hash. Every new hash is, as far as TCC is concerned, a completely different
program that has never been granted anything. Approving it is not a one-time
act of administration; it is a subscription.
Second: TCC attributes a request to the responsible process, not to the
process that actually touched the file. It resolves responsibility by walking
up the process ancestry. This is the mechanism behind something every Mac
developer has relied on without thinking about it: grant Terminal Full Disk
Access, and suddenly everything you run inside Terminal can read protected
files. The grep you ran never asked for anything. Terminal did, on its
behalf.
Put those two together and the fix suggests itself. If a child inherits
the responsibility of its parent, then a parent whose identity doesn't change
can hold a grant on behalf of a child whose identity changes constantly. Grant
it once, at the top, and it flows downward — to the runner, to the job scripts
the runner spawns, and to xcodebuild beneath those.
So: a launcher. Developer ID signed, hardened runtime, notarized, stapled. It
resolves where gitlab-runner lives, spawns it, forwards signals to it, and
waits. Ansible points the LaunchAgent at the launcher instead of at the runner,
passing the runner's own arguments through unchanged.
Never exec the runner
Ordinarily exec is the tidy way to write a wrapper: set up whatever you need,
then replace your own process image with the target and get out of the way. No
extra process loitering, no signal forwarding to write, no exit status to
relay. Half the wrappers in /usr/local/bin are shell scripts ending in
exec "$@", and they're right to.
Here it destroys the whole point. exec keeps the process ID and replaces the
code, and TCC evaluates the signature at request time rather than at launch —
so the moment the runner asks for a file, the kernel looks at what's running
under that PID and finds the unsigned gitlab-runner binary. The grant is
attached to a signature that is no longer there. You've rebuilt the original
bug, with extra steps.
So the launcher spawns a child and stays alive as its parent. That constraint
is written into the source, the README, and the design spec in roughly those
words, because nothing about the code makes it evident. exec here is a
correct-looking simplification that quietly removes the reason the program
exists, and the next person to read it — including me, in two years — deserves
to be told before they make it.
Staying resident brings its own obligations, all of them boring and all of them necessary:
| Guarantee | Why it matters |
|---|---|
| Environment and stdio inherited unmodified | Every CI job inherits the LaunchAgent's PATH; rewriting it breaks builds fleet-wide in a way that looks nothing like a launcher bug |
| Arguments passed through verbatim | The LaunchAgent invokes the launcher with the runner's arguments |
SIGTERM/SIGINT/SIGQUIT/SIGHUP forwarded to the child |
launchctl kickstart -k and gitlab-runner stop would otherwise orphan the runner on every restart — which ansible performs on every fleet run |
Child exit status propagated (128 + signal when signalled) |
launchd's KeepAlive and job failure reporting read it |
| Exit 127 when the runner can't be found or spawned | Distinguishable from anything the runner itself returns |
The signal handling has a trap in it worth calling out. You install the
forwarding handlers after spawning the child, not before. Setting a signal to
SIG_IGN in the parent first means the child inherits the ignored disposition
— and goes deaf to exactly the signals you were planning to forward to it.
Validating on real hardware
The unit tests cover argument passthrough, environment inheritance, signal forwarding, and exit-status propagation. They deliberately do not test the TCC grant.
They can't. A unit test runs under xcodebuild, which runs under whatever
you launched it from, which — on a developer's machine — very likely has Full
Disk Access already. A test that appeared to verify TCC inheritance would be
verifying the wrong attribution path and passing for the wrong reason. That is
worse than no test, because you'd believe it.
So the premise gets validated on real hardware instead, by tools/bench.sh: it
builds and signs the launcher, installs it as a LaunchAgent, points it at a
stub child that tries to read TCC-protected paths, and reports what the child
could actually see. It must run under launchd for the same reason the unit test
can't work — run the launcher from a terminal and the terminal becomes the
responsible process, and the test passes while proving nothing at all.
Results from the first real run, on macOS 26.6:
| Test | Change | Result |
|---|---|---|
| Attribution | FDA granted to the launcher | a shell-script child read TCC.db |
| Runner upgrade | child binary's sha256 changed | ALLOWED, silently |
| Launcher upgrade | bundle deleted, reinstalled, new cdhash, same identifier |
ALLOWED, silently |
| Negative control | same path, different signing identifier | grant invalidated; next run hung on a prompt |
Three things came out of that run that I didn't know going in.
One grant per node is enough. With Full Disk Access granted, the per-folder Desktop and Documents services were never consulted at all — no rows written, no prompts. FDA supersedes them.
A wrong signing identifier is worse than no grant. I expected the negative
control to simply fail to inherit. Instead it invalidated the existing entry
into an explicit deny — which never prompts, and which tccutil cannot
re-approve. Recovery means a human removing the entry in System Settings on
that machine. This is why the bundle identifier is pinned in project.yml with
a comment rather than derived from the target name.
A missing grant hangs; it does not fail. The negative control blocked indefinitely waiting for a dialog nobody would ever see. That is precisely the original production symptom, and it's why the bench harness carefully distinguishes "still running" from "did not run" instead of reporting both as "no result."
What the pipeline enforces
Upgrading the launcher itself is free — TCC keys on the identifier, not the version, so a new build lands on a node and the grant just carries. That freedom is the entire point of the exercise, and it's also the thing a careless release can spend. Hence a few rules, each of which is a bug I'd rather have once than twice:
- The build is universal, and the package job fails if either slice is missing. The fleet is mixed — a couple of nodes are still Intel — and an arm64-only build installs perfectly cleanly on an Intel box and then fails to launch, taking the runner down with it.
- Publishing is gated on tag shape, not on a tag existing. Bare semver
(
0.1.0) carries no namespace the way arelease/prefix did, so without a^\d+\.\d+\.\d+$gate, any scratch tag would ship a release. - Every merge to the default branch archives, notarizes, and staples even though nothing is published. Signing and notarization break for reasons that have nothing to do with your code — expired certificates, changed Apple requirements — and I'd rather find out on a Tuesday merge than on a release.
- Local builds sign ad-hoc, with placeholder
0.0.0versions. A fresh checkout builds and tests without the CI keychain, and a local build never claims to be a release.
Conclusion
The code is a few hundred lines of Swift, most of it comments. The interesting work was entirely in understanding the platform: that TCC evaluates identity at request time, that it walks the process tree to assign responsibility, and that those two behaviors — neither of which is a bug — combine to make an unsigned, frequently-updated binary permanently un-grantable.
Once you see that, the design is forced. You cannot make gitlab-runner
stable, so you put something stable in front of it and let inheritance do the
work. The launcher isn't clever; it's a place to hang an identity.
Which is more or less the general lesson. When a system keeps asking you the same question, sometimes the answer isn't to answer it faster or automate the clicking. It's to find the thing the system is actually asking about, and give it something worth remembering.