1use 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
17static 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
30pub 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
39pub fn builtin_exemptions() -> Vec<String> {
41 exempt_registry()
42 .read()
43 .unwrap_or_else(|error| error.into_inner())
44 .clone()
45}
46
47fn 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
58fn 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
77const 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
101const 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
140const 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
163const 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#[derive(Debug, Clone, PartialEq, Eq)]
190pub struct Denial {
191 pub source: &'static str,
193 pub message: String,
195}
196
197pub 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
222pub 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
273pub 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
281pub 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
373pub const SYNTHETIC_EVENT_USER_DATA: i64 = 0x00C0_5EC0_0DE1;
378
379pub 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#[derive(Debug, Clone, PartialEq, Eq)]
415pub enum PolicyAction {
416 Inspect,
418 Always,
420 Benign,
422 Observe,
424 Mutate,
426 Quit,
428 Key {
430 combo: String,
432 },
433 ClipboardSet,
435}
436
437#[derive(Debug, Clone, PartialEq, Eq)]
439pub enum Decision {
440 Allow {
442 source: &'static str,
444 },
445 Deny {
447 source: &'static str,
449 message: String,
451 },
452 Confirm {
454 reason: String,
456 },
457}
458
459#[derive(Debug, Clone, Default, PartialEq, Eq)]
461pub struct Subject {
462 pub name: Option<String>,
464 pub bundle_id: Option<String>,
466 pub path: Option<String>,
468 pub pid: Option<i32>,
470 pub window_id: Option<u32>,
472 pub window_title: Option<String>,
474 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#[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 builtin_exempt: Vec<String>,
505 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 pub fn blocked_urls(&self) -> &[String] {
528 &self.blocked_urls
529 }
530
531 pub fn builtin_exempt(&self) -> &[String] {
533 &self.builtin_exempt
534 }
535
536 pub fn timeout_secs(&self) -> u64 {
538 self.timeout_secs.clamp(1, 120)
539 }
540}
541
542pub 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
586pub 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 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
828pub 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
835pub 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 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#[derive(Debug, serde::Deserialize)]
1129#[serde(deny_unknown_fields)]
1130struct BuiltinDenySection {
1131 #[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
1163pub 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#[derive(Debug, serde::Serialize)]
1184pub struct ConfirmDocument<'a> {
1185 pub action: &'a str,
1187 pub bundle_id: Option<&'a str>,
1189 pub path: Option<&'a str>,
1191 pub pid: Option<i32>,
1193 pub window_id: Option<u32>,
1195 pub element: Option<u32>,
1197 pub reason: &'a str,
1199}
1200
1201pub 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
1246pub 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
1284pub 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
1296pub 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
1310pub 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
1342pub 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
1357pub 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
1409pub 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
1421pub fn override_paused(paused: bool) -> PauseOverride {
1425 let previous = TEST_PAUSED.with(|slot| slot.replace(Some(paused)));
1426 PauseOverride { previous }
1427}
1428
1429pub 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
1464pub 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 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}