macos-permissions-privacy · git:20260717.accdb14 · 2026-07-17 · sha256 103071b5822f938c
macos-permissions-privacy git:20260717.accdb14A
Immutable. This exact content is served forever at /api/v1/blob/103071b5822f938c.
---
name: macos-permissions-privacy
description: Use when a macOS app needs a TCC permission or touches private data — the map of which API, Info.plist key, and entitlement each permission needs, pre-prompt explanation patterns, denied-state recovery with System Settings deep links, and honest privacy copy.
---
# macOS Permissions & Privacy
Request only what the app needs *now*, explain why *before* the system asks, and give every denied state a recovery path. On macOS the permission landscape (TCC) is a patchwork — each permission has its own API, its own Info.plist key, and its own failure mode; the map below is most of the work.
## When to use
Use this skill when the user says:
- permission / permission prompt / TCC
- Accessibility access, Screen Recording, Full Disk Access, Input Monitoring
- Automation / Apple Events / "wants to control"
- access files, contacts, calendar, camera, microphone
- app can't see the folder / operation not permitted
- privacy settings / privacy copy
- sandbox entitlements for user data
Do not use this skill for notification permission specifically (see `macos-notifications`) or for iOS-only flows like App Tracking Transparency.
## Core rule
```
Explain before the system prompts. Request at the moment of need.
Request the narrowest thing that works. Every denied state has a
visible recovery path.
```
## The permission map
The column you'll consult most. "Prompt?" = can the app trigger a system dialog, or must the user flip a switch in System Settings themselves?
| Need | API to check/request | Info.plist usage string | Prompt? |
|---|---|---|---|
| Files the user picks | `NSOpenPanel` / drag-in + security-scoped bookmarks | — | Picker *is* consent |
| Desktop / Documents / Downloads (programmatic) | just access it; system prompts once | — | Yes, automatic |
| Network volumes / removable | same | — | Yes, automatic |
| **Full Disk Access** | none — attempt access, detect failure | — | **No — Settings only** |
| **Accessibility** (control UI, event taps) | `AXIsProcessTrustedWithOptions` | — | Prompt opens Settings |
| **Screen Recording** | `CGPreflightScreenCaptureAccess()` / `CGRequestScreenCaptureAccess()` | — | Once; then Settings |
| **Input Monitoring** | `IOHIDCheckAccess(.listenEvent)` / `IOHIDRequestAccess` | — | Yes |
| Automation (Apple Events) | `AEDeterminePermissionToAutomateTarget` | `NSAppleEventsUsageDescription` | Yes, per target app |
| Camera / Microphone | `AVCaptureDevice.requestAccess(for:)` | `NSCameraUsageDescription` / `NSMicrophoneUsageDescription` | Yes |
| Contacts | `CNContactStore.requestAccess` | `NSContactsUsageDescription` | Yes |
| Calendar / Reminders | `EKEventStore.requestFullAccessToEvents()` | `NSCalendarsFullAccessUsageDescription` | Yes |
| Location | `CLLocationManager.requestWhenInUseAuthorization` | `NSLocationUsageDescription` | Yes |
Sandboxed apps additionally need the matching entitlement (`com.apple.security.files.user-selected.read-write`, `.device.camera`, `.personal-information.*`, …) — without it the API fails without even prompting. A missing Info.plist usage string doesn't ask either: **it crashes the app** at request time.
## The four-step flow (every permission)
**1. Check silently.** Never re-prompt on launch; read status first.
**2. Pre-explain at the moment of need.** The system dialog must never be the first the user hears of it:
```
The user clicks "Watch this folder"
→ sheet: "To notice new files, the app needs access to the
folder you choose. Nothing leaves your Mac." [Choose Folder…]
→ NSOpenPanel (which itself grants the access)
```
**3. Request narrowly.** A folder picker instead of Full Disk Access. One target app for Automation instead of "everything". `whenInUse` instead of `always`.
**4. Handle denial with recovery.** Show the off state where the feature lives *and* in Settings, with a deep link.
## System Settings deep links
```swift
func openPrivacyPane(_ pane: String) {
let url = URL(string:
"x-apple.systempreferences:com.apple.preference.security?\(pane)")!
NSWorkspace.shared.open(url)
}
// The panes you'll actually need:
// Privacy_Accessibility Privacy_ScreenCapture Privacy_AllFiles
// Privacy_Automation Privacy_Microphone Privacy_Camera
// Privacy_ListenEvent (input monitoring) Privacy_LocationServices
```
A denied state without a button to the right pane is a dead end; with it, it's a two-click fix.
## Worked patterns
### Files: the picker is the permission
In the sandbox, whatever the user picks (or drags in) is granted. Persist access with a security-scoped bookmark, or the grant dies with the process:
```swift
// After NSOpenPanel:
let bookmark = try url.bookmarkData(options: .withSecurityScope,
includingResourceValuesForKeys: nil,
relativeTo: nil)
// store `bookmark` (Data) in your app support dir
// On next launch:
var stale = false
let url = try URL(resolvingBookmarkData: bookmark,
options: .withSecurityScope,
relativeTo: nil,
bookmarkDataIsStale: &stale)
guard url.startAccessingSecurityScopedResource() else { /* re-pick */ return }
defer { url.stopAccessingSecurityScopedResource() }
// … read/write …
```
If `stale` is true, re-create the bookmark from the resolved URL. If resolving fails (folder moved/deleted), ask the user to pick again — don't silently do nothing.
### Accessibility
```swift
// Check without prompting:
let trusted = AXIsProcessTrusted()
// Check AND show the system's "grant in Settings" dialog once:
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true]
let trusted = AXIsProcessTrustedWithOptions(options as CFDictionary)
```
The grant does not take effect until your process is relaunched in some flows — after the user enables it, re-check on `didBecomeActiveNotification` and offer "Relaunch" if features still fail.
### Screen recording
```swift
if !CGPreflightScreenCaptureAccess() { // silent check
let granted = CGRequestScreenCaptureAccess() // prompts once, ever
if !granted { /* show recovery UI → Privacy_ScreenCapture */ }
}
```
`CGRequestScreenCaptureAccess` only produces a dialog the first time; afterwards it just returns false. Treat "false" as "send them to Settings", and expect macOS 15+ to periodically re-confirm this permission with the user on your behalf.
### Full Disk Access — avoid, then detect
There is no API to request FDA. First redesign: can a folder picker cover it? If genuinely not (backup tools, indexers), detect by probing a protected path and guide:
```swift
// TCC-protected on every Mac; unreadable without FDA
let probe = FileManager.default
.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Mail")
let hasFDA = (try? FileManager.default
.contentsOfDirectory(atPath: probe.path)) != nil
```
Pair the guide screen with `Privacy_AllFiles` and exact instructions ("drag the app into the list, then relaunch").
### Automation (controlling another app)
```swift
var target = NSAppleEventDescriptor(bundleIdentifier: "com.apple.finder")
let status = AEDeterminePermissionToAutomateTarget(
target.aeDesc, typeWildCard, typeWildCard, true) // true = may prompt
// noErr = allowed · errAEEventNotPermitted = denied
// procNotFound = target not running (launch it first, then ask)
```
Requires `NSAppleEventsUsageDescription` in Info.plist and (sandboxed) a scripting-targets entitlement. The prompt names the target app — one prompt per target.
## Privacy copy pattern
Wherever a permission is explained — pre-prompt sheet, Settings, onboarding:
```
Why we need this one sentence, the feature not the API
What we access concrete: "the folder you choose", not "your files"
What stays local say it plainly if everything does
How to change it "System Settings > Privacy & Security > X, anytime"
```
If the app is local-first, *say so in the permission moment* — "processed on your Mac, never uploaded" is the sentence that converts a hesitant deny into a grant, and it must be true.
## Settings: the permission dashboard
Apps that need 2+ permissions should show their state honestly in one place:
```
Permissions
Folder access ~/Notes granted Change…
Accessibility Not granted Open System Settings…
Screen recording Granted
```
Re-read all statuses when the app becomes active — every one of these can be revoked behind your back, and stale "Granted" labels destroy trust.
## Checklist
```
[ ] Every permission has a pre-prompt explanation in product language
[ ] Requests fire at the moment of need, never stacked at first launch
[ ] Narrowest scope chosen (picker over FDA, one target over many)
[ ] Info.plist usage strings present for every prompting API (missing = crash)
[ ] Sandbox entitlements match the APIs used
[ ] Denied state visible where the feature lives, with the right deep link
[ ] Statuses re-checked on app activation, never cached across launches
[ ] Security-scoped bookmarks persisted and stale-handled for file access
[ ] Privacy copy states what stays local — and it's true
```
## Manual test
1. Fresh profile (or `tccutil reset <service> <bundle-id>`) → feature first-use shows your explanation, then the system prompt.
2. Deny → feature shows its off state + deep link lands on the right pane.
3. Grant in Settings → return to app → status updates without relaunch (or app offers relaunch where required).
4. Revoke while the app is running → app degrades with the recovery UI, no crash.
5. Move a bookmarked folder → app asks to re-pick, doesn't silently break.