Skip to content
All lab notes

Lab note · from WordEraser

AXIsProcessTrusted false after rebuild: ad-hoc signing and TCC

By Mark Santos · · 5 min read

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=adhoc and TeamIdentifier=not set, and their DRs are two different cdhashes.
  • After a rebuild the tap isn't created. CGEvent.h says 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 kAXTrustedCheckOptionPrompt as 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 CFBundleShortVersionString changed in Info.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.app shows a team identifier instead of TeamIdentifier=not set, and codesign -d -r- WordEraser.app shows identifier and certificate terms, not cdhash.
  • 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 that PostEvent also appears under Accessibility in System Settings but is a separate privilege, which tccutil reset Accessibility doesn't reset. WordEraser checks only AXIsProcessTrusted() and posts Ctrl+K's keystrokes with CGEvent.post, so tccutil 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 LSMinimumSystemVersion 13.0 and builds with swiftc from a shell script, with no Xcode project.

More lab notes