Skip to content

Extend signing identity migration to handle ad-hoc builds - #124

Merged
alexkroman merged 8 commits into
mainfrom
claude/dev-builds-accessibility-grant-avu2qx
Aug 12, 2026
Merged

Extend signing identity migration to handle ad-hoc builds#124
alexkroman merged 8 commits into
mainfrom
claude/dev-builds-accessibility-grant-avu2qx

Conversation

@alexkroman

Copy link
Copy Markdown
Collaborator

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, else cdhash:<hex> for ad-hoc code
  • SigningIdentityMigration renamed parameters and cases from team to identity to reflect the broader scope
  • AppDelegate.runAccessibilityGrantMigration() now calls SigningIdentity.current() instead of currentTeamIdentifier()
  • Added UI-test carve-out (#if UITEST_HOOKS) so local test runs don't wipe the developer's real grant
  • Extended test coverage to verify ad-hoc rebuild scenarios and swaps between release and dev builds
  • Updated documentation in AGENTS.md and CONTRIBUTING.md to explain the cdhash case and the per-build re-grant expectation

The defaults key name (accessibility.lastSigningTeam) is frozen to avoid re-running the reset for every installed user.

How it was tested

  • scripts/check.sh passes (or CI will, if I'm not on a Mac)
  • I read AGENTS.md and this doesn't reintroduce anything deliberately removed
  • Docs updated if behavior changed

Test coverage: Added SigningIdentityTests to verify current() is stable and shape-correct. Extended SigningIdentityMigrationTests with 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

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
@alexkroman
alexkroman enabled auto-merge August 12, 2026 03:40
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Dev build

Download Blurt.app — built from f29c60a, Debug-Local,
ad-hoc signed. Expires in 14 days.

Installing it
cd ~/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 Blurt

It is ad-hoc signed and not notarized: Gatekeeper refuses to open it until
the quarantine flag is cleared, and macOS treats it as a different app from a
released Blurt, so you have to re-grant Microphone, Accessibility, and Input
Monitoring. Reinstall the release DMG
when you are done reviewing.

Expect that re-grant once per dev build, including a second build of this
same PR. TCC pins an Accessibility grant to the signature that took it, and an
ad-hoc signature is just a hash of the binary, so every build is a new app as far
as tccd is concerned. Blurt clears the orphaned grant at launch, which is what
keeps the Accessibility step from getting stuck on a Blurt row that is switched on
and still denied. If you are coming from a build old enough to predate that,
clear the grant yourself once:

tccutil reset Accessibility dev.alex.blurt

claude added 4 commits August 12, 2026 03:42
`#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
@alexkroman
alexkroman disabled auto-merge August 12, 2026 04:13
`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
@alexkroman
alexkroman enabled auto-merge August 12, 2026 04:14
@alexkroman
alexkroman added this pull request to the merge queue Aug 12, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 12, 2026
@alexkroman
alexkroman added this pull request to the merge queue Aug 12, 2026
@alexkroman
alexkroman removed this pull request from the merge queue due to a manual request Aug 12, 2026
#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
@alexkroman
alexkroman enabled auto-merge August 12, 2026 04:38
@alexkroman
alexkroman added this pull request to the merge queue Aug 12, 2026
Merged via the queue into main with commit 816ff52 Aug 12, 2026
10 checks passed
@alexkroman
alexkroman deleted the claude/dev-builds-accessibility-grant-avu2qx branch August 12, 2026 04:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants