Skip to main content

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}