| name | monitor-code-signing |
| description | How Leap Monitor.app is code-signed with the per-user self-signed Leap Self-Signed certificate so macOS Accessibility and Notification grants survive every update, plus the related build, TCC, and Apple Silicon architecture troubleshooting. Use this when working on the Makefile signing steps, py2app builds, TCC/Accessibility persistence, or architecture-mismatch and signing failures. |
| user-invocable | false |
Monitor Code Signing (the "Leap Self-Signed" cert)
Leap Monitor.app is signed with a per-user self-signed code-signing certificate (CN = Leap Self-Signed) kept in a dedicated Leap keychain (~/Library/Keychains/leap-codesign.keychain-db) — not the login keychain. This is the mechanism that lets macOS Accessibility and Notification grants survive every make update / leap --update — without it, every rebuild changed the bundle's cdhash, which invalidated TCC and forced the user to re-grant Accessibility after every update. The dedicated keychain is the load-bearing design choice — see "Why a dedicated keychain" below.
Why it works. TCC keys grants on the bundle's designated requirement, not its cdhash. With ad-hoc signing (py2app's default), the designated requirement is cdhash H"..." — changes on every rebuild. With cert-based signing, it's identifier "com.leap.monitor" and certificate leaf = H"<cert-sha1>" — stable across rebuilds because the cert sits unchanged in the keychain.
One-time generation + self-heal (Makefile:.gen-codesign-cert → src/scripts/leap-codesign-setup.sh). Runs as a prereq of every monitor build (install-monitor and the rebuild in .update-after-pull). The script is idempotent and self-healing: if the cert already lives in the dedicated keychain it just re-unlocks that keychain and re-asserts its search-list entry, then exits; only when the cert is absent does it generate (openssl genrsa → openssl req → openssl pkcs12 [-legacy] → a fresh security create-keychain → security import -T /usr/bin/codesign → security set-key-partition-list authorized by the keychain password it just generated — see "Why a dedicated keychain"), then tccutil reset Accessibility com.leap.monitor (clear stale entries) and print the one-time re-grant notice. The keychain's password is random and persisted 0600 at ~/Library/Keychains/.leap-codesign.pass so later builds unlock it without a prompt — it gates only a per-machine code-signing key whose sole power is signing Leap Self-Signed for local TCC (nothing an attacker with file access doesn't already have), which is why a local password file is acceptable (standard CI-signing-keychain practice). security set-keychain-settings removes the auto-lock timeout so it stays unlocked through a multi-minute build.
Every build (Makefile:BUILD_MONITOR_APP). Right after setup.py py2app, the macro unlocks the dedicated keychain (with the stored password), looks up the cert's SHA-1 in it (security find-certificate -c "Leap Self-Signed" -Z <keychain>), and re-signs by that SHA-1: codesign --force --deep --keychain <keychain> --sign <sha1> --identifier com.leap.monitor. Why by SHA-1, not by name: a user migrating from the old scheme still has a same-named Leap Self-Signed cert in their login keychain; with both on the search list, --sign "Leap Self-Signed" fails with "ambiguous" — and --keychain does not break the name tie (verified empirically; the man page implies it should, but it doesn't). The unique SHA-1 is unambiguous, so we never have to delete the old login cert (deleting it could itself prompt) — it just goes inert. The --deep is required: the bundle ships ~230 nested Mach-O objects (Python.framework, the MacOS/python interpreter, many .dylib/.so), and codesign refuses to seal the bundle when any nested object is unsigned (code object is not signed at all / In subcomponent: .../python). Whether the nested binaries arrive ad-hoc-signed depends on which interpreter py2app copied in — Apple-Silicon framework pythons are ad-hoc-signed, but a python.org / custom-built interpreter can be fully unsigned, which made the un---deep sign fail on those machines. --deep re-signs every nested object with the cert in one pass; --identifier com.leap.monitor is stamped on every signed object, but TCC only matches the top bundle's designated requirement, which is derived from the bundle identifier + signing cert (both unchanged) and so stays byte-identical — Accessibility grants survive. The macro then runs codesign --verify and, on failure, prints a warning pointing the user to re-run leap-codesign-setup.sh. Do not strip _CodeSignature from the installed bundle — that's the cert signature. Earlier versions of this Makefile stripped it; that's been removed.
Why a dedicated keychain + set-key-partition-list (the whole point of this scheme). Any keychain in ~/Library/Keychains enforces a per-key partition list that security import -T does not populate, so codesign pops a "codesign wants to access key" dialog when it uses the key — and since we sign --deep (~230 nested objects), that was ~230 prompts unless the user clicked "Always Allow" (the confusing bug this scheme replaced). Verified on macOS 26: a custom keychain in ~/Library/Keychains prompts exactly like the login keychain — being a separate keychain is not enough on its own (an earlier version of this skill claimed it was; that was a false positive from testing with keychains under /var, which happen not to be partition-gated). The documented fix is security set-key-partition-list, which adds codesign to the key's partition list so it signs silently (verified end-to-end: --deep on the real 3650-object bundle completes in ~3s, zero prompts, valid signature). The catch: that call must be authorized with the keychain's password, and on the login keychain that's the user's login password (phishing-shaped on managed Macs, and it nags everyone) — which is precisely why we use a dedicated keychain whose password we generate. The setup script runs security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k <our-password> <keychain> right after import, so there is no user/login password and no "Always Allow". (A keychain outside ~/Library/Keychains happens not to be partition-gated and signs silently even without this call, but that's undocumented macOS behavior we deliberately don't rely on.)
codesign only finds identities in keychains on the user's search list, so the setup script appends the dedicated keychain to it. That search-list edit is the one genuinely risky operation — security has no "append" primitive, so you must re-set the whole list, and a bad re-set could drop the login keychain and break the user's saved passwords. ensure_in_search_list therefore: skips if already present (idempotent); captures the current list with a robust parse (strips the leading whitespace + surrounding quotes list-keychains prints, but not spaces inside a path); guarantees the login keychain is in the set it writes; then verifies the end state and rolls back to the captured list if the login keychain didn't survive. All membership checks compare resolved paths via _resolve (cd "$(dirname)" && pwd -P), because macOS canonicalizes stored keychain paths (/var→/private/var, symlinked/network/FileVault homes) and a textual match would silently miss. The alternatives we rejected: running set-key-partition-list against the login keychain (needs the user's login password — see above), and guiding users to click "Always Allow" (confused them).
Migration. Two cases, both costing exactly one re-grant. (a) Very old ad-hoc-signed installs: the TCC entry is cdhash-based and won't match — tccutil reset runs, the bundle is cert-signed, the user re-grants once. (b) Installs from the previous login-keychain cert scheme: the new cert lives in the dedicated keychain with a different SHA-1, so the designated requirement changes and the user re-grants Accessibility once via the in-app banner. The old login-keychain Leap Self-Signed cert is left untouched and inert — we always sign by the dedicated cert's SHA-1, so the login cert is never matched (and not deleting it avoids any deletion prompt). It can be removed by hand in Keychain Access but doesn't need to be. After the one re-grant, all later updates are silent. (Even users who briefly saw the short-lived "Always Allow" hint and clicked it get this one re-grant — unavoidable, since the cert moves keychains.)
Keychain wipe / new machine / uninstall. Cert + key live in ~/Library/Keychains/leap-codesign.keychain-db, password at ~/Library/Keychains/.leap-codesign.pass. If either is deleted (or on a clean install / new machine), the next build's setup script regenerates a fresh keychain + cert with a different SHA1 → designated requirement changes → one more one-time re-grant. Each machine gets its own cert; no cross-machine sharing (it'd require trusting whatever path moved the private key). uninstall-monitor runs leap-codesign-setup.sh --remove, which security delete-keychains the dedicated keychain (that also drops its search-list entry — verified) and removes the password file; the login keychain is never touched.
No more install-time permission prompts. Both install-monitor and .update-after-pull previously asked "Open Accessibility settings? (Y/n)" and ran a .prompt-notifications probe. Both are removed — opening the Settings pane via x-apple.systempreferences:... doesn't reliably pre-list the new app (user often has to click + and dig through /Applications), which is worse UX than the in-app banner flow that uses AXIsProcessTrustedWithOptions({AXTrustedCheckOptionPrompt: true}) to surface a native macOS dialog with the app pre-selected. The .prompt-notifications make target has been deleted entirely.
Gatekeeper vs TCC. spctl --assess will reject the cert-signed bundle (no Apple Developer ID anchor). That's expected and irrelevant for our use case — Gatekeeper rejection means "macOS warns on first launch from quarantine", but bundles installed via cp -R from local builds don't carry the quarantine xattr, so Gatekeeper never runs. TCC operates on a different axis and accepts the self-signed cert just fine.
Troubleshooting (signing / TCC / architecture)
⚠ Cert-based signing failed — bundle still has its py2app ad-hoc signature during build → the setup script ran but the build couldn't find/use the cert in the dedicated keychain — it was deleted, the keychain is locked with a lost password, the dedicated keychain fell off the search list, or the import silently failed last time. Check with security find-certificate -c "Leap Self-Signed" "$HOME/Library/Keychains/leap-codesign.keychain-db" and security list-keychains -d user (the dedicated keychain should be listed). Fix: re-run bash src/scripts/leap-codesign-setup.sh (or make install-monitor) — it regenerates / self-heals and tccutil resets so the user re-grants Accessibility once from the in-app banner.
Accessibility silently fails after update on a machine that should be cert-signed → Compare codesign -dr - "/Applications/Leap Monitor.app" to the TCC entry: sudo sqlite3 "/Library/Application Support/com.apple.TCC/TCC.db" "SELECT hex(csreq) FROM access WHERE client='com.leap.monitor' AND service='kTCCServiceAccessibility';". The bundle's designated requirement should be identifier "com.leap.monitor" and certificate leaf = H"<sha1>" and the TCC csreq should be its byte-identical encoding. Mismatch usually means the bundle was rebuilt with a different cert (e.g., keychain was wiped between installs). Fix: tccutil reset Accessibility com.leap.monitor and have the user re-grant once.
Install/update blocks on a security wants to use the "leap-codesign" keychain — enter the keychain password modal → The dedicated keychain's password (random, stored 0600 at ~/Library/Keychains/.leap-codesign.pass) no longer matches the keychain file — a leftover/half-written keychain from an aborted or older install. Normally unlock_keychain opens it silently from that file; on a mismatch, security unlock-keychain -p <stale> errors cleanly on recent macOS (verified macOS 26.4: exit 51, no GUI → the script's fast path falls through and regenerates), but on some older macOS versions a wrong -p escalates to that interactive modal and hangs the whole make install / leap --update on a prompt the user can't answer (the password is a random string, not their login/sudo password). This is now self-healed: every keychain unlock — both unlock_keychain (and the fast-path find-certificate/set-key-partition-list calls) in leap-codesign-setup.sh and the second unlock in the Makefile's BUILD_MONITOR_APP — is wrapped in _bounded (a perl -e 'alarm …; exec …' hard timeout, default 8s; perl is always present on macOS, falls back to a direct call if not). A correct password unlocks in milliseconds so the timeout never fires normally; a mismatch/hang is killed at 8s and treated as "couldn't unlock," so the script falls into the generation path (which security delete-keychains the stale keychain first — verified prompt-free even when locked — then regenerates a fresh matching cert). Net: leap --update self-heals hands-off. Manual unblock if a user is stuck on the modal mid-update on an old build: click Cancel, then security delete-keychain ~/Library/Keychains/leap-codesign.keychain-db 2>/dev/null; rm -f ~/Library/Keychains/.leap-codesign.pass (or bash src/scripts/leap-codesign-setup.sh --remove) and re-run the install.
Notifications/Accessibility silently dead on Apple Silicon (✗ Architecture mismatch at build time, or the Monitor never appears in System Settings → Notifications) → py2app freezes the bundle for the build interpreter's architecture, so an Intel (x86_64) Python on an Apple Silicon Mac produces an x86_64 app that runs under Rosetta — where AMFI rejects the self-signed binaries (Error -423 "adhoc signed or signed by an unknown certificate chain"), the process runs as <ID of InvalidCode>, and usernotificationsd refuses requestAuthorization (so the app never registers; TCC/Accessibility attribution fails the same way). Static codesign --verify can still pass — the rejection is at runtime (kernel: cs_invalid_page … tainted:1). Three layers handle it, all gated on sysctl -n hw.optional.arm64 == 1 (so Intel Macs are unaffected): (1) check-python refuses an x86_64 Python and falls through to brew install python@3.12 (arm64); (2) .env detects an existing Intel venv and does poetry env remove --all + poetry env use to rebuild it arm64; (3) BUILD_MONITOR_APP hard-stops a would-be x86_64 build with the ✗ Architecture mismatch message. Diagnose with file "/Applications/Leap Monitor.app/Contents/MacOS/Leap Monitor" (must say arm64) and cd <repo> && poetry run python -c 'import platform;print(platform.machine())'. Fix: cd <repo> && make install (self-heals — no make uninstall needed). Force an x86_64 build with LEAP_ALLOW_ROSETTA_BUILD=1.