Lab note · from WordEraser
AXIsProcessTrusted false after rebuild: ad-hoc signing and TCC
macOS stores an Accessibility grant together with the app's designated requirement (DR) and checks the running code against it. An ad-hoc-signed app's DR is cdhash H"…", the hash of that exact build, so any change to the executable, Info.plist or bundled resources gives code the grant no longer matches, and AXIsProcessTrusted() returns false. Sign development builds with a stable identity such as Apple Development, whose DR names the bundle identifier and certificate instead of a hash, then clear the old decision once with tccutil reset Accessibility <bundle id> and grant it again.
Symptoms
WordEraser is a menu bar app that turns Ctrl+W, Ctrl+U, Ctrl+K and Option+D into macOS delete commands with an active CGEventTap at .cgSessionEventTap. Without Accessibility it does nothing. Its build.sh signs with -, ad hoc, unless SIGN_ID names a real identity.
- Both copies on the development Mac, the installed one and the latest repository build, report
Signature=adhocandTeamIdentifier=not set, and their DRs are two different cdhashes. - After a rebuild the tap isn't created.
CGEvent.hsays that if a session tap "is not permitted to monitor these events when the tap is created, then the appropriate bits in the mask are cleared. If that results in an empty mask, then NULL is returned." WordEraser's menu bar icon becomes a warning triangle and its menu opens with "Accessibility access needed". - The system prompt doesn't return. WordEraser passes
kAXTrustedCheckOptionPromptas true only on first run. - System Settings can still list the app, even switched on. Apple doesn't document how the list shows a grant whose DR no longer matches, but the check is made against the stored DR, not the name in the list. A developer on Apple's forums described the loop: "whenever I change some code I have to grant it permissions again in the Accessibility section", deleting the old version from the list each time.
The project's own notes blamed the bundle path. The tests below show the path isn't part of an ad-hoc identity.
Why it happens
Apple's TN3127: Inside Code Signing: Requirements explains it with the microphone: "macOS solves this problem by recording your app's DR in its database of apps authorized to access the microphone. Each time your app tries to access the microphone, macOS checks that this version of the app satisfies the original DR." And: "Ad hoc signed code, called Sign to Run Locally by Xcode, has a DR but it's tied to that specific version of the code." For Accessibility, Quinn of Apple DTS wrote that "TCC uses code signatures to track such privileges", specifically the DR, and advised signing "with a stable signing identity, that is, not unsigned and not ad hoc signed".
A throwaway bundle, built with swiftc -O and signed with codesign --force --sign - on macOS 27.2, shows exactly what changes the hash:
$ codesign -d -r- A1.app
# designated => cdhash H"96381b3ff0df238b67611e12336d942c79bd7764"
- Same source, rebuilt: same cdhash. The output was byte-identical.
- One string literal changed: new cdhash.
- Only
CFBundleShortVersionStringchanged inInfo.plist, then re-signed: new cdhash. - Copied to another directory: same cdhash.
- Re-signed ad hoc with nothing changed: same cdhash.
So the trigger isn't every rebuild or every move. It's any change to what the signature covers, which in practice is every build you'd want to test. codesign --verify -R shows the result directly: the changed build fails the first build's DR with test-requirement: code failed to satisfy specified code requirement(s), and the copy satisfies it.
A stable identity gives a DR with no hash in it. Calculator's is identifier "com.apple.calculator" and anchor apple. An app on the same Mac signed with an Apple Development certificate (identifier and name redacted):
designated => identifier "<bundle id>" and anchor apple generic
and certificate leaf[subject.CN] = "Apple Development: <name>"
and certificate 1[field.1.2.840.113635.100.6.2.1] /* exists */
Any later build with the same identifier, signed with the same certificate, satisfies it.
The fix
1. Sign with a stable identity.
codesign --force --sign "Apple Development: <name> (<team id>)" WordEraser.app
# WordEraser's script: a real identity also adds --options runtime --timestamp
SIGN_ID="Apple Development: <name> (<team id>)" ./build.sh
Quinn, in another thread: "For day-to-day development, that should be your Apple Development signing identity." It doesn't need a paid membership: Apple's account overview says a Personal Team's "App IDs, devices, certificates, and provisioning profiles are managed directly in Xcode". Sign nested code with the same identity. build.sh signs Sparkle's helpers, then the framework, then the app, all with SIGN_ID. The project's handoff notes record why hardened runtime is off for ad-hoc builds: library validation rejected the ad-hoc-signed Sparkle framework with a "different Team IDs" error.
2. Clear the stale decision once.
tccutil reset Accessibility com.example.worderaser
man tccutil: "If a bundle identifier is specified, the service will be reset for that bundle only." The identifier has to resolve. For a throwaway bundle that LaunchServices hadn't registered, tccutil exited 64 with No such bundle identifier … (OSStatus error -10814.), which LSConstants.h defines as kLSApplicationNotFoundErr. Run it while the app is installed.
3. Prompt whenever the check fails, not only on first run.
let opts = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary
if !AXIsProcessTrustedWithOptions(opts) {
// show your own "Open Accessibility settings" path as well
}
AXUIElement.h: "Prompting occurs asynchronously and does not affect the return value."
Why it works: the next grant records a DR built from the identifier and certificate, and every later build signed the same way satisfies it, so the grant carries from build to build.
How WordEraser handles it
// Prompt on first run only; check quietly after that.
let opts = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: firstRun] as CFDictionary
_ = AXIsProcessTrustedWithOptions(opts)
// Every 2 seconds: create or re-arm the tap once trust exists.
func ensureRunning() {
if isActive { return }
if AXIsProcessTrusted() { start() }
}
The same timer swaps the menu bar icon, and the menu's warning item opens x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility. The tap callback re-enables the tap on tapDisabledByTimeout and tapDisabledByUserInput. What it doesn't do is tell a stale grant from no grant, or prompt again after first run, which is where step 3 comes in.
How to check you've fixed it
codesign -dv WordEraser.appshows a team identifier instead ofTeamIdentifier=not set, andcodesign -d -r- WordEraser.appshows identifier and certificate terms, notcdhash.- Keep the previous build, change some code, rebuild, and test the new build against the old DR:
DR=$(codesign -d -r- Old/WordEraser.app 2>&1 | sed -n 's/^#* *designated => //p')
codesign --verify -v -R "=$DR" WordEraser.app
explicit requirement satisfied means the grant carries over. test-requirement: code failed to satisfy specified code requirement(s) means another grant.
- Relaunch.
AXIsProcessTrusted()returns true and WordEraser's icon is the delete glyph, not the triangle.
Caveats
- Each identity change costs one more grant. TN3127: "if you sign a development build with your Apple Development code signing identity it gets a different DR than a distribution build signed with your Developer ID code signing identity."
- Accessibility isn't the only privilege involved. Asked about an active global event tap, Quinn answered "you don't need the Accessibility privilege to do that", pointing to Input Monitoring (
CGPreflightListenEventAccess,CGRequestListenEventAccess). He adds thatPostEventalso appears under Accessibility in System Settings but is a separate privilege, whichtccutil reset Accessibilitydoesn't reset. WordEraser checks onlyAXIsProcessTrusted()and posts Ctrl+K's keystrokes withCGEvent.post, sotccutil reset All <bundle id>is the broader reset. - Not tested against TCC itself: a moved bundle (the copy kept its cdhash and passed the old DR), and the switched-on row. Both need a manual grant in System Settings.
- First launch of an ad-hoc download: Apple's August 2024 note says "In macOS Sequoia, users will no longer be able to Control-click to override Gatekeeper". The route now is to try opening the app, then System Settings › Privacy & Security › Open Anyway, which Apple's guide says "is available for about an hour after you try to open the app".
- Versions: tests on macOS 27.2 with Xcode 27.0. WordEraser sets
LSMinimumSystemVersion13.0 and builds withswiftcfrom a shell script, with no Xcode project.
More lab notes
- CMIO camera extension: personal teams can't sign System ExtensionA Core Media I/O camera is a system extension. Apple doesn't offer the System Extension capability to free accounts, so a personal team can't sign it.
- CGWindowListCreateImage unavailable in macOS 15: use SCStreamCGWindowListCreateImage is deprecated in macOS 14 and a compile error from a macOS 15 target. Anchor still uses it; here is the SCStream replacement.
- Check a password against Have I Been Pwned without sending itSHA-1 the password, send only the first 5 hex characters to the Pwned Passwords range API, match the suffix locally, and add Add-Padding: true.
- Claude Code signed out by another app: refresh token rotationAnthropic rotates the refresh token on every refresh, so another app that refreshes Claude Code's login signs it out. Only Claude Code should refresh it.