Skip to main content

computer_use/
policy.rs

1//! Application policy: built-in deny list plus user and admin `policy.toml`.
2//!
3//! Resolution order is built-in deny, then admin policy, then user policy.
4//! Deny wins. A user allow cannot override an admin deny or a built-in deny.
5//! Admin `allow` entries do not grant access. A missing user file is
6//! `default = "allow"` plus this built-in list. `pause_on_user_input` defaults
7//! to false when the key is absent, so a missing file does not install an event tap.
8
9use std::path::Path;
10use std::sync::{OnceLock, RwLock};
11
12use crate::error::ComputerError;
13use crate::target::AppSelector;
14
15const SYSTEM_SETTINGS_BUNDLES: &[&str] = &["com.apple.systempreferences", "com.apple.Settings"];
16
17/// Built-in deny entries the loaded policy exempts.
18///
19/// The built-in list refuses terminals and password managers, plus apps that
20/// run agents of their own. A policy file can exempt a specific entry with
21/// `[builtin_deny] exempt = [...]`, but only the user file may do so and the
22/// exemption is never implicit. The registry is process-wide because the
23/// backends consult the built-in list without carrying the policy.
24static BUILTIN_EXEMPT: OnceLock<RwLock<Vec<String>>> = OnceLock::new();
25
26fn exempt_registry() -> &'static RwLock<Vec<String>> {
27    BUILTIN_EXEMPT.get_or_init(|| RwLock::new(Vec::new()))
28}
29
30/// Replace the built-in exemptions with `items`. Called once per action, right
31/// after the policy file is loaded.
32pub fn set_builtin_exemptions(items: &[String]) {
33    let mut registry = exempt_registry()
34        .write()
35        .unwrap_or_else(|error| error.into_inner());
36    *registry = items.to_vec();
37}
38
39/// The built-in exemptions currently in force.
40pub fn builtin_exemptions() -> Vec<String> {
41    exempt_registry()
42        .read()
43        .unwrap_or_else(|error| error.into_inner())
44        .clone()
45}
46
47/// True when `value` names a built-in entry the policy exempts.
48fn is_exempt(value: &str) -> bool {
49    let value = value.trim();
50    if value.is_empty() {
51        return false;
52    }
53    builtin_exemptions()
54        .iter()
55        .any(|item| item.trim().eq_ignore_ascii_case(value))
56}
57
58/// True when the exempt list names this bundle id, executable name, or bundle
59/// path. The comparison is deliberately loose so one entry can cover the
60/// bundle id, the executable, and the `.app` path of the same app.
61fn exempts_identity(bundle_id: Option<&str>, exe_name: Option<&str>, bundle: Option<&str>) -> bool {
62    let mut candidates: Vec<&str> = Vec::new();
63    if let Some(bundle_id) = bundle_id {
64        candidates.push(bundle_id);
65    }
66    if let Some(exe_name) = exe_name {
67        candidates.push(exe_name);
68        candidates.push(exe_name.trim_end_matches(".exe"));
69    }
70    if let Some(bundle) = bundle {
71        candidates.push(bundle);
72        candidates.push(bundle.trim_end_matches(".app"));
73    }
74    candidates.iter().any(|candidate| is_exempt(candidate))
75}
76
77/// Bundle ids that are always refused. Comparison is case-insensitive.
78const DENIED_BUNDLE_IDS: &[&str] = &[
79    "com.apple.Terminal",
80    "com.googlecode.iterm2",
81    "dev.warp.Warp-Stable",
82    "org.alacritty",
83    "net.kovidgoyal.kitty",
84    "com.mitchellh.ghostty",
85    "com.github.wez.wezterm",
86    "co.zeit.hyper",
87    "com.1password.1password",
88    "com.agilebits.onepassword7",
89    "com.bitwarden.desktop",
90    "org.keepassxc.KeePassXC",
91    "com.dashlane.Dashlane",
92    "in.sinew.Enpass-Desktop",
93    "com.lastpass.LastPass",
94    "com.lastpass.lastpassmacdesktop",
95    "com.apple.Passwords",
96    "com.apple.keychainaccess",
97    "Microsoft.WindowsTerminal",
98    "app.crynta.terax",
99];
100
101/// Executable file names, matched case-insensitively after canonicalization.
102const DENIED_EXE_NAMES: &[&str] = &[
103    "Terminal",
104    "iTerm2",
105    "Warp",
106    "Alacritty",
107    "kitty",
108    "ghostty",
109    "wezterm",
110    "wezterm-gui",
111    "Hyper",
112    "hyper",
113    "powershell.exe",
114    "cmd.exe",
115    "wt.exe",
116    "WindowsTerminal.exe",
117    "gnome-terminal",
118    "konsole",
119    "xterm",
120    "1Password",
121    "1Password.exe",
122    "Bitwarden",
123    "Bitwarden.exe",
124    "KeePassXC",
125    "KeePassXC.exe",
126    "KeePass.exe",
127    "Dashlane",
128    "Dashlane.exe",
129    "Enpass",
130    "Enpass.exe",
131    "LastPass",
132    "lastpass",
133    "LastPass.exe",
134    "Passwords",
135    "Passwords.exe",
136    "Keychain Access",
137    "Terax",
138];
139
140/// `.app` bundle directory names, matched as a single path component.
141const DENIED_APP_BUNDLES: &[&str] = &[
142    "Terminal.app",
143    "iTerm.app",
144    "iTerm2.app",
145    "Warp.app",
146    "Alacritty.app",
147    "kitty.app",
148    "Ghostty.app",
149    "WezTerm.app",
150    "Hyper.app",
151    "1Password.app",
152    "1Password 7.app",
153    "Bitwarden.app",
154    "KeePassXC.app",
155    "Dashlane.app",
156    "Enpass.app",
157    "LastPass.app",
158    "Passwords.app",
159    "Keychain Access.app",
160    "Terax.app",
161];
162
163/// Display names refused before a bundle is resolved. `Terminal` is enough
164/// to refuse `launch Terminal` without scanning the disk.
165const DENIED_DISPLAY_NAMES: &[&str] = &[
166    "Terminal",
167    "iTerm",
168    "iTerm2",
169    "Warp",
170    "Alacritty",
171    "kitty",
172    "Ghostty",
173    "WezTerm",
174    "Hyper",
175    "1Password",
176    "1Password 7",
177    "Bitwarden",
178    "KeePassXC",
179    "Dashlane",
180    "Enpass",
181    "LastPass",
182    "Passwords",
183    "Keychain Access",
184    "Windows Terminal",
185    "Terax",
186];
187
188/// Why a built-in rule refused an app.
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub struct Denial {
191    /// `bundle`, `path`, `name`, or `system-settings`.
192    pub source: &'static str,
193    /// Human-readable explanation. Does not include typed text or URLs.
194    pub message: String,
195}
196
197/// Refuse `selector` before it is resolved, when the selector itself is enough.
198pub fn selector_denial(selector: &AppSelector) -> Option<Denial> {
199    match selector {
200        AppSelector::Pid(_) => None,
201        AppSelector::BundleId(bundle_id) => bundle_denial(bundle_id).map(|message| Denial {
202            source: "bundle",
203            message,
204        }),
205        AppSelector::Name(name) => {
206            if denied_display_name(name) {
207                Some(Denial {
208                    source: "name",
209                    message: format!("built-in deny list refuses {name}"),
210                })
211            } else {
212                None
213            }
214        }
215        AppSelector::Path(path) => path_denial(path).map(|message| Denial {
216            source: "path",
217            message,
218        }),
219    }
220}
221
222/// Refuse a resolved app. System Settings is refused only when the targeted
223/// window title or accessibility identifier mentions Privacy or Security.
224pub fn identity_denial(
225    bundle_id: Option<&str>,
226    exe_path: Option<&Path>,
227    window_title: Option<&str>,
228    ax_identifier: Option<&str>,
229) -> Option<Denial> {
230    if let Some(bundle_id) = bundle_id {
231        let exe_name = exe_path
232            .and_then(|path| path.file_name())
233            .and_then(|file| file.to_str());
234        let bundle = exe_path
235            .and_then(|path| path.components().next_back())
236            .map(|component| component.as_os_str().to_string_lossy().to_string())
237            .filter(|text| text.ends_with(".app"));
238        if !exempts_identity(Some(bundle_id), exe_name, bundle.as_deref()) {
239            if let Some(message) = bundle_denial(bundle_id) {
240                return Some(Denial {
241                    source: "bundle",
242                    message,
243                });
244            }
245        }
246        if is_system_settings(bundle_id) && privacy_or_security(window_title, ax_identifier) {
247            return Some(Denial {
248                source: "system-settings",
249                message: "built-in deny list refuses System Settings while the targeted window title or identifier contains Privacy or Security".into(),
250            });
251        }
252    }
253    if let Some(path) = exe_path {
254        let exe_name = path.file_name().and_then(|file| file.to_str());
255        let bundle = path
256            .components()
257            .next_back()
258            .map(|component| component.as_os_str().to_string_lossy().to_string())
259            .filter(|text| text.ends_with(".app"));
260        if exempts_identity(bundle_id, exe_name, bundle.as_deref()) {
261            return None;
262        }
263        if let Some(message) = path_denial(path) {
264            return Some(Denial {
265                source: "path",
266                message,
267            });
268        }
269    }
270    None
271}
272
273/// Map a denial to [`ComputerError::PolicyDenied`].
274pub fn deny_or_allow(denial: Option<Denial>) -> Result<(), ComputerError> {
275    match denial {
276        None => Ok(()),
277        Some(denial) => Err(ComputerError::PolicyDenied(denial.message)),
278    }
279}
280
281/// `loginwindow`, `WindowServer`, and this process cannot be quit.
282pub fn quit_is_protected(
283    pid: i32,
284    current_pid: i32,
285    bundle_id: Option<&str>,
286    name: &str,
287    path: Option<&str>,
288) -> bool {
289    if pid == current_pid {
290        return true;
291    }
292    if bundle_id.is_some_and(|id| id.eq_ignore_ascii_case("com.apple.loginwindow")) {
293        return true;
294    }
295    let name = name.trim();
296    if name.eq_ignore_ascii_case("loginwindow") || name.eq_ignore_ascii_case("WindowServer") {
297        return true;
298    }
299    path.is_some_and(|path| {
300        Path::new(path)
301            .file_name()
302            .and_then(|file| file.to_str())
303            .is_some_and(|file| {
304                file.eq_ignore_ascii_case("loginwindow")
305                    || file.eq_ignore_ascii_case("WindowServer")
306            })
307    })
308}
309
310fn bundle_denial(bundle_id: &str) -> Option<String> {
311    if is_exempt(bundle_id) {
312        return None;
313    }
314    DENIED_BUNDLE_IDS
315        .iter()
316        .find(|id| id.eq_ignore_ascii_case(bundle_id))
317        .map(|id| format!("built-in deny list refuses {id}"))
318}
319
320fn path_denial(path: &Path) -> Option<String> {
321    if let Some(file) = path.file_name().and_then(|file| file.to_str()) {
322        if DENIED_EXE_NAMES
323            .iter()
324            .any(|name| name.eq_ignore_ascii_case(file))
325        {
326            return Some(format!(
327                "built-in deny list refuses executable {}",
328                path.display()
329            ));
330        }
331    }
332    for component in path.components() {
333        let text = component.as_os_str().to_string_lossy();
334        if DENIED_APP_BUNDLES
335            .iter()
336            .any(|name| text.eq_ignore_ascii_case(name))
337        {
338            return Some(format!(
339                "built-in deny list refuses path {}",
340                path.display()
341            ));
342        }
343    }
344    None
345}
346
347fn denied_display_name(name: &str) -> bool {
348    let name = name.trim();
349    if is_exempt(name) {
350        return false;
351    }
352    DENIED_DISPLAY_NAMES
353        .iter()
354        .any(|denied| denied.eq_ignore_ascii_case(name))
355}
356
357fn is_system_settings(bundle_id: &str) -> bool {
358    SYSTEM_SETTINGS_BUNDLES
359        .iter()
360        .any(|id| id.eq_ignore_ascii_case(bundle_id))
361}
362
363fn privacy_or_security(window_title: Option<&str>, ax_identifier: Option<&str>) -> bool {
364    [window_title, ax_identifier]
365        .into_iter()
366        .flatten()
367        .any(|text| {
368            let lower = text.to_ascii_lowercase();
369            lower.contains("privacy") || lower.contains("security")
370        })
371}
372
373/// Marker stored in `kCGEventSourceUserData` (`CGEventField::EventSourceUserData`, value 42).
374///
375/// P7 posts synthetic events with this value. The listen-only tap ignores them.
376/// Field 43 is `kCGEventSourceUserID` and is not the marker.
377pub const SYNTHETIC_EVENT_USER_DATA: i64 = 0x00C0_5EC0_0DE1;
378
379/// True when a tap event was posted by this process and must not pause.
380pub fn event_is_synthetic(user_data: i64) -> bool {
381    user_data == SYNTHETIC_EVENT_USER_DATA
382}
383
384const BROWSER_BUNDLES: &[&str] = &[
385    "com.apple.Safari",
386    "com.google.Chrome",
387    "com.google.Chrome.canary",
388    "org.chromium.Chromium",
389    "com.microsoft.edgemac",
390    "com.microsoft.Edge",
391    "com.brave.Browser",
392    "company.thebrowser.Browser",
393    "org.mozilla.firefox",
394];
395
396const BROWSER_EXES: &[&str] = &[
397    "Safari",
398    "Google Chrome",
399    "Chromium",
400    "chrome",
401    "chrome.exe",
402    "chromium",
403    "msedge",
404    "msedge.exe",
405    "Microsoft Edge",
406    "brave",
407    "Brave Browser",
408    "Arc",
409    "firefox",
410    "firefox.exe",
411];
412
413/// How [`decide`] classifies an action.
414#[derive(Debug, Clone, PartialEq, Eq)]
415pub enum PolicyAction {
416    /// `policy_check`. The app verdict is separate.
417    Inspect,
418    /// Always allowed: wait, doctor, displays, list, pause, resume.
419    Always,
420    /// Read-only maintenance that does not name a denied app.
421    Benign,
422    /// Tree, screenshot, or get-state. A denied target is refused.
423    Observe,
424    /// Changes the desktop.
425    Mutate,
426    /// Quit, including force quit. Allowed apps still need confirmation.
427    Quit,
428    /// A key combination.
429    Key {
430        /// Combo text such as `cmd+q`.
431        combo: String,
432    },
433    /// `clipboard_set` (P8). Classified here so that phase can call [`decide`].
434    ClipboardSet,
435}
436
437/// Allow, refuse, or ask the confirm hook.
438#[derive(Debug, Clone, PartialEq, Eq)]
439pub enum Decision {
440    /// The action may run.
441    Allow {
442        /// `builtin`, `admin`, `user`, or `default`.
443        source: &'static str,
444    },
445    /// The action must not run.
446    Deny {
447        /// `builtin`, `admin`, or `user`.
448        source: &'static str,
449        /// Human-readable explanation. Does not include typed text or URLs.
450        message: String,
451    },
452    /// A sensitive action. Run the confirm hook, or return `confirm_required`.
453    Confirm {
454        /// Why confirmation is required.
455        reason: String,
456    },
457}
458
459/// Resolved target passed to [`decide`].
460#[derive(Debug, Clone, Default, PartialEq, Eq)]
461pub struct Subject {
462    /// Localized name.
463    pub name: Option<String>,
464    /// Bundle id.
465    pub bundle_id: Option<String>,
466    /// Executable path. [`decide`] canonicalizes it.
467    pub path: Option<String>,
468    /// Process id.
469    pub pid: Option<i32>,
470    /// Window id when one was targeted.
471    pub window_id: Option<u32>,
472    /// Targeted window title. Used for the System Settings privacy rule.
473    pub window_title: Option<String>,
474    /// Accessibility identifier of the targeted window.
475    pub ax_identifier: Option<String>,
476}
477
478#[derive(Debug, Clone, PartialEq, Eq)]
479struct Rule {
480    effect: RuleEffect,
481    bundle_id: Option<String>,
482    path: Option<String>,
483    name: Option<String>,
484    source: &'static str,
485}
486
487#[derive(Debug, Clone, Copy, PartialEq, Eq)]
488enum RuleEffect {
489    Allow,
490    Deny,
491}
492
493/// Merged admin and user policy.
494#[derive(Debug, Clone, PartialEq, Eq)]
495pub struct LoadedPolicy {
496    user_default_deny: bool,
497    admin_default_deny: bool,
498    rules: Vec<Rule>,
499    blocked_urls: Vec<String>,
500    user_confirm: Option<String>,
501    admin_confirm: Option<String>,
502    timeout_secs: u64,
503    /// Built-in deny entries the user policy exempts.
504    builtin_exempt: Vec<String>,
505    /// True only when a loaded file sets the key. A missing file stays false.
506    pub pause_on_user_input: bool,
507}
508
509impl Default for LoadedPolicy {
510    fn default() -> Self {
511        Self {
512            user_default_deny: false,
513            admin_default_deny: false,
514            rules: Vec::new(),
515            blocked_urls: Vec::new(),
516            user_confirm: None,
517            admin_confirm: None,
518            timeout_secs: 30,
519            builtin_exempt: Vec::new(),
520            pause_on_user_input: false,
521        }
522    }
523}
524
525impl LoadedPolicy {
526    /// Patterns from the user and admin files.
527    pub fn blocked_urls(&self) -> &[String] {
528        &self.blocked_urls
529    }
530
531    /// Built-in deny entries the user policy exempts.
532    pub fn builtin_exempt(&self) -> &[String] {
533        &self.builtin_exempt
534    }
535
536    /// Timeout for the confirm hook. Capped at 120 seconds.
537    pub fn timeout_secs(&self) -> u64 {
538        self.timeout_secs.clamp(1, 120)
539    }
540}
541
542/// Built-in deny, then admin, then user. Deny wins over allow.
543pub fn decide(policy: &LoadedPolicy, target: &Subject, action: &PolicyAction) -> Decision {
544    if matches!(
545        action,
546        PolicyAction::Always | PolicyAction::Benign | PolicyAction::Inspect
547    ) {
548        return Decision::Allow { source: "default" };
549    }
550    let verdict = app_verdict(policy, target);
551    if let Decision::Deny { .. } = &verdict {
552        return verdict;
553    }
554    if unscoped_target(target)
555        && matches!(
556            action,
557            PolicyAction::Mutate
558                | PolicyAction::Quit
559                | PolicyAction::Key { .. }
560                | PolicyAction::ClipboardSet
561        )
562        && (policy.admin_default_deny || policy.user_default_deny)
563    {
564        let source = if policy.admin_default_deny {
565            "admin"
566        } else {
567            "user"
568        };
569        return Decision::Deny {
570            source,
571            message: format!("{source} policy default is deny and no app was named"),
572        };
573    }
574    if matches!(action, PolicyAction::Quit) && quit_protected(target) {
575        return Decision::Deny {
576            source: "builtin",
577            message: "refusing to quit loginwindow, WindowServer, or this process".into(),
578        };
579    }
580    if let Some(reason) = confirm_reason(action) {
581        return Decision::Confirm { reason };
582    }
583    verdict
584}
585
586/// Allow or deny for `policy check`. Never returns [`Decision::Confirm`].
587pub fn app_verdict(policy: &LoadedPolicy, target: &Subject) -> Decision {
588    let canonical = target
589        .path
590        .as_deref()
591        .map(|path| crate::target::canonical_exe_path(Path::new(path)));
592    if let Some(denial) = identity_denial(
593        target.bundle_id.as_deref(),
594        canonical.as_deref(),
595        target.window_title.as_deref(),
596        target.ax_identifier.as_deref(),
597    ) {
598        return Decision::Deny {
599            source: "builtin",
600            message: denial.message,
601        };
602    }
603    if target.bundle_id.is_none() && canonical.is_none() {
604        if let Some(name) = target.name.as_deref() {
605            if denied_display_name(name) {
606                return Decision::Deny {
607                    source: "builtin",
608                    message: format!("built-in deny list refuses {name}"),
609                };
610            }
611        }
612    }
613    let subject_path = canonical.unwrap_or_default();
614    if policy.rules.iter().any(|rule| {
615        rule.source == "admin"
616            && rule.effect == RuleEffect::Deny
617            && deny_matches(rule, target, &subject_path)
618    }) {
619        return Decision::Deny {
620            source: "admin",
621            message: "admin policy denies this app".into(),
622        };
623    }
624    if policy.rules.iter().any(|rule| {
625        rule.source == "user"
626            && rule.effect == RuleEffect::Deny
627            && deny_matches(rule, target, &subject_path)
628    }) {
629        return Decision::Deny {
630            source: "user",
631            message: "user policy denies this app".into(),
632        };
633    }
634    if let Some(message) = allow_path_mismatch(policy, target, &subject_path) {
635        return Decision::Deny {
636            source: "user",
637            message,
638        };
639    }
640    // An empty subject is the desktop or an action that named no app.
641    // Default deny applies to a named app. Unscoped mutating actions are
642    // refused in `decide`. A user allow never overrides an admin default deny.
643    let named = !unscoped_target(target);
644    if policy.admin_default_deny && named {
645        return Decision::Deny {
646            source: "admin",
647            message: "admin policy default is deny".into(),
648        };
649    }
650    if user_allow_matches(policy, target, &subject_path) {
651        return Decision::Allow { source: "user" };
652    }
653    if policy.user_default_deny && named {
654        return Decision::Deny {
655            source: "user",
656            message: "user policy default is deny".into(),
657        };
658    }
659    Decision::Allow { source: "default" }
660}
661
662fn unscoped_target(target: &Subject) -> bool {
663    target.name.is_none()
664        && target.bundle_id.is_none()
665        && target.path.is_none()
666        && target.pid.is_none()
667}
668
669fn quit_protected(target: &Subject) -> bool {
670    let Some(pid) = target.pid else {
671        return false;
672    };
673    quit_is_protected(
674        pid,
675        std::process::id() as i32,
676        target.bundle_id.as_deref(),
677        target.name.as_deref().unwrap_or(""),
678        target.path.as_deref(),
679    )
680}
681
682fn confirm_reason(action: &PolicyAction) -> Option<String> {
683    match action {
684        PolicyAction::Quit => Some("quit".into()),
685        PolicyAction::ClipboardSet => Some("clipboard_set".into()),
686        PolicyAction::Key { combo } if key_needs_confirm(combo) => {
687            Some(format!("key combination {combo}"))
688        }
689        _ => None,
690    }
691}
692
693fn deny_matches(rule: &Rule, target: &Subject, canonical: &Path) -> bool {
694    if let Some(bundle) = &rule.bundle_id {
695        if target
696            .bundle_id
697            .as_ref()
698            .is_some_and(|id| id.eq_ignore_ascii_case(bundle))
699        {
700            return true;
701        }
702    }
703    if let Some(path) = &rule.path {
704        if path_matches(canonical, path) {
705            return true;
706        }
707    }
708    if let Some(name) = &rule.name {
709        if target
710            .name
711            .as_ref()
712            .is_some_and(|value| value.eq_ignore_ascii_case(name))
713        {
714            return true;
715        }
716    }
717    false
718}
719
720fn user_allow_matches(policy: &LoadedPolicy, target: &Subject, canonical: &Path) -> bool {
721    policy.rules.iter().any(|rule| {
722        rule.source == "user"
723            && rule.effect == RuleEffect::Allow
724            && allow_matches(rule, target, canonical)
725    })
726}
727
728fn allow_matches(rule: &Rule, target: &Subject, canonical: &Path) -> bool {
729    let mut saw = false;
730    if let Some(bundle) = &rule.bundle_id {
731        saw = true;
732        if !target
733            .bundle_id
734            .as_ref()
735            .is_some_and(|id| id.eq_ignore_ascii_case(bundle))
736        {
737            return false;
738        }
739    }
740    if let Some(path) = &rule.path {
741        saw = true;
742        if !path_matches(canonical, path) {
743            return false;
744        }
745    }
746    if let Some(name) = &rule.name {
747        saw = true;
748        if !target
749            .name
750            .as_ref()
751            .is_some_and(|value| value.eq_ignore_ascii_case(name))
752        {
753            return false;
754        }
755    }
756    saw
757}
758
759fn allow_path_mismatch(
760    policy: &LoadedPolicy,
761    target: &Subject,
762    canonical: &Path,
763) -> Option<String> {
764    let bundle = target.bundle_id.as_deref()?;
765    let constrained: Vec<&Rule> = policy
766        .rules
767        .iter()
768        .filter(|rule| {
769            rule.effect == RuleEffect::Allow
770                && rule.source == "user"
771                && rule.path.is_some()
772                && rule
773                    .bundle_id
774                    .as_ref()
775                    .is_some_and(|id| id.eq_ignore_ascii_case(bundle))
776        })
777        .collect();
778    if constrained.is_empty() {
779        return None;
780    }
781    if constrained
782        .iter()
783        .any(|rule| path_matches(canonical, rule.path.as_deref().unwrap_or("")))
784    {
785        return None;
786    }
787    if policy.rules.iter().any(|rule| {
788        rule.source == "user"
789            && rule.effect == RuleEffect::Allow
790            && rule.path.is_none()
791            && rule
792                .bundle_id
793                .as_ref()
794                .is_some_and(|id| id.eq_ignore_ascii_case(bundle))
795    }) {
796        return None;
797    }
798    Some("allow.path does not match the canonical executable path".into())
799}
800
801fn path_matches(canonical: &Path, rule_path: &str) -> bool {
802    if canonical.as_os_str().is_empty() || rule_path.is_empty() {
803        return false;
804    }
805    let rule = Path::new(rule_path);
806    if canonical == rule {
807        return true;
808    }
809    let subject = normalize_path(canonical);
810    let allow = normalize_path(rule);
811    if subject.eq_ignore_ascii_case(&allow) {
812        return true;
813    }
814    let with_sep = if allow.ends_with('/') {
815        allow.clone()
816    } else {
817        format!("{allow}/")
818    };
819    subject
820        .to_ascii_lowercase()
821        .starts_with(&with_sep.to_ascii_lowercase())
822}
823
824fn normalize_path(path: &Path) -> String {
825    path.to_string_lossy().replace('\\', "/")
826}
827
828/// Load the user file at `explicit` (or the default path) and merge the admin file.
829pub fn load_policy(explicit: Option<&Path>) -> Result<LoadedPolicy, ComputerError> {
830    let user = user_policy_file(explicit)?;
831    let admin = admin_policy_file();
832    load_paths(user.as_deref(), admin.as_deref())
833}
834
835/// Load two already chosen files. `None` means that file is absent.
836pub fn load_paths(
837    user: Option<&Path>,
838    admin: Option<&Path>,
839) -> Result<LoadedPolicy, ComputerError> {
840    let mut policy = LoadedPolicy::default();
841    if let Some(path) = admin {
842        merge_file(&mut policy, path, PolicyOrigin::Admin)?;
843    }
844    if let Some(path) = user {
845        merge_file(&mut policy, path, PolicyOrigin::User)?;
846    }
847    policy.timeout_secs = policy.timeout_secs.min(120);
848    Ok(policy)
849}
850
851#[derive(Clone, Copy)]
852enum PolicyOrigin {
853    User,
854    Admin,
855}
856
857fn user_policy_file(explicit: Option<&Path>) -> Result<Option<std::path::PathBuf>, ComputerError> {
858    if let Some(path) = explicit {
859        if !path.is_file() {
860            return Err(ComputerError::InvalidArguments(format!(
861                "policy file not found: {}",
862                path.display()
863            )));
864        }
865        return Ok(Some(path.to_path_buf()));
866    }
867    let Some(base) = user_config_base() else {
868        return Ok(None);
869    };
870    let path = base.join("computer-use").join("policy.toml");
871    if path.is_file() {
872        Ok(Some(path))
873    } else {
874        Ok(None)
875    }
876}
877
878fn user_config_base() -> Option<std::path::PathBuf> {
879    // Explicit `--policy` paths still load. Ambient config must not, in tests.
880    ambient_user_config_base()
881}
882
883#[cfg(test)]
884fn ambient_user_config_base() -> Option<std::path::PathBuf> {
885    None
886}
887
888#[cfg(not(test))]
889fn ambient_user_config_base() -> Option<std::path::PathBuf> {
890    if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
891        if !xdg.is_empty() {
892            return Some(std::path::PathBuf::from(xdg));
893        }
894    }
895    dirs::home_dir().map(|home| home.join(".config"))
896}
897
898fn admin_policy_file() -> Option<std::path::PathBuf> {
899    ambient_admin_policy_file()
900}
901
902#[cfg(test)]
903fn ambient_admin_policy_file() -> Option<std::path::PathBuf> {
904    None
905}
906
907#[cfg(not(test))]
908fn ambient_admin_policy_file() -> Option<std::path::PathBuf> {
909    if let Some(overridden) = std::env::var_os("COMPUTER_USE_ADMIN_POLICY") {
910        let path = std::path::PathBuf::from(overridden);
911        return path.is_file().then_some(path);
912    }
913    let path = default_admin_path();
914    path.is_file().then_some(path)
915}
916
917#[cfg(all(not(test), windows))]
918fn default_admin_path() -> std::path::PathBuf {
919    let base = std::env::var_os("ProgramData")
920        .map(std::path::PathBuf::from)
921        .unwrap_or_else(|| std::path::PathBuf::from(r"C:\ProgramData"));
922    base.join("computer-use").join("policy.toml")
923}
924
925#[cfg(all(not(test), not(windows)))]
926fn default_admin_path() -> std::path::PathBuf {
927    std::path::PathBuf::from("/etc/computer-use/policy.toml")
928}
929
930fn merge_file(
931    policy: &mut LoadedPolicy,
932    path: &Path,
933    origin: PolicyOrigin,
934) -> Result<(), ComputerError> {
935    let text = std::fs::read_to_string(path).map_err(|error| policy_io(origin, path, error))?;
936    if text.trim().is_empty() {
937        return Ok(());
938    }
939    let parsed: PolicyFile =
940        toml::from_str(&text).map_err(|error| parse_error(origin, path, &text, &error))?;
941    if parsed.version.unwrap_or(0) != 1 {
942        return Err(origin_error(
943            origin,
944            path,
945            format!(
946                "line {}: policy version must be 1",
947                line_of_key(&text, "version")
948            ),
949        ));
950    }
951    let default_deny = match parsed.default.as_deref() {
952        None => false,
953        Some("allow") => false,
954        Some("deny") => true,
955        Some(other) => {
956            return Err(origin_error(
957                origin,
958                path,
959                format!(
960                    "line {}: default must be allow or deny (got {other})",
961                    line_of_key(&text, "default")
962                ),
963            ))
964        }
965    };
966    if parsed.pause_on_user_input.unwrap_or(false) {
967        policy.pause_on_user_input = true;
968    }
969    let source = match origin {
970        PolicyOrigin::User => {
971            policy.user_default_deny = default_deny;
972            if let Some(builtin) = parsed.builtin_deny.as_ref() {
973                for entry in &builtin.exempt {
974                    let entry = entry.trim();
975                    if !entry.is_empty() && !policy.builtin_exempt.iter().any(|item| item == entry)
976                    {
977                        policy.builtin_exempt.push(entry.to_string());
978                    }
979                }
980            }
981            if let Some(confirm) = parsed.confirm.as_ref() {
982                if let Some(command) = confirm
983                    .command
984                    .clone()
985                    .filter(|text| !text.trim().is_empty())
986                {
987                    policy.user_confirm = Some(command);
988                }
989                if let Some(timeout) = confirm.timeout_secs {
990                    policy.timeout_secs = timeout.clamp(1, 120);
991                }
992            }
993            "user"
994        }
995        PolicyOrigin::Admin => {
996            policy.admin_default_deny = default_deny;
997            if let Some(confirm) = parsed.confirm.as_ref() {
998                if let Some(command) = confirm
999                    .command
1000                    .clone()
1001                    .filter(|text| !text.trim().is_empty())
1002                {
1003                    policy.admin_confirm = Some(command);
1004                }
1005                if policy.user_confirm.is_none() {
1006                    if let Some(timeout) = confirm.timeout_secs {
1007                        policy.timeout_secs = timeout.clamp(1, 120);
1008                    }
1009                }
1010            }
1011            "admin"
1012        }
1013    };
1014    for entry in parsed.deny {
1015        policy.rules.push(
1016            rule_from(entry, RuleEffect::Deny, source)
1017                .map_err(|detail| origin_error(origin, path, detail))?,
1018        );
1019    }
1020    if matches!(origin, PolicyOrigin::User) {
1021        for entry in parsed.allow {
1022            policy.rules.push(
1023                rule_from(entry, RuleEffect::Allow, source)
1024                    .map_err(|detail| origin_error(origin, path, detail))?,
1025            );
1026        }
1027    }
1028    if let Some(browser) = parsed.browser {
1029        policy.blocked_urls.extend(browser.blocked_urls);
1030    }
1031    Ok(())
1032}
1033
1034fn rule_from(entry: PolicyEntry, effect: RuleEffect, source: &'static str) -> Result<Rule, String> {
1035    if entry.bundle_id.is_none() && entry.path.is_none() && entry.name.is_none() {
1036        return Err("policy allow and deny entries need bundle_id, path, or name".into());
1037    }
1038    let path = entry.path.map(|path| {
1039        crate::target::canonical_exe_path(Path::new(&path))
1040            .to_string_lossy()
1041            .into_owned()
1042    });
1043    Ok(Rule {
1044        effect,
1045        bundle_id: entry.bundle_id,
1046        path,
1047        name: entry.name,
1048        source,
1049    })
1050}
1051
1052fn policy_io(origin: PolicyOrigin, path: &Path, error: std::io::Error) -> ComputerError {
1053    origin_error(origin, path, error.to_string())
1054}
1055
1056fn line_of_key(text: &str, key: &str) -> usize {
1057    text.lines()
1058        .position(|line| {
1059            let trimmed = line.trim_start();
1060            trimmed.starts_with(key) && trimmed[key.len()..].trim_start().starts_with('=')
1061        })
1062        .map(|index| index + 1)
1063        .unwrap_or(1)
1064}
1065
1066fn parse_error(
1067    origin: PolicyOrigin,
1068    path: &Path,
1069    text: &str,
1070    error: &toml::de::Error,
1071) -> ComputerError {
1072    let line = error
1073        .span()
1074        .map(|span| {
1075            text[..span.start.min(text.len())]
1076                .chars()
1077                .filter(|ch| *ch == '\n')
1078                .count()
1079                + 1
1080        })
1081        .or_else(|| line_from_display(&error.to_string()))
1082        .unwrap_or(1);
1083    origin_error(origin, path, format!("line {line}: {error}"))
1084}
1085
1086fn line_from_display(text: &str) -> Option<usize> {
1087    let marker = "at line ";
1088    let start = text.find(marker)? + marker.len();
1089    text[start..]
1090        .split(|ch: char| !ch.is_ascii_digit())
1091        .next()?
1092        .parse()
1093        .ok()
1094}
1095
1096fn origin_error(origin: PolicyOrigin, path: &Path, detail: String) -> ComputerError {
1097    match origin {
1098        PolicyOrigin::User => {
1099            ComputerError::InvalidArguments(format!("{}: {detail}", path.display()))
1100        }
1101        PolicyOrigin::Admin => ComputerError::PolicyDenied(format!("{}: {detail}", path.display())),
1102    }
1103}
1104
1105#[derive(Debug, serde::Deserialize)]
1106#[serde(deny_unknown_fields)]
1107struct PolicyFile {
1108    #[serde(default)]
1109    version: Option<u32>,
1110    #[serde(default)]
1111    default: Option<String>,
1112    #[serde(default)]
1113    pause_on_user_input: Option<bool>,
1114    #[serde(default)]
1115    allow: Vec<PolicyEntry>,
1116    #[serde(default)]
1117    deny: Vec<PolicyEntry>,
1118    #[serde(default)]
1119    confirm: Option<ConfirmSection>,
1120    #[serde(default)]
1121    browser: Option<BrowserSection>,
1122    #[serde(default)]
1123    builtin_deny: Option<BuiltinDenySection>,
1124}
1125
1126/// Exemptions from the built-in deny list. Only the user policy file may set
1127/// this; an admin file that sets it is ignored.
1128#[derive(Debug, serde::Deserialize)]
1129#[serde(deny_unknown_fields)]
1130struct BuiltinDenySection {
1131    /// Bundle ids, executable names, or `.app` bundle names to exempt.
1132    #[serde(default)]
1133    exempt: Vec<String>,
1134}
1135
1136#[derive(Debug, serde::Deserialize)]
1137#[serde(deny_unknown_fields)]
1138struct PolicyEntry {
1139    #[serde(default)]
1140    bundle_id: Option<String>,
1141    #[serde(default)]
1142    path: Option<String>,
1143    #[serde(default)]
1144    name: Option<String>,
1145}
1146
1147#[derive(Debug, serde::Deserialize)]
1148#[serde(deny_unknown_fields)]
1149struct ConfirmSection {
1150    #[serde(default)]
1151    command: Option<String>,
1152    #[serde(default)]
1153    timeout_secs: Option<u64>,
1154}
1155
1156#[derive(Debug, serde::Deserialize)]
1157#[serde(deny_unknown_fields)]
1158struct BrowserSection {
1159    #[serde(default)]
1160    blocked_urls: Vec<String>,
1161}
1162
1163/// Program from the flag, then `COMPUTER_USE_CONFIRM_CMD`, then user policy, then admin.
1164///
1165/// Unit tests do not read the environment variable. The CLI process does.
1166pub fn confirm_program(policy: &LoadedPolicy, flag: Option<&str>) -> Option<String> {
1167    if let Some(flag) = flag.map(str::trim).filter(|text| !text.is_empty()) {
1168        return Some(flag.to_string());
1169    }
1170    #[cfg(not(test))]
1171    if let Ok(env) = std::env::var("COMPUTER_USE_CONFIRM_CMD") {
1172        if !env.trim().is_empty() {
1173            return Some(env);
1174        }
1175    }
1176    policy
1177        .user_confirm
1178        .clone()
1179        .or_else(|| policy.admin_confirm.clone())
1180}
1181
1182/// JSON object written to the confirm hook's stdin.
1183#[derive(Debug, serde::Serialize)]
1184pub struct ConfirmDocument<'a> {
1185    /// Action name.
1186    pub action: &'a str,
1187    /// Target bundle id.
1188    pub bundle_id: Option<&'a str>,
1189    /// Target executable path.
1190    pub path: Option<&'a str>,
1191    /// Target pid.
1192    pub pid: Option<i32>,
1193    /// Target window id.
1194    pub window_id: Option<u32>,
1195    /// Element index.
1196    pub element: Option<u32>,
1197    /// Why this action is sensitive.
1198    pub reason: &'a str,
1199}
1200
1201/// Spawn the hook. Exit 0 proceeds. Any other result is `confirm_denied`.
1202///
1203/// No configured command is `confirm_required` and the action does not run.
1204pub fn run_confirm(
1205    command: Option<&str>,
1206    timeout_secs: u64,
1207    document: &ConfirmDocument<'_>,
1208) -> Result<(), ComputerError> {
1209    let Some(command) = command.map(str::trim).filter(|text| !text.is_empty()) else {
1210        return Err(ComputerError::ConfirmRequired(format!(
1211            "{} requires a confirm hook ({})",
1212            document.action, document.reason
1213        )));
1214    };
1215    let (program, args) = split_command(command)?;
1216    let payload = serde_json::json!({
1217        "action": document.action,
1218        "app": {
1219            "bundle_id": document.bundle_id,
1220            "path": document.path,
1221        },
1222        "pid": document.pid,
1223        "window_id": document.window_id,
1224        "element": document.element,
1225        "reason": document.reason,
1226    });
1227    let bytes = serde_json::to_vec(&payload).map_err(|error| {
1228        ComputerError::ExecutionFailed(format!("confirm document encode failed: {error}"))
1229    })?;
1230    let timeout = std::time::Duration::from_secs(timeout_secs.clamp(1, 120));
1231    let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
1232    match crate::backend::run_with_stdin(&program, &arg_refs, &bytes, timeout) {
1233        Ok(output) if output.status.success() => Ok(()),
1234        Ok(output) => Err(ComputerError::ConfirmDenied(format!(
1235            "confirm hook exited {}",
1236            output
1237                .status
1238                .code()
1239                .map(|code| code.to_string())
1240                .unwrap_or_else(|| "without a status".into())
1241        ))),
1242        Err(error) => Err(ComputerError::ConfirmDenied(error)),
1243    }
1244}
1245
1246/// Split a command into a program and arguments. Quotes may use `"` or `'`.
1247pub fn split_command(input: &str) -> Result<(String, Vec<String>), ComputerError> {
1248    let mut tokens = Vec::new();
1249    let mut current = String::new();
1250    let mut quote: Option<char> = None;
1251    let mut any = false;
1252    for ch in input.chars() {
1253        match quote {
1254            Some(mark) if ch == mark => quote = None,
1255            Some(_) => current.push(ch),
1256            None if ch == '"' || ch == '\'' => quote = Some(ch),
1257            None if ch.is_whitespace() => {
1258                if any {
1259                    tokens.push(std::mem::take(&mut current));
1260                    any = false;
1261                }
1262            }
1263            None => {
1264                current.push(ch);
1265                any = true;
1266            }
1267        }
1268    }
1269    if quote.is_some() {
1270        return Err(ComputerError::InvalidArguments(
1271            "confirm command has an unmatched quote".into(),
1272        ));
1273    }
1274    if any {
1275        tokens.push(current);
1276    }
1277    let mut tokens = tokens.into_iter();
1278    let program = tokens
1279        .next()
1280        .ok_or_else(|| ComputerError::InvalidArguments("confirm command is empty".into()))?;
1281    Ok((program, tokens.collect()))
1282}
1283
1284/// Click or `AXPress` titles that need confirmation.
1285pub fn sensitive_title(title: &str) -> bool {
1286    title
1287        .split(|ch: char| !ch.is_ascii_alphanumeric())
1288        .any(|word| {
1289            matches!(
1290                word.to_ascii_lowercase().as_str(),
1291                "delete" | "erase" | "pay" | "purchase" | "send" | "submit"
1292            )
1293        })
1294}
1295
1296/// `cmd+q` and `alt+f4`.
1297pub fn key_needs_confirm(keys: &str) -> bool {
1298    let Ok(combos) = crate::keys::parse_key_sequence(keys) else {
1299        return false;
1300    };
1301    combos.iter().any(|combo| {
1302        let command =
1303            combo.modifiers.len() == 1 && combo.modifiers[0] == crate::types::Modifier::Command;
1304        let alt = combo.modifiers.len() == 1 && combo.modifiers[0] == crate::types::Modifier::Alt;
1305        (command && combo.key.eq_ignore_ascii_case("q"))
1306            || (alt && combo.key.eq_ignore_ascii_case("f4"))
1307    })
1308}
1309
1310/// Safari, Chrome, Chromium, Edge, Brave, Arc, and Firefox.
1311pub fn is_browser(bundle_id: Option<&str>, path: Option<&Path>, name: Option<&str>) -> bool {
1312    if bundle_id.is_some_and(|id| {
1313        BROWSER_BUNDLES
1314            .iter()
1315            .any(|browser| browser.eq_ignore_ascii_case(id))
1316    }) {
1317        return true;
1318    }
1319    if let Some(path) = path {
1320        if let Some(file) = path.file_name().and_then(|file| file.to_str()) {
1321            if BROWSER_EXES
1322                .iter()
1323                .any(|exe| exe.eq_ignore_ascii_case(file))
1324            {
1325                return true;
1326            }
1327        }
1328    }
1329    name.is_some_and(|name| {
1330        BROWSER_EXES
1331            .iter()
1332            .any(|exe| exe.eq_ignore_ascii_case(name.trim()))
1333            || name.eq_ignore_ascii_case("Google Chrome")
1334            || name.eq_ignore_ascii_case("Safari")
1335            || name.eq_ignore_ascii_case("Firefox")
1336            || name.eq_ignore_ascii_case("Microsoft Edge")
1337            || name.eq_ignore_ascii_case("Brave Browser")
1338            || name.eq_ignore_ascii_case("Arc")
1339    })
1340}
1341
1342/// First `http://`, `https://`, or `file://` token in `text`.
1343pub fn url_in_text(text: &str) -> Option<&str> {
1344    for token in text.split_whitespace() {
1345        let token =
1346            token.trim_matches(|ch: char| matches!(ch, '"' | '\'' | '<' | '>' | '(' | ')' | ','));
1347        if token.starts_with("https://")
1348            || token.starts_with("http://")
1349            || token.starts_with("file://")
1350        {
1351            return Some(token);
1352        }
1353    }
1354    None
1355}
1356
1357/// Pattern that matches `url`, if any. `*` matches any span, including empty.
1358///
1359/// A pattern that ends in `/*` also matches the same URL with no trailing slash.
1360/// P10 calls this before CDP navigation.
1361pub fn url_blocked<'a>(url: &str, patterns: &'a [String]) -> Option<&'a str> {
1362    patterns.iter().find_map(|pattern| {
1363        if glob_match(pattern, url) || slash_star_origin(pattern, url) {
1364            Some(pattern.as_str())
1365        } else {
1366            None
1367        }
1368    })
1369}
1370
1371fn slash_star_origin(pattern: &str, url: &str) -> bool {
1372    let Some(origin) = pattern.strip_suffix("/*") else {
1373        return false;
1374    };
1375    url == origin || url.starts_with(&format!("{origin}?"))
1376}
1377
1378fn glob_match(pattern: &str, text: &str) -> bool {
1379    let parts: Vec<&str> = pattern.split('*').collect();
1380    if parts.len() == 1 {
1381        return pattern == text;
1382    }
1383    if !text.starts_with(parts[0]) {
1384        return false;
1385    }
1386    let mut rest = &text[parts[0].len()..];
1387    for (index, part) in parts.iter().enumerate().skip(1) {
1388        if part.is_empty() {
1389            if index + 1 == parts.len() {
1390                return true;
1391            }
1392            continue;
1393        }
1394        if index + 1 == parts.len() {
1395            return rest.ends_with(part);
1396        }
1397        match rest.find(part) {
1398            Some(pos) => rest = &rest[pos + part.len()..],
1399            None => return false,
1400        }
1401    }
1402    true
1403}
1404
1405std::thread_local! {
1406    static TEST_PAUSED: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
1407}
1408
1409/// Clears a test pause override when dropped.
1410pub struct PauseOverride {
1411    previous: Option<bool>,
1412}
1413
1414impl Drop for PauseOverride {
1415    fn drop(&mut self) {
1416        let previous = self.previous.take();
1417        TEST_PAUSED.with(|slot| slot.set(previous));
1418    }
1419}
1420
1421/// Force [`is_paused`] on this thread until the guard drops.
1422///
1423/// This does not create an event tap and does not set the process-wide flag.
1424pub fn override_paused(paused: bool) -> PauseOverride {
1425    let previous = TEST_PAUSED.with(|slot| slot.replace(Some(paused)));
1426    PauseOverride { previous }
1427}
1428
1429/// Whether mutating actions must return `paused`.
1430///
1431/// The pause file is the cross-process source of truth. The atomic flag covers
1432/// this process when the file could not be written. Tests consult the
1433/// thread-local override first, then the file, and never the real home directory.
1434pub fn is_paused() -> bool {
1435    #[cfg(test)]
1436    if let Some(paused) = TEST_PAUSED.with(|slot| slot.get()) {
1437        return paused;
1438    }
1439    if pause_file_present() {
1440        return true;
1441    }
1442    paused_without_file()
1443}
1444
1445#[cfg(test)]
1446fn paused_without_file() -> bool {
1447    false
1448}
1449
1450#[cfg(not(test))]
1451fn paused_without_file() -> bool {
1452    if FILE_IO_OK.load(std::sync::atomic::Ordering::Acquire) {
1453        PAUSED.store(false, std::sync::atomic::Ordering::Release);
1454        false
1455    } else {
1456        PAUSED.load(std::sync::atomic::Ordering::Acquire)
1457    }
1458}
1459
1460#[cfg(not(test))]
1461static PAUSED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
1462static FILE_IO_OK: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
1463
1464/// Set or clear the pause flag and the `paused` file next to the audit log.
1465pub fn set_paused(paused: bool) -> Result<(), ComputerError> {
1466    #[cfg(not(test))]
1467    PAUSED.store(paused, std::sync::atomic::Ordering::Release);
1468    match write_pause_file(paused) {
1469        Ok(()) => {
1470            FILE_IO_OK.store(true, std::sync::atomic::Ordering::Release);
1471            Ok(())
1472        }
1473        Err(error) => {
1474            FILE_IO_OK.store(false, std::sync::atomic::Ordering::Release);
1475            Err(error)
1476        }
1477    }
1478}
1479
1480fn pause_file_present() -> bool {
1481    crate::audit::state_dir()
1482        .map(|dir| dir.join("paused").is_file())
1483        .unwrap_or(false)
1484}
1485
1486fn write_pause_file(paused: bool) -> Result<(), ComputerError> {
1487    let Some(dir) = crate::audit::state_dir() else {
1488        return Ok(());
1489    };
1490    std::fs::create_dir_all(&dir).map_err(ComputerError::Io)?;
1491    #[cfg(unix)]
1492    {
1493        use std::os::unix::fs::PermissionsExt;
1494        let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
1495    }
1496    let path = dir.join("paused");
1497    if paused {
1498        let mut options = std::fs::OpenOptions::new();
1499        options.write(true).create(true).truncate(true);
1500        #[cfg(unix)]
1501        {
1502            use std::os::unix::fs::OpenOptionsExt;
1503            options.mode(0o600);
1504            #[cfg(target_os = "linux")]
1505            options.custom_flags(0x20000);
1506            #[cfg(target_os = "macos")]
1507            options.custom_flags(0x0100);
1508        }
1509        options.open(&path).map_err(ComputerError::Io)?;
1510        Ok(())
1511    } else if path.exists() {
1512        std::fs::remove_file(&path).map_err(ComputerError::Io)
1513    } else {
1514        Ok(())
1515    }
1516}
1517
1518#[cfg(test)]
1519mod tests {
1520    use super::*;
1521    #[cfg(unix)]
1522    use std::fs;
1523    use std::path::PathBuf;
1524
1525    #[test]
1526    fn terminal_bundle_id_and_fixture_path_are_denied() {
1527        let by_id = identity_denial(Some("com.apple.Terminal"), None, None, None).unwrap();
1528        assert_eq!(by_id.source, "bundle");
1529        assert!(by_id.message.contains("com.apple.Terminal"));
1530
1531        let path = Path::new("/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal");
1532        let by_path = identity_denial(None, Some(path), None, None).unwrap();
1533        assert_eq!(by_path.source, "path");
1534        assert!(by_path.message.contains("Terminal.app"));
1535
1536        let selector = AppSelector::BundleId("com.apple.Terminal".into());
1537        assert!(selector_denial(&selector).is_some());
1538        let named = AppSelector::Name(r#"Foo "Bar""#.into());
1539        assert!(selector_denial(&named).is_none());
1540
1541        assert!(identity_denial(Some("app.crynta.terax"), None, None, None).is_some());
1542        assert!(selector_denial(&AppSelector::Name("Terax".into())).is_some());
1543        let terax = Path::new("/Applications/Terax.app/Contents/MacOS/Terax");
1544        assert!(identity_denial(None, Some(terax), None, None).is_some());
1545    }
1546
1547    #[test]
1548    fn every_listed_bundle_and_exe_is_denied_and_notes_is_not() {
1549        for bundle_id in DENIED_BUNDLE_IDS {
1550            assert!(
1551                identity_denial(Some(bundle_id), None, None, None).is_some(),
1552                "{bundle_id}"
1553            );
1554        }
1555        for exe in DENIED_EXE_NAMES {
1556            let path = PathBuf::from("/usr/bin").join(exe);
1557            assert!(
1558                identity_denial(None, Some(&path), None, None).is_some(),
1559                "{exe}"
1560            );
1561        }
1562        assert!(identity_denial(
1563            Some("com.apple.Notes"),
1564            Some(Path::new(
1565                "/System/Applications/Notes.app/Contents/MacOS/Notes"
1566            )),
1567            None,
1568            None
1569        )
1570        .is_none());
1571        assert!(identity_denial(
1572            None,
1573            Some(Path::new("/usr/local/bin/TerminalHelper")),
1574            None,
1575            None
1576        )
1577        .is_none());
1578    }
1579
1580    #[test]
1581    fn system_settings_privacy_window_is_denied_and_other_windows_are_not() {
1582        assert!(identity_denial(
1583            Some("com.apple.systempreferences"),
1584            Some(Path::new(
1585                "/System/Applications/System Settings.app/Contents/MacOS/System Settings"
1586            )),
1587            Some("Privacy & Security"),
1588            None,
1589        )
1590        .is_some());
1591        assert!(identity_denial(
1592            Some("com.apple.systempreferences"),
1593            None,
1594            Some("Displays"),
1595            Some("AXIdentifier"),
1596        )
1597        .is_none());
1598        assert!(identity_denial(Some("com.apple.systempreferences"), None, None, None).is_none());
1599        assert!(identity_denial(
1600            Some("com.apple.Settings"),
1601            None,
1602            Some("Privacy & Security"),
1603            None,
1604        )
1605        .is_some());
1606        assert!(
1607            identity_denial(Some("com.apple.Settings"), None, Some("Displays"), None).is_none()
1608        );
1609        assert!(identity_denial(
1610            Some("com.apple.systempreferences"),
1611            None,
1612            None,
1613            Some("Privacy_Security"),
1614        )
1615        .is_some());
1616    }
1617
1618    #[test]
1619    fn powershell_cmd_and_wt_paths_are_denied() {
1620        for exe in ["powershell.exe", "cmd.exe", "wt.exe", "gnome-terminal"] {
1621            let path = PathBuf::from(r"C:\Windows\System32").join(exe);
1622            assert!(path_denial(&path).is_some(), "{exe}");
1623        }
1624    }
1625
1626    #[test]
1627    fn quit_protects_loginwindow_windowserver_and_self() {
1628        assert!(quit_is_protected(9, 9, None, "computer-use", None));
1629        assert!(quit_is_protected(
1630            2,
1631            9,
1632            Some("com.apple.loginwindow"),
1633            "loginwindow",
1634            None
1635        ));
1636        assert!(quit_is_protected(3, 9, None, "WindowServer", None));
1637        assert!(!quit_is_protected(
1638            10,
1639            9,
1640            Some("com.apple.Notes"),
1641            "Notes",
1642            Some("/System/Applications/Notes.app/Contents/MacOS/Notes")
1643        ));
1644    }
1645
1646    #[cfg(unix)]
1647    #[test]
1648    fn symlink_to_a_terminal_executable_is_denied() {
1649        let dir = std::env::temp_dir().join(format!(
1650            "computer-use-policy-{}-{}",
1651            std::process::id(),
1652            std::time::SystemTime::now()
1653                .duration_since(std::time::UNIX_EPOCH)
1654                .map(|duration| duration.as_nanos())
1655                .unwrap_or(0)
1656        ));
1657        let _ = fs::remove_dir_all(&dir);
1658        fs::create_dir_all(&dir).unwrap();
1659        let real = dir.join("Terminal");
1660        fs::write(&real, b"fixture").unwrap();
1661        let link = dir.join("alias");
1662        std::os::unix::fs::symlink(&real, &link).unwrap();
1663        let canonical = fs::canonicalize(&link).unwrap();
1664        assert_eq!(
1665            canonical.file_name().and_then(|name| name.to_str()),
1666            Some("Terminal")
1667        );
1668        assert!(identity_denial(None, Some(&canonical), None, None).is_some());
1669        let user = dir.join("user.toml");
1670        std::fs::write(&user, "version = 1\n[[allow]]\npath = \"alias\"\n").unwrap();
1671        // The allow path is relative to the process, so point it at the symlink itself.
1672        let user_text = format!("version = 1\n[[allow]]\npath = \"{}\"\n", link.display());
1673        std::fs::write(&user, user_text).unwrap();
1674        let policy = load_paths(Some(&user), None).unwrap();
1675        let decision = decide(
1676            &policy,
1677            &Subject {
1678                path: Some(link.display().to_string()),
1679                ..Subject::default()
1680            },
1681            &PolicyAction::Mutate,
1682        );
1683        assert!(
1684            matches!(
1685                decision,
1686                Decision::Deny {
1687                    source: "builtin",
1688                    ..
1689                }
1690            ),
1691            "{decision:?}"
1692        );
1693        let _ = fs::remove_dir_all(&dir);
1694    }
1695
1696    fn temp_dir(label: &str) -> PathBuf {
1697        let dir = std::env::temp_dir().join(format!(
1698            "computer-use-{label}-{}-{}",
1699            std::process::id(),
1700            std::time::SystemTime::now()
1701                .duration_since(std::time::UNIX_EPOCH)
1702                .map(|duration| duration.as_nanos())
1703                .unwrap_or(0)
1704        ));
1705        let _ = std::fs::remove_dir_all(&dir);
1706        std::fs::create_dir_all(&dir).unwrap();
1707        dir
1708    }
1709
1710    fn subject_bundle(bundle: &str) -> Subject {
1711        Subject {
1712            bundle_id: Some(bundle.into()),
1713            ..Subject::default()
1714        }
1715    }
1716
1717    #[test]
1718    fn default_deny_allows_only_listed_apps() {
1719        let dir = temp_dir("default-deny");
1720        let user = dir.join("user.toml");
1721        std::fs::write(
1722            &user,
1723            "version = 1\ndefault = \"deny\"\n\n[[allow]]\nbundle_id = \"com.apple.Notes\"\n",
1724        )
1725        .unwrap();
1726        let policy = load_paths(Some(&user), None).unwrap();
1727        assert!(matches!(
1728            app_verdict(&policy, &subject_bundle("com.apple.Notes")),
1729            Decision::Allow { source: "user" }
1730        ));
1731        assert!(matches!(
1732            app_verdict(&policy, &subject_bundle("com.apple.Finder")),
1733            Decision::Deny { source: "user", .. }
1734        ));
1735        let _ = std::fs::remove_dir_all(&dir);
1736    }
1737
1738    #[test]
1739    fn admin_deny_beats_user_allow_and_builtin_beats_user_allow() {
1740        let dir = temp_dir("merge");
1741        let admin = dir.join("admin.toml");
1742        let user = dir.join("user.toml");
1743        std::fs::write(
1744            &admin,
1745            "version = 1\n[[deny]]\nbundle_id = \"com.apple.Notes\"\n",
1746        )
1747        .unwrap();
1748        std::fs::write(
1749            &user,
1750            "version = 1\n[[allow]]\nbundle_id = \"com.apple.Notes\"\n[[allow]]\nbundle_id = \"com.apple.Terminal\"\n",
1751        )
1752        .unwrap();
1753        let policy = load_paths(Some(&user), Some(&admin)).unwrap();
1754        assert!(matches!(
1755            app_verdict(&policy, &subject_bundle("com.apple.Notes")),
1756            Decision::Deny {
1757                source: "admin",
1758                ..
1759            }
1760        ));
1761        assert!(matches!(
1762            app_verdict(&policy, &subject_bundle("com.apple.Terminal")),
1763            Decision::Deny {
1764                source: "builtin",
1765                ..
1766            }
1767        ));
1768        let _ = std::fs::remove_dir_all(&dir);
1769    }
1770
1771    #[test]
1772    fn glob_blocks_accounts_host_and_allows_example() {
1773        let patterns = vec!["https://accounts.example.com/*".to_string()];
1774        assert_eq!(
1775            url_blocked("https://accounts.example.com/login", &patterns),
1776            Some("https://accounts.example.com/*")
1777        );
1778        assert!(url_blocked("https://accounts.example.com/", &patterns).is_some());
1779        assert!(url_blocked("https://accounts.example.com", &patterns).is_some());
1780        assert!(url_blocked("https://example.com/", &patterns).is_none());
1781        assert!(url_blocked("https://example.com/accounts.example.com/", &patterns).is_none());
1782    }
1783
1784    #[test]
1785    fn user_parse_error_names_the_line_and_admin_fails_closed() {
1786        let dir = temp_dir("parse");
1787        let user = dir.join("user.toml");
1788        std::fs::write(&user, "version = 1\n\ndefault = \"sometimes\"\n").unwrap();
1789        let error = load_paths(Some(&user), None).unwrap_err();
1790        assert_eq!(error.code(), "invalid_arguments");
1791        assert!(error.to_string().contains("line "), "{error}");
1792        let admin = dir.join("admin.toml");
1793        std::fs::write(&admin, "version = 1\nnope = true\n").unwrap();
1794        let error = load_paths(None, Some(&admin)).unwrap_err();
1795        assert_eq!(error.code(), "policy_denied");
1796        assert!(
1797            error.to_string().contains(&admin.display().to_string()),
1798            "{error}"
1799        );
1800        let _ = std::fs::remove_dir_all(&dir);
1801    }
1802
1803    #[test]
1804    fn confirm_hook_exit_codes() {
1805        let dir = temp_dir("confirm");
1806        let document = ConfirmDocument {
1807            action: "quit",
1808            bundle_id: Some("com.apple.Notes"),
1809            path: None,
1810            pid: Some(10),
1811            window_id: None,
1812            element: None,
1813            reason: "quit",
1814        };
1815        #[cfg(unix)]
1816        let (allow, deny) = {
1817            let allow = dir.join("allow.sh");
1818            let deny = dir.join("deny.sh");
1819            std::fs::write(&allow, "#!/bin/sh\ncat >/dev/null\nexit 0\n").unwrap();
1820            std::fs::write(&deny, "#!/bin/sh\ncat >/dev/null\nexit 1\n").unwrap();
1821            #[cfg(unix)]
1822            {
1823                use std::os::unix::fs::PermissionsExt;
1824                std::fs::set_permissions(&allow, std::fs::Permissions::from_mode(0o700)).unwrap();
1825                std::fs::set_permissions(&deny, std::fs::Permissions::from_mode(0o700)).unwrap();
1826            }
1827            (allow.display().to_string(), deny.display().to_string())
1828        };
1829        #[cfg(windows)]
1830        let (allow, deny) = ("cmd /C exit 0".to_string(), "cmd /C exit 1".to_string());
1831        assert!(run_confirm(Some(&allow), 30, &document).is_ok());
1832        let denied = run_confirm(Some(&deny), 30, &document).unwrap_err();
1833        assert_eq!(denied.code(), "confirm_denied");
1834        let missing = run_confirm(None, 30, &document).unwrap_err();
1835        assert_eq!(missing.code(), "confirm_required");
1836        let _ = std::fs::remove_dir_all(&dir);
1837    }
1838
1839    #[test]
1840    fn sensitive_titles_and_quit_shortcuts() {
1841        assert!(sensitive_title("Delete Note"));
1842        assert!(sensitive_title("SEND"));
1843        assert!(!sensitive_title("Save"));
1844        assert!(key_needs_confirm("cmd+q"));
1845        assert!(key_needs_confirm("alt+f4"));
1846        assert!(!key_needs_confirm("cmd+s"));
1847        assert!(!key_needs_confirm("meta+q"));
1848        assert!(!event_is_synthetic(1));
1849        assert!(event_is_synthetic(SYNTHETIC_EVENT_USER_DATA));
1850    }
1851}