Skip to content
C.W.K.
Stream
Lesson 04 of 06 · published

TCC: What macOS Remembers About Your App

~17 min · bundle-signing, tcc, accessibility, privacy, permissions

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"A check that cannot fail is not a check."

The Privacy Database Behind Every Prompt

TCC (Transparency, Consent and Control) is the macOS subsystem behind Privacy & Security: Accessibility, Microphone, Camera, Screen Recording, Input Monitoring, Full Disk Access, Calendars, Reminders and more. Each is a separate service with its own grant — the family's calendar bridge learned that Reminders is a different permission from Calendars. A grant records a service, a client (a bundle identifier or a path) and that client's code requirement, which is why the previous three lessons matter: if the requirement changes, the grant no longer applies.

Two practical rules come first. Requests that show a system dialog need a usage-description string in the app's Info.plist (NSMicrophoneUsageDescription and friends), and a command-line tool without an embedded Info.plist is denied silently. And the way to ask about Accessibility is from the running app: AXIsProcessTrustedWithOptions, with the prompt option when you want macOS to show the request.

Grants Belong to the Responsible Process

TCC attributes a request to the responsible process — the app that owns the process tree — not to whatever binary made the call. Run a probe from Terminal and it reports Terminal's grant; run it from a coding agent's shell and it reports the agent host's. That is why a family gate that drives a split-view divider through the Accessibility API could not run from an agent session and had to become an operator-run stage, and why a command launched in Terminal through AppleScript still carried the sender's identity. It is also why each installed launcher bundle needs its own Accessibility grant: a new bundle identity is a new client.

Reading the Database Honestly

A fleet probe reported "never granted" for a menu-bar utility on every Mac, including Macs where it worked. It queried the user TCC database. Accessibility grants live in the system database; Microphone grants live in the per-user one. A check that could not see the row it looked for reported absence by construction. The probe now queries each service in the database that holds it, and distinguishes three states instead of two: no row (never asked, will prompt), a row with auth_value = 0 (asked and not approved — it will not prompt again; the toggle must be flipped in System Settings), and auth_value = 2 (granted). Reading either database from a remote shell needs Full Disk Access; unreadable is reported as unreadable, never as granted or missing.

Stable Signature, Missing Grant

As the requirement lesson showed, a stable designated requirement means a future grant will survive; it never proves a grant exists. The family's launchers therefore ship a --check-accessibility mode that asks from the bundle as LaunchServices launched it, before relying on the permission.

Code

Ask from the running app — and a Swift 6 detail about the prompt key·swift
import ApplicationServices
import Foundation

enum AccessibilityGrant {
    /// Asks TCC about THIS process as launched. prompt: true shows the system request.
    static func isTrusted(prompt: Bool) -> Bool {
        // kAXTrustedCheckOptionPrompt is an imported global var, and Swift 6 refuses it as
        // not concurrency-safe. Its value is this string.
        let key = "AXTrustedCheckOptionPrompt"
        return AXIsProcessTrustedWithOptions([key: prompt] as CFDictionary)
    }
}

@main
struct AXProbe {
    static func main() {
        // Launched from Terminal, this reports TERMINAL's grant: TCC asks the responsible process.
        print("AXIsProcessTrusted:", AccessibilityGrant.isTrusted(prompt: false))
    }
}
Reset once after changing identity; query the right database when auditing·bash
# After moving an app from ad-hoc to a stable identity (old grants pin the old cdhash):
tccutil reset Accessibility com.example.spark
tccutil reset Microphone com.example.spark

# Auditing (needs Full Disk Access for the shell). Accessibility is in the SYSTEM database:
sqlite3 "/Library/Application Support/com.apple.TCC/TCC.db" \
  "SELECT client, auth_value FROM access WHERE service='kTCCServiceAccessibility' AND client='com.example.spark';"
# Microphone is in the USER database:
sqlite3 "$HOME/Library/Application Support/com.apple.TCC/TCC.db" \
  "SELECT client, auth_value FROM access WHERE service='kTCCServiceMicrophone' AND client='com.example.spark';"
# no row -> never asked (will prompt) | 0 -> asked, not approved (will NOT prompt) | 2 -> granted

External links

Exercise

Build the AX probe as a plain executable and run it from Terminal, then from another host app (an editor's integrated terminal, for example). Record both answers and explain the difference. Then add the same check to Spark's Mac app behind a --check-accessibility argument, install the signed app, launch it with open -a Spark --args --check-accessibility, and confirm it reports the app's own grant rather than Terminal's.
Hint
open launches through LaunchServices, so the app is its own responsible process. Running the executable inside the bundle directly from Terminal would make Terminal responsible again.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.