Starting with macOS 15 (Sequoia) and especially macOS 26 (Tahoe) released in September 2025, two things are required for accessibility permissions to behave well:
- Code signing with a stable identity — so TCC (Transparency, Consent, Control) recognizes the binary across rebuilds.
- An
.appbundle wrapper — so the System Settings → Accessibility picker accepts the binary, and so TCC keys the entry by bundle identifier (com.jackielii.skhd) instead of by file path.
- Without a stable signature (Zig's default adhoc signing): every rebuild looks like a "different" binary to TCC, permissions reset, you keep seeing "ACCESSIBILITY PERMISSIONS REQUIRED".
- Without the
.appbundle (bare Mach-O): on macOS Tahoe the Accessibility+picker silently rejects the binary; the entry never appears in the list. TCC entries that do get created are path-based (client_type=1) and break onbrew upgradebecause the Cellar version path changes. - CVE-2025-43312: Unsigned services are now blocked from launching on Intel Macs.
Use a self-signed code signing certificate (skhd-cert) with a stable bundle identifier (com.jackielii.skhd), AND wrap the binary in an .app bundle. The combination produces TCC entries keyed by bundle ID that survive both rebuilds and Homebrew version upgrades.
zig build sign will try to create the cert automatically via openssl + security import. If that fails (e.g. on some keychain configurations), create it by hand in Keychain Access:
open "/Applications/Utilities/Keychain Access.app"Then:
- Menu: Keychain Access → Certificate Assistant → Create a Certificate
- Name:
skhd-cert - Identity Type: Self-Signed Root
- Certificate Type: Code Signing
- Click Create
# Build the bare binary (development iteration)
zig build
# Build skhd.app + sign both the inner Mach-O and the bundle
zig build sign-app
# Equivalent to:
zig build app # produces zig-out/skhd.app
./scripts/codesign.sh zig-out/skhd.app # signs both layers- Move or symlink the bundle into
/Applications(Tahoe's picker prefers paths there):ln -sfn "$(pwd)/zig-out/skhd.app" /Applications/skhd.app - Open: System Settings → Privacy & Security → Accessibility
- Enable the checkbox next to
skhd - Restart skhd
Permissions will now persist across rebuilds as long as you sign each new build with the same certificate.
zig build run does not run the bare binary at zig-out/bin/skhd — on Tahoe, an adhoc-signed bare binary cannot be granted accessibility. Instead it builds and signs a separate dev bundle so debug runs have their own TCC slot:
| Path | Bundle ID | Cert | |
|---|---|---|---|
Prod (sign-app) |
zig-out/skhd.app |
com.jackielii.skhd |
skhd-cert |
Dev (run) |
zig-out/skhd-dev.app |
com.jackielii.skhd.dev |
skhd-dev-cert |
The dev cert is auto-created on first zig build run. One-time setup: add zig-out/skhd-dev.app in System Settings → Privacy & Security → Accessibility and toggle it on. Permissions persist across rebuilds because every zig build run re-signs with the same skhd-dev-cert.
The two bundles never overlap, so debugging never disturbs the prod TCC entry that the Homebrew-installed daemon relies on. If you want to debug without the prod daemon also receiving keypresses, stop it first: skhd --stop-service.
To override the dev cert/bundle ID, set SKHD_CERT and SKHD_BUNDLE_ID before invoking scripts/codesign.sh directly.
codesign -dv --verbose=2 ./zig-out/skhd.appExpected output with proper signing:
Executable=/path/to/zig-out/skhd.app/Contents/MacOS/skhd
Identifier=com.jackielii.skhd
Format=app bundle with Mach-O thin (arm64)
Authority=skhd-cert
Signed Time=...
TeamIdentifier=not set
Sealed Resources version=2 ...
Key things to confirm:
Format=app bundle with Mach-O thin (arm64)— proves the bundle layer is signed, not just the inner binaryAuthority=skhd-cert(not "adhoc")Identifier=com.jackielii.skhd(stable bundle ID)
You can also verify TCC has bundle-ID-keyed entries (rather than path-keyed):
sudo sqlite3 "/Library/Application Support/com.apple.TCC/TCC.db" \
"SELECT service, client, client_type FROM access WHERE client='com.jackielii.skhd';"Look for rows with client_type=0 (bundle ID) — those are the entries that survive rebuilds and brew upgrade.
Code signing is optional for CI environments:
- Builds will succeed without signing
- GitHub Actions and other CI systems don't need certificates
- Local development requires signing for accessibility permissions to persist
For users installing via Homebrew:
- The formula will attempt to create a certificate and sign the binary automatically
- Users will be prompted to grant accessibility permissions once
- Permissions will persist across Homebrew updates
This is normal - click Always Allow to avoid repeated prompts.
If you cp a freshly-built binary on top of an existing path (e.g. into /opt/homebrew/Cellar/skhd-zig/<ver>/...), TCC may invalidate the previously-granted entry because the on-disk code signature stops matching the stored requirement (csreq). The entry stays in the table with auth_value=2 but the daemon reports "not granted".
Two ways to recover:
-
Use the .app bundle approach (recommended): bundle-ID-keyed TCC entries (
client_type=0) survive in-place replacements as long as the new binary keeps the sameIdentifier(com.jackielii.skhd) and signing certificate. This is whyzig build sign-appis the right local workflow. -
Drop and re-grant: if you have a stale path-keyed entry blocking re-add, delete it directly:
sudo sqlite3 "/Library/Application Support/com.apple.TCC/TCC.db" \ "DELETE FROM access WHERE client LIKE '%skhd%' AND client_type=1;"
Then restart skhd and grant fresh.
On macOS Tahoe the Accessibility picker silently rejects bare Mach-O binaries. You need the .app bundle. Build with zig build sign-app, symlink it into /Applications, and add /Applications/skhd.app (not the inner binary) in System Settings.
AXIsProcessTrusted() checks the responsible process, which for terminal-launched commands is the terminal, not skhd. The launchd-spawned daemon is the one whose permissions matter — check launchctl list | grep com.jackielii.skhd for a non-zero PID, and the log at ~/Library/Logs/skhd.log for Event tap created successfully.
- Verify the signature:
codesign -dv --verbose=2 ./zig-out/skhd.app - Check that
Authority=skhd-cert(not "adhoc") - Check that
Format=app bundle with Mach-O thin (...)— bundle layer is signed - Check that
Identifier=com.jackielii.skhdis present - Remove old accessibility entries (especially path-keyed ones) and re-add
- Ensure you're signing with the same certificate each time
- Verify certificate exists:
security find-identity -v -p codesigning - If not found, create it manually using Keychain Access (see step 1 above)
- The script will provide detailed instructions if the certificate is missing