Skip to content

TCC Identity Anchors

A pencil sketch of two stone anchors planted on a marble pedestal — one carved with the Apple seal, one carved with the operator's mark; Tommy the Abyssinian sits between them like a stamp witness

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.)

Apple Root CA
└─ Developer ID Certification Authority
└─ Developer ID Application: Node.js Foundation (HX7739G8FX)
└─ /usr/local/bin/node ← Sanctum's canonical node binary

Installed 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.

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 child

A 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.

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.

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.

  • ~/.sanctum/scripts/sanctum-grant-tcc.sh — grants the canonical 13 TCC categories to the .pkg node binary. --verify for read-only checks; --uninstall for clean removal; --client <path> for targeting other binaries.
  • ~/Projects/sanctum-bridge/ — source repo for the SanctumBridge launcher. make build && make install produces an updated bundle; one codesign --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 on brew pin — both superseded by the .pkg anchor 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.