Extend signing identity migration to handle ad-hoc builds - #124
Conversation
TCC pins an Accessibility grant to the designated requirement of the signature that took it. Ad-hoc signatures have no team to pin, so codesign's default requirement pins the cdhash — and the per-PR dev build (`CODE_SIGN_IDENTITY="-"`) gets a fresh one every build. A reviewer who installs a second dev build, or drops one over a release install, is therefore a different app to `tccd`: the Blurt row stays in System Settings switched on while `AXIsProcessTrusted()` keeps returning false, and the wizard's Accessibility step can never be satisfied. Toggling the row doesn't help — it belongs to the previous build's signature. The launch migration that already handles a signing-team change was the right place to catch this, but it explicitly no-oped on ad-hoc builds (Team ID nil), which is exactly the case that strands the reviewer. Generalize it from "team" to "identity": `SigningIdentity.current()` returns the Team ID when there is one and `cdhash:<hex>` otherwise, and the migration resets the orphaned grant on any change while untrusted. The persisted marker keeps its frozen key and its bare-Team-ID shape, so installed users see no change and no extra reset. The UI-test build opts out (`#if UITEST_HOOKS`): uitest.sh and check.sh sign ad-hoc under the shipping bundle id, so honouring a cdhash there would make every local test run wipe the developer's real Blurt grant. Also spell out in the PR dev-build comment that the grant is re-taken once per build, with the manual `tccutil reset` for anyone coming from a build that predates this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018yY5zrgX28ap3vizkFZu6e
Dev buildDownload Blurt.app — built from Installing itcd ~/Downloads
unzip -o blurt-dev-build-pr-124.zip # GitHub wraps every artifact in a zip
unzip -o Blurt-dev-f29c60a.zip
find Blurt.app -exec xattr -c {} + # clear quarantine: xattr lost -r in macOS 12.3
rm -rf /Applications/Blurt.app && cp -R Blurt.app /Applications/
open -a BlurtIt is ad-hoc signed and not notarized: Gatekeeper refuses to open it until Expect that re-grant once per dev build, including a second build of this tccutil reset Accessibility dev.alex.blurt |
`#expect(hex.allSatisfy(\.isHexDigit))` rewrites to
`__checkFunctionCall(hex.self, calling: { $0.allSatisfy($1) }, …)`, and the
rewrite drops `allSatisfy`'s `rethrows`-ness — the expansion then wants a
`try` the call has no use for and the test target fails to build. Bind the
result first and assert on that.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018yY5zrgX28ap3vizkFZu6e
`discouraged_optional_collection` is an opt-in swiftlint rule this repo enables, and check.sh runs swiftlint --strict, so the new helper's `[String: Any]?` return failed the build. Nothing needs to tell "no signing information" apart from "no such keys" — both leave every caller at the same answer — so return an empty dictionary and drop the optional. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018yY5zrgX28ap3vizkFZu6e
Periphery scans the app's Debug scheme, where UITEST_HOOKS is on — so wrapping the `SigningIdentity.current()` call in `#if UITEST_HOOKS` compiled the only app-side reference out of the build periphery sees, and it reported `current()` and `cdhashPrefix` as dead engine code (check.sh runs periphery --strict). Express the policy as an argument instead: `current(includingAdHoc:)` is called unconditionally, and the app decides what to pass with a `#if`-selected constant. `currentTeamIdentifier()` goes away — asking for `includingAdHoc: false` is the same question, and leaving a public function only the tests reach would trip the same scan. The carve-out is now testable rather than an invisible compile branch, so the suite covers "an excluded ad-hoc build reports no identity at all". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018yY5zrgX28ap3vizkFZu6e
`xattr -dr com.apple.quarantine Blurt.app` fails with "option -r not
recognized" on any current macOS: `-r` belonged to the Python xattr that
Apple dropped in 12.3, and the C replacement never had it. Reviewers hit
this on the first line of the install block that matters.
`find … -exec xattr -c {} +` does what `-dr` was there for, on every
supported version, and clears the whole bundle rather than just its root
— which matters when the zip was expanded by Finder, since Archive
Utility propagates quarantine to the extracted contents while
command-line unzip does not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018yY5zrgX28ap3vizkFZu6e
#118 landed `DefaultsKey` while this branch was in flight, and both its doc comment and `DefaultsKeyTests` name the TCC marker as `lastSigningTeamDefaultsKey` — the symbol this branch renamed to `lastSigningIdentityDefaultsKey`. Nothing conflicted textually (different files), so the PR run stayed green and only the merge-queue candidate, built on the newer main, failed to compile the test target. The assertion it makes is still exactly right: the marker is not a user setting, so it carries no `Blurt` prefix and stays out of the reset sweep. Only the symbol name changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018yY5zrgX28ap3vizkFZu6e
What & why
The Accessibility grant migration previously only handled signing-team changes (re-signed releases). This extends it to also handle cdhash changes between ad-hoc-signed dev builds, where every rebuild is a distinct app to
tccd.The problem: Ad-hoc signatures pin the code hash rather than a team, so each dev build carries a different identity. When a reviewer installs a second dev build, the Accessibility grant from the first is orphaned — the Blurt row stays switched on in System Settings while
AXIsProcessTrusted()keeps returning false, stranding them on the wizard's Accessibility step.The solution: Generalize the migration to track any signing identity (Team ID for team-signed builds,
cdhash:<hex>for ad-hoc), not just the team.SigningIdentity.current()collapses both cases into one comparable string. The migration logic stays pure and testable; the only system-level change is reading the full identity instead of just the team.Changes:
SigningIdentity.current()returns the identity that an Accessibility grant would pin to: Team ID when present, elsecdhash:<hex>for ad-hoc codeSigningIdentityMigrationrenamed parameters and cases fromteamtoidentityto reflect the broader scopeAppDelegate.runAccessibilityGrantMigration()now callsSigningIdentity.current()instead ofcurrentTeamIdentifier()#if UITEST_HOOKS) so local test runs don't wipe the developer's real grantAGENTS.mdandCONTRIBUTING.mdto explain the cdhash case and the per-build re-grant expectationThe defaults key name (
accessibility.lastSigningTeam) is frozen to avoid re-running the reset for every installed user.How it was tested
scripts/check.shpasses (or CI will, if I'm not on a Mac)Test coverage: Added
SigningIdentityTeststo verifycurrent()is stable and shape-correct. ExtendedSigningIdentityMigrationTestswith scenarios for ad-hoc rebuilds, same-build relaunches, and swaps between release and dev builds. All existing tests updated to use the new parameter names.https://claude.ai/code/session_018yY5zrgX28ap3vizkFZu6e