macOS Code Signing for TCC Permission Persistence
macOS Code Signing for TCC Permission Persistence
Section titled “macOS Code Signing for TCC Permission Persistence”What this does
Section titled “What this does”stapler-squad signs its binary with a self-signed certificate (StaplerSquadDev) on every make install-service. The certificate anchors the Designated Requirement (DR), so macOS TCC grants (Full Disk Access, Apple Events) survive rebuilds without re-prompting.
Without a stable cert-anchored DR, every rebuild changes the binary’s cdhash and macOS treats it as a new app — requiring a fresh round of permission dialogs.
One-time setup
Section titled “One-time setup”Run once per developer machine:
# Install OpenSSL if needed (macOS ships LibreSSL, which won't work)brew install openssl
# Create and trust the self-signed certOPENSSL_BIN=$(brew --prefix openssl)/bin/openssl make setup-codesignIf openssl version shows OpenSSL (not LibreSSL), you can omit OPENSSL_BIN:
make setup-codesignThe script is idempotent — running it a second time prints “StaplerSquadDev cert already present.” and exits without creating a duplicate.
Troubleshooting: “MAC verification failed during PKCS12 import (wrong password?)”
Section titled “Troubleshooting: “MAC verification failed during PKCS12 import (wrong password?)””OpenSSL 3.x’s PKCS12 export produces a MAC that macOS’s security import cannot verify when the export password is empty — a known OpenSSL 3.x/macOS incompatibility. setup-codesign.sh works around this by generating a random throwaway password with openssl rand -base64 24 and using it for both the pkcs12 -export and security import steps (the password only protects the .p12 file in transit between those two commands and is discarded immediately after). If you still see this error, confirm $OPENSSL_BIN (or openssl on PATH) is real OpenSSL and not a stale/patched build — openssl version should print OpenSSL 3.x, not LibreSSL.
Verifying the setup
Section titled “Verifying the setup”After make install-service, run:
make verify-codesignFocus Window uses Launch Services (lsappinfo + open), not Apple Events, so macOS shows no per-app “would like to access…” dialog and no Automation entitlement is needed. Signing still keeps other TCC grants (e.g. file access) stable across rebuilds; without make setup-codesign those may re-prompt after each rebuild.
Pass criteria:
Authority=StaplerSquadDevin the Code Signature sectionIdentifier=com.stapler-squadin the Code Signature section- DR section contains
anchor H"<cert-sha1>"oranchor trusted(cert-anchored, notcdhash) CFBundleIdentifier => "com.stapler-squad"in the Embedded Info.plist section
Exporting the cert for backup
Section titled “Exporting the cert for backup”If you need to move the cert to another machine or back it up:
- Open Keychain Access (
/Applications/Utilities/Keychain Access.app) - Select the login keychain
- Find StaplerSquadDev in the My Certificates category
- Right-click → Export “StaplerSquadDev”
- Save as
.p12, set a password when prompted
Re-importing on a new machine
Section titled “Re-importing on a new machine”security import StaplerSquadDev.p12 \ -k ~/Library/Keychains/login.keychain-db \ -T /usr/bin/codesignThen run make setup-codesign (it will detect the cert exists and skip creation, but the set-key-partition-list step ensures codesign can access the key without prompting).
Actually for a clean import, just run the full setup again — the script checks for the cert and exits early if found. You may need to delete the old cert entry first in Keychain Access if re-importing on the same machine.
Diagnosing TCC issues
Section titled “Diagnosing TCC issues”Check current grants
Section titled “Check current grants”# List Apple Events grants for stapler-squadsqlite3 ~/Library/Application\ Support/com.apple.TCC/TCC.db \ "SELECT client, auth_value, auth_reason FROM access WHERE client = 'com.stapler-squad';"Note: reading TCC.db directly may require Full Disk Access for your terminal.
Reset grants (development only)
Section titled “Reset grants (development only)”make tcc-resetThis resets all TCC grants for com.stapler-squad. Use during development to reproduce the first-launch permission prompt experience. Requires sudo.
Re-grant after reset
Section titled “Re-grant after reset”- Run
make install-service - Open System Settings → Privacy & Security → Full Disk Access
- Enable the toggle for
stapler-squad - Re-run your workflow; Focus Window no longer needs an Apple Events grant
How it works
Section titled “How it works”The make install-service flow on macOS:
go buildwithCGO_LDFLAGS="-sectcreate __TEXT __info_plist macos/Info.plist"embeds the plist into the binary’s__TEXT/__info_plistMach-O sectionotoolassertion verifies the plist was actually embedded (catches silentCGO_ENABLED=0failures)codesign --sign "StaplerSquadDev" --entitlements entitlements.plistsigns the binaryinstall-service.shstops, installs, and restarts the LaunchAgent
The CFBundleIdentifier = com.stapler-squad in the embedded plist causes TCC to track the app by bundle ID (client_type=0) rather than path+hash. The cert-anchored DR means the TCC row’s csreq field matches every rebuild as long as the same cert is used.
Why Info.plist lives in macos/ and not the repo root: if the source Info.plist file sits in the same directory as the built binary, codesign auto-detects a bundle layout and seals the entire directory as bundle resources (every file, recursively — this repo has 80k+ tracked/untracked files). codesign --verify then fails with a sealed resource is missing or invalid the moment anything in the repo changes (a git worktree’s .git/index, an untracked .claude/settings.local.json, etc.), which silently aborts install-service.sh’s pre-flight signature check before it ever restarts the LaunchAgent. Keeping Info.plist out of the binary’s directory keeps codesign treating it as a plain Mach-O executable (Sealed Resources=none).
Bazel-built binary (owner device check; not done yet)
Section titled “Bazel-built binary (owner device check; not done yet)”Signing stays outside Bazel (ADR-005, ADR-006): Bazel produces the unsigned binary and the existing flow signs a copy.
Nothing in this section has been run on a Mac. The Bazel side (--config=macos, the -sectcreate Info.plist link
in tools/bzl/otel_weave_hermetic.sh, //macos:infoplist_test, .github/workflows/bazel-macos.yml) was written
without one and is unverified, including whether the woven (otelc) darwin build links at all; cross-compiling darwin
from Linux is not supported (cgo is off there, so no Info.plist).
Designated requirement of the signed copy
Section titled “Designated requirement of the signed copy”BUILD_SYSTEM=bazel make install-service runs tools/bzl/verifybin -codesign, which aborts before the old service is
stopped unless codesign -d -r- on the signed copy prints exactly the requirement recorded here. Until the owner
records it the check fails closed (it names the missing marker). Record it once, from the make-built signed binary:
make install-service # BUILD_SYSTEM=make, once, so the binary is signed by StaplerSquadDevcodesign -d -r- ./stapler-squad # copy the "designated =>" line into the block belowNot recorded yet (owner: replace this line with the designated => line).
Checklist (owner, per architecture: arm64 and amd64)
Section titled “Checklist (owner, per architecture: arm64 and amd64)”- Build with Bazel and copy to a stable path (not
bazel-bin):bazel build --config=release //:stapler-squad && cp bazel-bin/stapler-squad /path/stable/stapler-squad-bazel(add--config=darwin_amd64for Intel). - Sign the copy:
make _codesign-binary BIN=/path/stable/stapler-squad-bazel, thencodesign --verify --strict /path/stable/stapler-squad-bazelmust exit 0. - Compare requirements, and read the TCC guidance it prints:
bazel run //tools/bzl/codesigndiff -- -make-bin ./stapler-squad -bazel-bin /path/stable/stapler-squad-bazel(exit 0 andIDENTICAL). Also runscripts/verify-bazel-codesign.sh /path/stable/stapler-squad-bazel ./stapler-squad: it checks the signature, theStaplerSquadDevauthority and a certificate-anchored (notcdhash) requirement, and that the requirement equals the make-built binary’s. It exits 2 with a message anywhere but macOS. - With the make-built binary’s TCC grants in place, replace it with the Bazel-built signed binary and relaunch. PASS
only if macOS shows no new permission prompt. Do not run
make tcc-reset. - Record the result below and in the PR.
| Architecture | Date | Result (no new prompt?) | Notes |
|---|---|---|---|
| arm64 | not run | ||
| amd64 | not run |
Owner sign-off (OWNER-RUN, unverified by agents)
Section titled “Owner sign-off (OWNER-RUN, unverified by agents)”Agents cannot run any of this: it needs a Mac, the login keychain with StaplerSquadDev, and live TCC state. The
script’s non-macOS exit and its pass/fail logic against a stub codesign are tested in
scripts/verify-bazel-codesign_test.sh; the real codesign output format is not.
- arm64: step 1-3 of the checklist,
scripts/verify-bazel-codesign.shexits 0 - arm64: step 4, no new permission prompt after swapping in the signed Bazel binary
- amd64 (
--config=darwin_amd64): the same two items - The
designated =>block above is recorded, soverifybin -codesignno longer fails closed - Results entered in the table above and in the PR