1use 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
16const 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
40const 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
79const 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
102const 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#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct Denial {
130 pub source: &'static str,
132 pub message: String,
134}
135
136pub 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
161pub 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
194pub 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
202pub 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
287pub const SYNTHETIC_EVENT_USER_DATA: i64 = 0x00C0_5EC0_0DE1;
292
293pub 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#[derive(Debug, Clone, PartialEq, Eq)]
329pub enum PolicyAction {
330 Inspect,
332 Always,
334 Benign,
336 Observe,
338 Mutate,
340 Quit,
342 Key {
344 combo: String,
346 },
347 ClipboardSet,
349}
350
351#[derive(Debug, Clone, PartialEq, Eq)]
353pub enum Decision {
354 Allow {
356 source: &'static str,
358 },
359 Deny {
361 source: &'static str,
363 message: String,
365 },
366 Confirm {
368 reason: String,
370 },
371}
372
373#[derive(Debug, Clone, Default, PartialEq, Eq)]
375pub struct Subject {
376 pub name: Option<String>,
378 pub bundle_id: Option<String>,
380 pub path: Option<String>,
382 pub pid: Option<i32>,
384 pub window_id: Option<u32>,
386 pub window_title: Option<String>,
388 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#[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 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 pub fn blocked_urls(&self) -> &[String] {
439 &self.blocked_urls
440 }
441
442 pub fn timeout_secs(&self) -> u64 {
444 self.timeout_secs.clamp(1, 120)
445 }
446}
447
448pub 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
492pub 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 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
734pub 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
741pub 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 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
1048pub 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#[derive(Debug, serde::Serialize)]
1069pub struct ConfirmDocument<'a> {
1070 pub action: &'a str,
1072 pub bundle_id: Option<&'a str>,
1074 pub path: Option<&'a str>,
1076 pub pid: Option<i32>,
1078 pub window_id: Option<u32>,
1080 pub element: Option<u32>,
1082 pub reason: &'a str,
1084}
1085
1086pub 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
1131pub 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
1169pub 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
1181pub 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
1195pub 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
1227pub 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
1242pub 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
1294pub 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
1306pub fn override_paused(paused: bool) -> PauseOverride {
1310 let previous = TEST_PAUSED.with(|slot| slot.replace(Some(paused)));
1311 PauseOverride { previous }
1312}
1313
1314pub 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
1349pub 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 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}