car_permissions/lib.rs
1//! Cross-platform permission preflight for Common Agent Runtime.
2//!
3//! Unifies macOS TCC, Windows privacy/consent APIs, and Linux
4//! `xdg-desktop-portal` / polkit / filesystem-permissions semantics under
5//! one surface:
6//!
7//! - [`status`] — current grant state
8//! - [`request`] — trigger a native prompt if the OS supports one
9//! - [`explain`] — human-readable diagnostic + the user-visible fix
10//! - [`domains`] — which [`Domain`]s are meaningful on the current OS
11//!
12//! # Honest modeling
13//!
14//! A domain that doesn't exist on the current OS returns
15//! [`PermissionStatus::NotApplicable`] — NOT `Denied`. Callers should branch
16//! on `NotApplicable` and skip, rather than treating it as failure.
17//!
18//! # No private APIs
19//!
20//! Backends use only public, documented interfaces. On macOS that means
21//! querying TCC through Apple's public Security/AppKit paths (or returning
22//! `NotDetermined` when only private access would give better data).
23
24use serde::{Deserialize, Serialize};
25use thiserror::Error;
26
27/// Capability domains. A domain may be `NotApplicable` on some OSes — the
28/// enum is the union across all supported platforms.
29///
30/// This enum is `#[non_exhaustive]` — new variants may be added in minor
31/// versions. Downstream consumers MUST include a wildcard arm when
32/// matching, typically handled as `Domain::<unknown>` → `NotApplicable`.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35#[non_exhaustive]
36pub enum Domain {
37 /// Browser automation (launching Chromium, controlling it). Meaningful
38 /// on every OS CAR supports — status reflects whether `car browse` can
39 /// find a Chrome binary it's allowed to invoke.
40 Browser,
41 /// macOS TCC Contacts; Windows Contacts; Linux n/a (usually granted).
42 Contacts,
43 /// macOS TCC Calendar; Windows Calendar; Linux n/a.
44 Calendar,
45 /// Mail — no OS-level permission domain; returns `NotApplicable`
46 /// everywhere. Kept so the domain list is stable across capabilities.
47 Mail,
48 /// Accessibility features / input synthesis. macOS TCC Accessibility;
49 /// Windows UIPI / SendInput (usually granted on interactive sessions);
50 /// Linux X11/Wayland distinction — partially applicable.
51 Accessibility,
52 /// Screen capture. macOS TCC Screen Recording; Windows/Linux typically
53 /// granted.
54 ScreenCapture,
55 /// Automation / app control. macOS Apple Events; Windows COM; Linux n/a.
56 Automation,
57 /// Camera.
58 Camera,
59 /// Microphone.
60 Microphone,
61 /// Wearable / activity data read access. macOS TCC HealthKit types;
62 /// Windows/Linux: per-vendor OAuth (Fitbit / Garmin / Oura / etc.).
63 HealthRead,
64}
65
66impl Domain {
67 pub fn as_str(self) -> &'static str {
68 match self {
69 Domain::Browser => "browser",
70 Domain::Contacts => "contacts",
71 Domain::Calendar => "calendar",
72 Domain::Mail => "mail",
73 Domain::Accessibility => "accessibility",
74 Domain::ScreenCapture => "screen_capture",
75 Domain::Automation => "automation",
76 Domain::Camera => "camera",
77 Domain::Microphone => "microphone",
78 Domain::HealthRead => "health_read",
79 }
80 }
81}
82
83/// Permission state. `#[non_exhaustive]` — future variants are additive.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case")]
86#[non_exhaustive]
87pub enum PermissionStatus {
88 /// The user has granted access.
89 Granted,
90 /// The user has explicitly denied access. Re-requesting usually won't
91 /// re-prompt — the user has to toggle it in System Settings / Windows
92 /// Settings.
93 Denied,
94 /// The user has not yet been prompted. `request()` will prompt.
95 NotDetermined,
96 /// Policy (MDM, parental controls, corp config) is blocking access.
97 Restricted,
98 /// This domain doesn't exist on the current OS. Skip the check.
99 NotApplicable,
100 /// Access was granted, but the process was running when the grant
101 /// happened and must be relaunched before the capability actually
102 /// works. macOS ScreenCaptureKit is the canonical case — status()
103 /// reports `granted`, but `capture_screenshot` keeps returning black
104 /// frames until the process restarts. Explicit state so downstream
105 /// apps can show "restart required" UX instead of silently breaking.
106 RestartRequired,
107 /// The binary signature changed since access was granted (e.g. dev
108 /// → signed release build, or a resign). macOS TCC treats this as a
109 /// new, effectively-denied app, but unlike a fresh `Denied` state
110 /// there is no `request()` prompt — the user must clear the grant
111 /// via `tccutil reset` or toggle in System Settings. Separate state
112 /// so apps can tell users to clear before re-requesting.
113 SignatureChanged,
114}
115
116/// Per-domain diagnostic + user-visible fix hint. `#[non_exhaustive]`.
117#[derive(Debug, Clone, Serialize, Deserialize)]
118#[non_exhaustive]
119pub struct PermissionExplanation {
120 pub domain: Domain,
121 pub status: PermissionStatus,
122 /// Short human-readable description of the current state.
123 pub message: String,
124 /// Suggested next step for the user, or `None` if none applies
125 /// (e.g. already granted, or `NotApplicable`).
126 pub fix: Option<String>,
127}
128
129#[derive(Debug, Error)]
130pub enum PermissionError {
131 #[error("permission subsystem unavailable: {0}")]
132 Unavailable(String),
133 #[error("backend error: {0}")]
134 Backend(String),
135}
136
137/// Context modifiers for a status/request/explain call. Most domains
138/// ignore all fields; `Automation` on macOS requires `target_bundle_id`.
139#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
140#[non_exhaustive]
141pub struct Context {
142 /// For `Domain::Automation` on macOS: the bundle ID of the target
143 /// app you want to control (e.g. `"com.apple.mail"`). TCC Automation
144 /// grants are per-(caller, target) pairs — without this, preflight
145 /// can't tell you whether your caller is authorized for the
146 /// *specific* app it's about to control, and users loop on prompts
147 /// because each target gets its own approval row.
148 pub target_bundle_id: Option<String>,
149}
150
151impl Context {
152 pub fn with_target(bundle_id: impl Into<String>) -> Self {
153 Self {
154 target_bundle_id: Some(bundle_id.into()),
155 }
156 }
157}
158
159/// Current status of a single domain.
160pub fn status(domain: Domain) -> Result<PermissionStatus, PermissionError> {
161 status_with(domain, &Context::default())
162}
163
164/// Status with per-domain context — see [`Context`].
165pub fn status_with(domain: Domain, ctx: &Context) -> Result<PermissionStatus, PermissionError> {
166 backend::status_with(domain, ctx)
167}
168
169/// Trigger a native prompt (where the OS supports one). Returns the new
170/// status after the user responds, or the current status if no prompt was
171/// shown.
172///
173/// On macOS, first-time prompts are specific — you can only ask once per
174/// domain, then the user must change it in System Settings.
175pub fn request(domain: Domain) -> Result<PermissionStatus, PermissionError> {
176 request_with(domain, &Context::default())
177}
178
179pub fn request_with(domain: Domain, ctx: &Context) -> Result<PermissionStatus, PermissionError> {
180 backend::request_with(domain, ctx)
181}
182
183/// Human-readable explanation + fix suggestion.
184pub fn explain(domain: Domain) -> Result<PermissionExplanation, PermissionError> {
185 explain_with(domain, &Context::default())
186}
187
188pub fn explain_with(
189 domain: Domain,
190 ctx: &Context,
191) -> Result<PermissionExplanation, PermissionError> {
192 let s = status_with(domain, ctx)?;
193 let (message, fix) = backend::describe(domain, s);
194 Ok(PermissionExplanation {
195 domain,
196 status: s,
197 message,
198 fix,
199 })
200}
201
202/// All domains defined by this crate. Callers can filter to the ones that
203/// are meaningful on the current OS by checking `status() != NotApplicable`.
204pub fn domains() -> Vec<Domain> {
205 vec![
206 Domain::Browser,
207 Domain::Contacts,
208 Domain::Calendar,
209 Domain::Mail,
210 Domain::Accessibility,
211 Domain::ScreenCapture,
212 Domain::Automation,
213 Domain::Camera,
214 Domain::Microphone,
215 Domain::HealthRead,
216 ]
217}
218
219// ---------------------------------------------------------------------------
220// Backends
221// ---------------------------------------------------------------------------
222
223mod backend;
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 #[test]
230 fn domains_is_stable() {
231 assert_eq!(domains().len(), 10);
232 }
233
234 #[test]
235 fn every_domain_reports_something() {
236 for d in domains() {
237 // Should not error regardless of OS; NotApplicable is a valid
238 // answer, not an error.
239 let s = status(d).expect("status should not error");
240 let e = explain(d).expect("explain should not error");
241 assert_eq!(e.status, s);
242 assert_eq!(e.domain, d);
243 }
244 }
245
246 #[test]
247 fn explain_includes_fix_when_not_granted() {
248 // We can't assume any specific status, but we can assert that
249 // `explain` returns coherent data — if status is denied or
250 // restricted, `fix` should be Some.
251 for d in domains() {
252 let e = explain(d).unwrap();
253 match e.status {
254 PermissionStatus::Denied
255 | PermissionStatus::Restricted
256 | PermissionStatus::NotDetermined => {
257 assert!(
258 e.fix.is_some(),
259 "domain {:?} has status {:?} but no fix hint",
260 d,
261 e.status
262 );
263 }
264 PermissionStatus::Granted | PermissionStatus::NotApplicable => {
265 // Fix may be None.
266 }
267 PermissionStatus::RestartRequired | PermissionStatus::SignatureChanged => {
268 // Both carry actionable fix text, but asserting on them
269 // is environment-dependent (signature / restart state
270 // depends on the running process), so we don't enforce
271 // here — the describe() match arm handles messaging.
272 }
273 }
274 }
275 }
276}