A Signed Parent for gitlab-runner


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 a release/ 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.0 versions. 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.