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