TCC Identity Anchors

macOS never forgets a face it trusts — as long as the face never changes. The council spent an afternoon on exactly that catch, arguing over how many stone anchors one haus needs. Not the boat kind. The identity kind.
TCC (Transparency, Consent, and Control) keys every permission grant to a (binary, category) tuple, and the binary’s identity is the face it anchors to. If the binary is unsigned, TCC keys to its absolute path — brittle, since any rename, any reinstall, any version churn orphans the grant, and the family starts clicking Allow again. If the binary is signed, TCC keys to the Team ID of the signing certificate — years of stability across upgrades.
So the real question was never “how do we stop the prompts.” It was: how many signing identities does one haus keep, and which work goes behind each? The answer is two — not one, not nine. They are NOT federated under a single umbrella bundle, by deliberate council decision (2026-05-22 — vault topic federate-vs-two-anchors-decision). Here is each anchor, and why the council refused to collapse them. (The night the prompts actually stopped is its own story: The Allow Button, Retired.)
Anchor A — Node.js Foundation .pkg
Section titled “Anchor A — Node.js Foundation .pkg”Apple Root CA └─ Developer ID Certification Authority └─ Developer ID Application: Node.js Foundation (HX7739G8FX) └─ /usr/local/bin/node ← Sanctum's canonical node binaryInstalled from the official nodejs.org .pkg. Notarized by Apple, trusted timestamp, self-contained (statically links or bundles all its dylibs — no brew Cellar dependency chain). Path is stable because Apple’s Installer pkg layout doesn’t churn.
Used by: 9 of 10 sanctum node services — dench-proxy.js, ha-gateway.js, firewalla-bridge.js, claude-max-api-tailscale.js, command-center, jocasta-message-sync, and three jocasta-mcp stdio instances.
Grants: 13 TCC categories applied via ~/.sanctum/scripts/sanctum-grant-tcc.sh — Full Disk Access, all SystemPolicy* folders, MediaLibrary, Photos, Reminders, Calendar, AddressBook, FileProviderDomain.
Maintenance: when Node.js Foundation ships a new release and Sanctum wants to upgrade, re-run the .pkg installer (same signing identity, same Team ID — grants persist). brew node is irrelevant to this anchor; it can be unpinned and allowed to upgrade freely.
Anchor B — SanctumBridge.app
Section titled “Anchor B — SanctumBridge.app”Apple Root CA └─ Developer ID Certification Authority └─ Developer ID Application: Bertrand Nepveu (GJ994MN2YF) └─ /Users/neo/Applications/SanctumBridge.app ├─ Bundle ID: ai.openclaw.denchclaw (stable) ├─ Mach-O C launcher (Contents/MacOS/SanctumBridge) └─ posix_spawns /usr/local/bin/node as a childA signed .app bundle that proxies SQLite reads for iMessage, WhatsApp, Calendar, and Contacts — the SanctumBridge service, in full elsewhere. The small C launcher embedded at Contents/MacOS/SanctumBridge (launcher/main.c, ~90 lines) is what holds the bundle-level TCC identity — TCC keys to the bundle ID ai.openclaw.denchclaw because that’s the executable launchd spawns. The launcher then posix_spawns /usr/local/bin/node as a child, which runs Resources/bridge/server.js.
Used by: one service — the FDA-privileged SQLite bridge.
Grants: bundle-level Full Disk Access, Automation grants to the target apps (Messages.app, Calendar.app, Mail.app, Contacts.app).
Maintenance: only requires re-signing when the launcher source changes (launcher/main.c in Ogilthorp3/sanctum-bridge). The bundle’s Makefile produces build/SanctumBridge.app; make install copies it to ~/Applications; a single codesign --force --sign "Developer ID Application: Bertrand Nepveu (GJ994MN2YF)" re-anchors the bundle.
Why two anchors, not one
Section titled “Why two anchors, not one”The temptation, after standing up the maximum-effort migration to Anchor A, was to federate every sanctum node service under a single umbrella bundle (call it SanctumLauncher.app). One Developer ID for the whole haus. Tempting.
The council voted against it. The technically dispositive argument came from council-brain (Claude Opus via Max bridge):
“Launchd-spawned daemons don’t cleanly inherit a wrapper app’s TCC responsible-process identity anyway, so don’t rebuild a system you just stabilized.”
macOS’s TCC “responsible process” mechanism — which is what attributes a child’s permission requests to its parent — only flows cleanly for interactive parent apps. When you double-click an .app in Finder and that app spawns child processes, TCC attributes the children to the parent’s identity. When launchd spawns the process directly (which is how sanctum’s 9 vanilla node services run), each child is its own top-level process for TCC purposes. There is no interactive parent to attribute up to.
Federating those 9 services under an umbrella bundle would have produced a parent bundle whose identity TCC would never actually use for the daemons’ protected accesses. Federation cost (rebuild on every node-service change, more complex plists, notarization gating) for zero TCC benefit.
SanctumBridge.app is the right shape because its launcher is the interactive (well, launchd-launched-but-bundled) parent that does hold the responsible-process identity for its specific child. The other 9 services don’t have that shape; they’re naked node scripts whose anchor is the node binary itself.
The third-anchor question
Section titled “The third-anchor question”A third anchor would be the right shape if Sanctum starts shipping native interactive .app surfaces that need their own bundle-level TCC (e.g. a Swift dashboard app, a menu-bar widget that touches Photos, a system extension). Those would each be their own anchor under Bertrand’s Developer ID — different bundle IDs, same Team ID, separate TCC grants.
What would NOT be a good third anchor: a wrapper bundle around launchd-spawned daemons. That’s the case the council just decided against. The launchd→child attribution gap is structural; no amount of wrapper engineering closes it on macOS as currently shipped.
Operator surface
Section titled “Operator surface”~/.sanctum/scripts/sanctum-grant-tcc.sh— grants the canonical 13 TCC categories to the.pkgnode binary.--verifyfor read-only checks;--uninstallfor clean removal;--client <path>for targeting other binaries.~/Projects/sanctum-bridge/— source repo for the SanctumBridge launcher.make build && make installproduces an updated bundle; onecodesign --force --sign "Developer ID Application: Bertrand Nepveu (GJ994MN2YF)"re-anchors it.~/Projects/sanctum-runtime/docs/doctrine/runbooks/node-tcc-stop-the-prompts.md— runbook for diagnosing TCC prompt loops, with the FDA + Automation trade-off worked through step by step. Heads-up: its step-by-step still names the old brew-Cellar node path and leans onbrew pin— both superseded by the.pkganchor this page documents. The diagnosis logic holds; the path it points you at is one migration behind.
It is recorded here so the next operator, or the next Claude session, doesn’t melt two stable anchors into one clever bundle, rediscover the launchd attribution gap the hard way, and rebuild a system that was already holding. Two anchors. Both years-stable, both upstream of TCC’s complaint surface. Leave them planted.