I’m building a Stream Deck plugin called ClaudeDeck — Stream Deck is Elgato’s little USB grid of programmable keys with LCD displays under each one. The plugin talks to a background daemon (a long-running process that starts at login and waits for events), and that daemon needs to call System Events via AppleScript to switch Ghostty tabs (Ghostty is my terminal emulator) whenever I press a Stream Deck key. macOS gates that capability — automating other apps — through System Settings → Privacy & Security → Accessibility, the pane you’ve probably toggled for tools like Rectangle or BetterTouchTool. So on first install I added the daemon, toggled it on, and got back to work.
It worked. Once.
The next time I ran ./install.sh, the grant was effectively gone. The entry was still in the Accessibility list — still toggled on — but the daemon hit errAEEventNotPermitted the first time it tried to send an event. Toggle off and on? No change. Remove and re-add? That works. For exactly one more install.
The symptom #
The daemon’s startup preflight fires a no-op AppleScript event and logs the result. After a rebuild:
accessibility self-test: DENIED — System Events got an error:
osascript is not allowed assistive access. (-1719)Meanwhile, the Accessibility pane showed the daemon present and enabled. That contradiction is what sent me down the rabbit hole.
What I thought TCC was #
My mental model — accidentally inherited from years of consumer-app installs — was that the Accessibility grant is keyed by app path, or maybe bundle identifier. Like a checkbox: “is ~/.local/bin/ClaudeDeckDaemon.app allowed? yes/no”. The path doesn’t change between rebuilds. The bundle ID is hardcoded. So the grant should stick.
Wrong. TCC — the Transparency, Consent, and Control subsystem, the permission database behind every macOS “allow this app to access your microphone/camera/contacts” dialog — doesn’t store “is this app allowed”. It stores “is this code allowed”, where “this code” is identified by something called a designated requirement (DR).
What TCC actually stores #
When you grant Accessibility, TCC writes a row in ~/Library/Application Support/com.apple.TCC/TCC.db (a SQLite database — yes, you can open it in sqlite3 to look around, though System Integrity Protection blocks writes) roughly like this:
service = kTCCServiceAccessibility
client = com.nickboy.claudedeck.daemon -- bundle identifier
client_type = 0 -- 0 = bundle ID, 1 = absolute path
allowed = 1
csreq = <BLOB> -- the actual gateThe csreq blob (short for “code signing requirement”, stored as a binary blob inside the SQLite row) is a serialized code requirement — Apple’s little DSL for expressing “this binary has to be signed by this party, with this identifier, anchored to this certificate.” You can dump one with codesign -d -r- (the -d is “display info”; -r- means “print the requirements to stdout”):
designated => identifier "com.nickboy.claudedeck.daemon"
and anchor apple generic
and certificate leaf = H"a1b2c3..."When the AppleScript event fires, TCC doesn’t just look up “is this bundle ID allowed”. It does a stricter check: it computes the current code identity of the calling process and asks “does it satisfy the csreq we stored at grant time?”
If yes: allow. If no: silent deny, return -1719.
So the grant doesn’t survive arbitrary code changes. It survives only changes the stored csreq will still accept. That’s the whole game.
How ad-hoc codesigning fits in #
My install script signs the daemon with codesign --sign -. The - means ad-hoc: no identity, no certificate, just a self-contained hash of the binary’s contents. This is what every “I’m not paying $99/year for an Apple Developer account” project does to make macOS happy enough to let the binary run at all. Dump the DR of an ad-hoc binary and you get:
designated => cdhash H"d4e5f6..."That’s the trap. The DR is pinned to the binary’s cdhash — Apple’s term for “code directory hash”, a SHA-256 derived from the code segments and signature blob, used as the identity for the signed bundle. Change the binary by a single byte, and the cdhash changes. Stored csreq no longer matches.
Here’s the kicker. bun build --compile (the Bun JavaScript runtime’s “bundle everything into one self-contained executable” command) is not deterministic across rebuilds (oven-sh/bun#29120). Even with identical source, the embedded JS bundle has slightly different layout — timestamp metadata, content ordering, whatever Bun’s compiler decides on a given run — and the final Mach-O (macOS’s executable file format, like ELF on Linux or PE on Windows) ends up with a different cdhash.
New cdhash → new DR → stored csreq rejects the new code → silent deny on every AppleScript call → -1719.
The TCC.db row is still there. The Accessibility list still shows the app. But the gate is closed.
Why remove-and-re-add works (for one install) #
Delete the entry in System Settings and add it back, and TCC computes a fresh csreq from the current binary’s signature and stores that. The new DR is pinned to the new cdhash. Next AppleScript call passes. Until the next ./install.sh recompiles the daemon — which is, in my case, several times an hour.
This is why my install loop felt like Sisyphus. Every iteration: re-grant.
A stable bundle identifier doesn’t help #
I tried the obvious workaround first. Wrap the daemon in a proper .app bundle (the directory tree macOS treats as a single application, with Contents/MacOS/ for the binary and Contents/Info.plist for the metadata) with a stable CFBundleIdentifier (the reverse-DNS string like com.apple.Safari that uniquely names the bundle):
~/.local/bin/ClaudeDeckDaemon.app/
└── Contents/
├── Info.plist # CFBundleIdentifier = com.nickboy.claudedeck.daemon
└── MacOS/
└── claudedeck-daemonThat does fix one half of the problem. launchd (macOS’s equivalent of systemd — the supervisor that brings up daemons at boot/login) now targets a stable path, and macOS knows the bundle by its identifier. But it does not fix TCC, because the stored DR for an ad-hoc-signed binary is still keyed on cdhash. The bundle ID never enters the DR for ad-hoc binaries — Apple’s code-requirement DSL only includes identifier clauses when there’s a signing identity to bind them to.
Verified by inspection:
$ codesign -d -r- ~/.local/bin/ClaudeDeckDaemon.app
Executable=.../Contents/MacOS/claudedeck-daemon
designated => cdhash H"d4e5f6..."Still cdhash-pinned. Still resets on every rebuild.
The fix: a self-signed Keychain cert #
The way out is to sign with an identity — even a self-signed one. (Keychain Access is the GUI app that manages macOS’s certificate and password stores, at /Applications/Utilities/Keychain Access.app.) Sign with a cert, and the DR shifts to:
designated => identifier "com.nickboy.claudedeck.daemon"
and certificate leaf = H"<sha1-of-cert>"Now the DR is pinned to the cert’s leaf hash, not the binary’s cdhash. The cert’s leaf hash is stable across every binary signed with it. Sign with the same cert on every rebuild, and the DR stays the same, and TCC’s stored csreq accepts the rebuild without complaint.
This is the same trick yabai, skhd, and AeroSpace users (popular macOS window-management and hotkey daemons) have been doing for years. I just didn’t connect the dots until I dumped the DR and stared at it.
Creating the cert #
Three minutes in Keychain Access:
- Open Keychain Access.
- Keychain Access → Certificate Assistant → Create a Certificate…
- Name:
ClaudeDeck Code Signing - Identity Type:
Self Signed Root - Certificate Type: Code Signing (this is the critical dropdown)
- Override Defaults: leave unchecked.
The cert lives in your login keychain. No CA, no Apple Developer ID, no $99/year. It’s only trusted on your machine — which is fine. TCC doesn’t care whether the cert is publicly trusted; it cares whether the DR has a stable anchor. The cert provides one.
Using the cert #
codesign picks the cert by name:
codesign --sign "ClaudeDeck Code Signing" --force --deep \
--identifier com.nickboy.claudedeck.daemon \
~/.local/bin/ClaudeDeckDaemon.appVerify:
$ codesign -d -r- ~/.local/bin/ClaudeDeckDaemon.app
designated => identifier "com.nickboy.claudedeck.daemon"
and certificate leaf = H"a8b9c0..."Cdhash gone. Cert hash stays. Grant survives.
The probe-for-cert gotcha #
One more booby trap. To detect whether the user has the cert installed, my first instinct was:
security find-identity -v -p codesigningThe -v filters to valid identities. But a self-signed root reports CSSMERR_TP_NOT_TRUSTED — it’s not chained to a system trust root. Fine for codesigning purposes (the cert can still sign; codesign only checks trust when verifying someone else’s signature), but -v hides it. So my probe missed the cert the user had just created. Embarrassing.
The fix is to drop -v:
security find-identity -p codesigning | grep "ClaudeDeck Code Signing"This catches the untrusted-but-usable self-signed cert. codesign itself doesn’t care about the trust state — it only needs a private key.
What the installer surfaces #
After all this, the install script prints one of two lines:
signed-with: ClaudeDeck Code Signing (TCC grant survives rebuilds)or:
signed-with: ad-hoc (Accessibility will reset on rebuild)So a future-me who wonders why they’re re-granting Accessibility for the third time today has a one-line diagnosis: the cert isn’t being found, or wasn’t created.
Lessons #
- TCC stores code requirements, not app identities. “This app is allowed” hides a sharper truth — “this specific code identity is allowed”.
- Ad-hoc codesigning pins the designated requirement to cdhash. Cdhash changes on every rebuild. Ad-hoc plus a tight iteration loop equals Accessibility groundhog day.
- A self-signed Keychain cert is enough to break the loop. No Apple Developer ID required. The DR just needs a stable anchor; the cert’s leaf hash provides one.
security find-identity -v -p codesigningfilters too aggressively. Drop-vif you need to detect self-signed roots.codesigndoesn’t care about trust state when signing.- When TCC’s behavior surprises you, dump the designated requirement.
codesign -d -r-shows exactly what TCC is matching against. Most of my “TCC is broken” theories evaporated when I looked at the actual DR.
The full installer integration lives in docs/2026-05-10-codesign-tcc-persistence.md in the ClaudeDeck repo.
References #
codesign(1)man page — the--sign,-r, and-dflags.tccutil(1)man page — for resetting TCC entries when debugging (tccutil reset Accessibility <bundle-id>).- Apple, “Code Signing Requirement Language” — https://developer.apple.com/library/archive/documentation/Security/Conceptual/CodeSigningGuide/RequirementLang/RequirementLang.html
- Howard Oakley, “A brief history of code signing on Macs” (2025-04-26) — https://eclecticlight.co/2025/04/26/a-brief-history-of-code-signing-on-macs/
- Apple Developer Forums #730043, “How to handle TCC permissions” — https://developer.apple.com/forums/thread/730043
- yabai wiki, “Codesigning yabai” — https://github.com/koekeishiya/yabai/wiki/Installing-yabai-(latest-release)#codesigning-yabai (same self-signed-cert pattern, predates this post by years)
- Bun’s non-deterministic
--compileoutput — oven-sh/bun#29120 - ClaudeDeck installer cert detection —
cli/src/install.ts(findSigningCert,defaultCodesigner) - ClaudeDeck longer-form walkthrough —
docs/2026-05-10-codesign-tcc-persistence.md