use std::path::Path;
use crate::error::ComputerError;
use crate::target::AppSelector;
const SYSTEM_SETTINGS_BUNDLES: &[&str] = &["com.apple.systempreferences", "com.apple.Settings"];
const DENIED_BUNDLE_IDS: &[&str] = &[
"com.apple.Terminal",
"com.googlecode.iterm2",
"dev.warp.Warp-Stable",
"org.alacritty",
"net.kovidgoyal.kitty",
"com.mitchellh.ghostty",
"com.github.wez.wezterm",
"co.zeit.hyper",
"com.1password.1password",
"com.agilebits.onepassword7",
"com.bitwarden.desktop",
"org.keepassxc.KeePassXC",
"com.dashlane.Dashlane",
"in.sinew.Enpass-Desktop",
"com.lastpass.LastPass",
"com.lastpass.lastpassmacdesktop",
"com.apple.Passwords",
"com.apple.keychainaccess",
"Microsoft.WindowsTerminal",
"app.crynta.terax",
];
const DENIED_EXE_NAMES: &[&str] = &[
"Terminal",
"iTerm2",
"Warp",
"Alacritty",
"kitty",
"ghostty",
"wezterm",
"wezterm-gui",
"Hyper",
"hyper",
"powershell.exe",
"cmd.exe",
"wt.exe",
"WindowsTerminal.exe",
"gnome-terminal",
"konsole",
"xterm",
"1Password",
"1Password.exe",
"Bitwarden",
"Bitwarden.exe",
"KeePassXC",
"KeePassXC.exe",
"KeePass.exe",
"Dashlane",
"Dashlane.exe",
"Enpass",
"Enpass.exe",
"LastPass",
"lastpass",
"LastPass.exe",
"Passwords",
"Passwords.exe",
"Keychain Access",
"Terax",
];
const DENIED_APP_BUNDLES: &[&str] = &[
"Terminal.app",
"iTerm.app",
"iTerm2.app",
"Warp.app",
"Alacritty.app",
"kitty.app",
"Ghostty.app",
"WezTerm.app",
"Hyper.app",
"1Password.app",
"1Password 7.app",
"Bitwarden.app",
"KeePassXC.app",
"Dashlane.app",
"Enpass.app",
"LastPass.app",
"Passwords.app",
"Keychain Access.app",
"Terax.app",
];
const DENIED_DISPLAY_NAMES: &[&str] = &[
"Terminal",
"iTerm",
"iTerm2",
"Warp",
"Alacritty",
"kitty",
"Ghostty",
"WezTerm",
"Hyper",
"1Password",
"1Password 7",
"Bitwarden",
"KeePassXC",
"Dashlane",
"Enpass",
"LastPass",
"Passwords",
"Keychain Access",
"Windows Terminal",
"Terax",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Denial {
pub source: &'static str,
pub message: String,
}
pub fn selector_denial(selector: &AppSelector) -> Option<Denial> {
match selector {
AppSelector::Pid(_) => None,
AppSelector::BundleId(bundle_id) => bundle_denial(bundle_id).map(|message| Denial {
source: "bundle",
message,
}),
AppSelector::Name(name) => {
if denied_display_name(name) {
Some(Denial {
source: "name",
message: format!("built-in deny list refuses {name}"),
})
} else {
None
}
}
AppSelector::Path(path) => path_denial(path).map(|message| Denial {
source: "path",
message,
}),
}
}
pub fn identity_denial(
bundle_id: Option<&str>,
exe_path: Option<&Path>,
window_title: Option<&str>,
ax_identifier: Option<&str>,
) -> Option<Denial> {
if let Some(bundle_id) = bundle_id {
if let Some(message) = bundle_denial(bundle_id) {
return Some(Denial {
source: "bundle",
message,
});
}
if is_system_settings(bundle_id) && privacy_or_security(window_title, ax_identifier) {
return Some(Denial {
source: "system-settings",
message: "built-in deny list refuses System Settings while the targeted window title or identifier contains Privacy or Security".into(),
});
}
}
if let Some(path) = exe_path {
if let Some(message) = path_denial(path) {
return Some(Denial {
source: "path",
message,
});
}
}
None
}
pub fn deny_or_allow(denial: Option<Denial>) -> Result<(), ComputerError> {
match denial {
None => Ok(()),
Some(denial) => Err(ComputerError::PolicyDenied(denial.message)),
}
}
pub fn quit_is_protected(
pid: i32,
current_pid: i32,
bundle_id: Option<&str>,
name: &str,
path: Option<&str>,
) -> bool {
if pid == current_pid {
return true;
}
if bundle_id.is_some_and(|id| id.eq_ignore_ascii_case("com.apple.loginwindow")) {
return true;
}
let name = name.trim();
if name.eq_ignore_ascii_case("loginwindow") || name.eq_ignore_ascii_case("WindowServer") {
return true;
}
path.is_some_and(|path| {
Path::new(path)
.file_name()
.and_then(|file| file.to_str())
.is_some_and(|file| {
file.eq_ignore_ascii_case("loginwindow")
|| file.eq_ignore_ascii_case("WindowServer")
})
})
}
fn bundle_denial(bundle_id: &str) -> Option<String> {
DENIED_BUNDLE_IDS
.iter()
.find(|id| id.eq_ignore_ascii_case(bundle_id))
.map(|id| format!("built-in deny list refuses {id}"))
}
fn path_denial(path: &Path) -> Option<String> {
if let Some(file) = path.file_name().and_then(|file| file.to_str()) {
if DENIED_EXE_NAMES
.iter()
.any(|name| name.eq_ignore_ascii_case(file))
{
return Some(format!(
"built-in deny list refuses executable {}",
path.display()
));
}
}
for component in path.components() {
let text = component.as_os_str().to_string_lossy();
if DENIED_APP_BUNDLES
.iter()
.any(|name| text.eq_ignore_ascii_case(name))
{
return Some(format!(
"built-in deny list refuses path {}",
path.display()
));
}
}
None
}
fn denied_display_name(name: &str) -> bool {
DENIED_DISPLAY_NAMES
.iter()
.any(|denied| denied.eq_ignore_ascii_case(name.trim()))
}
fn is_system_settings(bundle_id: &str) -> bool {
SYSTEM_SETTINGS_BUNDLES
.iter()
.any(|id| id.eq_ignore_ascii_case(bundle_id))
}
fn privacy_or_security(window_title: Option<&str>, ax_identifier: Option<&str>) -> bool {
[window_title, ax_identifier]
.into_iter()
.flatten()
.any(|text| {
let lower = text.to_ascii_lowercase();
lower.contains("privacy") || lower.contains("security")
})
}
pub const SYNTHETIC_EVENT_USER_DATA: i64 = 0x00C0_5EC0_0DE1;
pub fn event_is_synthetic(user_data: i64) -> bool {
user_data == SYNTHETIC_EVENT_USER_DATA
}
const BROWSER_BUNDLES: &[&str] = &[
"com.apple.Safari",
"com.google.Chrome",
"com.google.Chrome.canary",
"org.chromium.Chromium",
"com.microsoft.edgemac",
"com.microsoft.Edge",
"com.brave.Browser",
"company.thebrowser.Browser",
"org.mozilla.firefox",
];
const BROWSER_EXES: &[&str] = &[
"Safari",
"Google Chrome",
"Chromium",
"chrome",
"chrome.exe",
"chromium",
"msedge",
"msedge.exe",
"Microsoft Edge",
"brave",
"Brave Browser",
"Arc",
"firefox",
"firefox.exe",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PolicyAction {
Inspect,
Always,
Benign,
Observe,
Mutate,
Quit,
Key {
combo: String,
},
ClipboardSet,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Decision {
Allow {
source: &'static str,
},
Deny {
source: &'static str,
message: String,
},
Confirm {
reason: String,
},
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Subject {
pub name: Option<String>,
pub bundle_id: Option<String>,
pub path: Option<String>,
pub pid: Option<i32>,
pub window_id: Option<u32>,
pub window_title: Option<String>,
pub ax_identifier: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Rule {
effect: RuleEffect,
bundle_id: Option<String>,
path: Option<String>,
name: Option<String>,
source: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RuleEffect {
Allow,
Deny,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoadedPolicy {
user_default_deny: bool,
admin_default_deny: bool,
rules: Vec<Rule>,
blocked_urls: Vec<String>,
user_confirm: Option<String>,
admin_confirm: Option<String>,
timeout_secs: u64,
pub pause_on_user_input: bool,
}
impl Default for LoadedPolicy {
fn default() -> Self {
Self {
user_default_deny: false,
admin_default_deny: false,
rules: Vec::new(),
blocked_urls: Vec::new(),
user_confirm: None,
admin_confirm: None,
timeout_secs: 30,
pause_on_user_input: false,
}
}
}
impl LoadedPolicy {
pub fn blocked_urls(&self) -> &[String] {
&self.blocked_urls
}
pub fn timeout_secs(&self) -> u64 {
self.timeout_secs.clamp(1, 120)
}
}
pub fn decide(policy: &LoadedPolicy, target: &Subject, action: &PolicyAction) -> Decision {
if matches!(
action,
PolicyAction::Always | PolicyAction::Benign | PolicyAction::Inspect
) {
return Decision::Allow { source: "default" };
}
let verdict = app_verdict(policy, target);
if let Decision::Deny { .. } = &verdict {
return verdict;
}
if unscoped_target(target)
&& matches!(
action,
PolicyAction::Mutate
| PolicyAction::Quit
| PolicyAction::Key { .. }
| PolicyAction::ClipboardSet
)
&& (policy.admin_default_deny || policy.user_default_deny)
{
let source = if policy.admin_default_deny {
"admin"
} else {
"user"
};
return Decision::Deny {
source,
message: format!("{source} policy default is deny and no app was named"),
};
}
if matches!(action, PolicyAction::Quit) && quit_protected(target) {
return Decision::Deny {
source: "builtin",
message: "refusing to quit loginwindow, WindowServer, or this process".into(),
};
}
if let Some(reason) = confirm_reason(action) {
return Decision::Confirm { reason };
}
verdict
}
pub fn app_verdict(policy: &LoadedPolicy, target: &Subject) -> Decision {
let canonical = target
.path
.as_deref()
.map(|path| crate::target::canonical_exe_path(Path::new(path)));
if let Some(denial) = identity_denial(
target.bundle_id.as_deref(),
canonical.as_deref(),
target.window_title.as_deref(),
target.ax_identifier.as_deref(),
) {
return Decision::Deny {
source: "builtin",
message: denial.message,
};
}
if target.bundle_id.is_none() && canonical.is_none() {
if let Some(name) = target.name.as_deref() {
if denied_display_name(name) {
return Decision::Deny {
source: "builtin",
message: format!("built-in deny list refuses {name}"),
};
}
}
}
let subject_path = canonical.unwrap_or_default();
if policy.rules.iter().any(|rule| {
rule.source == "admin"
&& rule.effect == RuleEffect::Deny
&& deny_matches(rule, target, &subject_path)
}) {
return Decision::Deny {
source: "admin",
message: "admin policy denies this app".into(),
};
}
if policy.rules.iter().any(|rule| {
rule.source == "user"
&& rule.effect == RuleEffect::Deny
&& deny_matches(rule, target, &subject_path)
}) {
return Decision::Deny {
source: "user",
message: "user policy denies this app".into(),
};
}
if let Some(message) = allow_path_mismatch(policy, target, &subject_path) {
return Decision::Deny {
source: "user",
message,
};
}
let named = !unscoped_target(target);
if policy.admin_default_deny && named {
return Decision::Deny {
source: "admin",
message: "admin policy default is deny".into(),
};
}
if user_allow_matches(policy, target, &subject_path) {
return Decision::Allow { source: "user" };
}
if policy.user_default_deny && named {
return Decision::Deny {
source: "user",
message: "user policy default is deny".into(),
};
}
Decision::Allow { source: "default" }
}
fn unscoped_target(target: &Subject) -> bool {
target.name.is_none()
&& target.bundle_id.is_none()
&& target.path.is_none()
&& target.pid.is_none()
}
fn quit_protected(target: &Subject) -> bool {
let Some(pid) = target.pid else {
return false;
};
quit_is_protected(
pid,
std::process::id() as i32,
target.bundle_id.as_deref(),
target.name.as_deref().unwrap_or(""),
target.path.as_deref(),
)
}
fn confirm_reason(action: &PolicyAction) -> Option<String> {
match action {
PolicyAction::Quit => Some("quit".into()),
PolicyAction::ClipboardSet => Some("clipboard_set".into()),
PolicyAction::Key { combo } if key_needs_confirm(combo) => {
Some(format!("key combination {combo}"))
}
_ => None,
}
}
fn deny_matches(rule: &Rule, target: &Subject, canonical: &Path) -> bool {
if let Some(bundle) = &rule.bundle_id {
if target
.bundle_id
.as_ref()
.is_some_and(|id| id.eq_ignore_ascii_case(bundle))
{
return true;
}
}
if let Some(path) = &rule.path {
if path_matches(canonical, path) {
return true;
}
}
if let Some(name) = &rule.name {
if target
.name
.as_ref()
.is_some_and(|value| value.eq_ignore_ascii_case(name))
{
return true;
}
}
false
}
fn user_allow_matches(policy: &LoadedPolicy, target: &Subject, canonical: &Path) -> bool {
policy.rules.iter().any(|rule| {
rule.source == "user"
&& rule.effect == RuleEffect::Allow
&& allow_matches(rule, target, canonical)
})
}
fn allow_matches(rule: &Rule, target: &Subject, canonical: &Path) -> bool {
let mut saw = false;
if let Some(bundle) = &rule.bundle_id {
saw = true;
if !target
.bundle_id
.as_ref()
.is_some_and(|id| id.eq_ignore_ascii_case(bundle))
{
return false;
}
}
if let Some(path) = &rule.path {
saw = true;
if !path_matches(canonical, path) {
return false;
}
}
if let Some(name) = &rule.name {
saw = true;
if !target
.name
.as_ref()
.is_some_and(|value| value.eq_ignore_ascii_case(name))
{
return false;
}
}
saw
}
fn allow_path_mismatch(
policy: &LoadedPolicy,
target: &Subject,
canonical: &Path,
) -> Option<String> {
let bundle = target.bundle_id.as_deref()?;
let constrained: Vec<&Rule> = policy
.rules
.iter()
.filter(|rule| {
rule.effect == RuleEffect::Allow
&& rule.source == "user"
&& rule.path.is_some()
&& rule
.bundle_id
.as_ref()
.is_some_and(|id| id.eq_ignore_ascii_case(bundle))
})
.collect();
if constrained.is_empty() {
return None;
}
if constrained
.iter()
.any(|rule| path_matches(canonical, rule.path.as_deref().unwrap_or("")))
{
return None;
}
if policy.rules.iter().any(|rule| {
rule.source == "user"
&& rule.effect == RuleEffect::Allow
&& rule.path.is_none()
&& rule
.bundle_id
.as_ref()
.is_some_and(|id| id.eq_ignore_ascii_case(bundle))
}) {
return None;
}
Some("allow.path does not match the canonical executable path".into())
}
fn path_matches(canonical: &Path, rule_path: &str) -> bool {
if canonical.as_os_str().is_empty() || rule_path.is_empty() {
return false;
}
let rule = Path::new(rule_path);
if canonical == rule {
return true;
}
let subject = normalize_path(canonical);
let allow = normalize_path(rule);
if subject.eq_ignore_ascii_case(&allow) {
return true;
}
let with_sep = if allow.ends_with('/') {
allow.clone()
} else {
format!("{allow}/")
};
subject
.to_ascii_lowercase()
.starts_with(&with_sep.to_ascii_lowercase())
}
fn normalize_path(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
pub fn load_policy(explicit: Option<&Path>) -> Result<LoadedPolicy, ComputerError> {
let user = user_policy_file(explicit)?;
let admin = admin_policy_file();
load_paths(user.as_deref(), admin.as_deref())
}
pub fn load_paths(
user: Option<&Path>,
admin: Option<&Path>,
) -> Result<LoadedPolicy, ComputerError> {
let mut policy = LoadedPolicy::default();
if let Some(path) = admin {
merge_file(&mut policy, path, PolicyOrigin::Admin)?;
}
if let Some(path) = user {
merge_file(&mut policy, path, PolicyOrigin::User)?;
}
policy.timeout_secs = policy.timeout_secs.min(120);
Ok(policy)
}
#[derive(Clone, Copy)]
enum PolicyOrigin {
User,
Admin,
}
fn user_policy_file(explicit: Option<&Path>) -> Result<Option<std::path::PathBuf>, ComputerError> {
if let Some(path) = explicit {
if !path.is_file() {
return Err(ComputerError::InvalidArguments(format!(
"policy file not found: {}",
path.display()
)));
}
return Ok(Some(path.to_path_buf()));
}
let Some(base) = user_config_base() else {
return Ok(None);
};
let path = base.join("computer-use").join("policy.toml");
if path.is_file() {
Ok(Some(path))
} else {
Ok(None)
}
}
fn user_config_base() -> Option<std::path::PathBuf> {
ambient_user_config_base()
}
#[cfg(test)]
fn ambient_user_config_base() -> Option<std::path::PathBuf> {
None
}
#[cfg(not(test))]
fn ambient_user_config_base() -> Option<std::path::PathBuf> {
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
if !xdg.is_empty() {
return Some(std::path::PathBuf::from(xdg));
}
}
dirs::home_dir().map(|home| home.join(".config"))
}
fn admin_policy_file() -> Option<std::path::PathBuf> {
ambient_admin_policy_file()
}
#[cfg(test)]
fn ambient_admin_policy_file() -> Option<std::path::PathBuf> {
None
}
#[cfg(not(test))]
fn ambient_admin_policy_file() -> Option<std::path::PathBuf> {
if let Some(overridden) = std::env::var_os("COMPUTER_USE_ADMIN_POLICY") {
let path = std::path::PathBuf::from(overridden);
return path.is_file().then_some(path);
}
let path = default_admin_path();
path.is_file().then_some(path)
}
#[cfg(all(not(test), windows))]
fn default_admin_path() -> std::path::PathBuf {
let base = std::env::var_os("ProgramData")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::PathBuf::from(r"C:\ProgramData"));
base.join("computer-use").join("policy.toml")
}
#[cfg(all(not(test), not(windows)))]
fn default_admin_path() -> std::path::PathBuf {
std::path::PathBuf::from("/etc/computer-use/policy.toml")
}
fn merge_file(
policy: &mut LoadedPolicy,
path: &Path,
origin: PolicyOrigin,
) -> Result<(), ComputerError> {
let text = std::fs::read_to_string(path).map_err(|error| policy_io(origin, path, error))?;
if text.trim().is_empty() {
return Ok(());
}
let parsed: PolicyFile =
toml::from_str(&text).map_err(|error| parse_error(origin, path, &text, &error))?;
if parsed.version.unwrap_or(0) != 1 {
return Err(origin_error(
origin,
path,
format!(
"line {}: policy version must be 1",
line_of_key(&text, "version")
),
));
}
let default_deny = match parsed.default.as_deref() {
None => false,
Some("allow") => false,
Some("deny") => true,
Some(other) => {
return Err(origin_error(
origin,
path,
format!(
"line {}: default must be allow or deny (got {other})",
line_of_key(&text, "default")
),
))
}
};
if parsed.pause_on_user_input.unwrap_or(false) {
policy.pause_on_user_input = true;
}
let source = match origin {
PolicyOrigin::User => {
policy.user_default_deny = default_deny;
if let Some(confirm) = parsed.confirm.as_ref() {
if let Some(command) = confirm
.command
.clone()
.filter(|text| !text.trim().is_empty())
{
policy.user_confirm = Some(command);
}
if let Some(timeout) = confirm.timeout_secs {
policy.timeout_secs = timeout.clamp(1, 120);
}
}
"user"
}
PolicyOrigin::Admin => {
policy.admin_default_deny = default_deny;
if let Some(confirm) = parsed.confirm.as_ref() {
if let Some(command) = confirm
.command
.clone()
.filter(|text| !text.trim().is_empty())
{
policy.admin_confirm = Some(command);
}
if policy.user_confirm.is_none() {
if let Some(timeout) = confirm.timeout_secs {
policy.timeout_secs = timeout.clamp(1, 120);
}
}
}
"admin"
}
};
for entry in parsed.deny {
policy.rules.push(
rule_from(entry, RuleEffect::Deny, source)
.map_err(|detail| origin_error(origin, path, detail))?,
);
}
if matches!(origin, PolicyOrigin::User) {
for entry in parsed.allow {
policy.rules.push(
rule_from(entry, RuleEffect::Allow, source)
.map_err(|detail| origin_error(origin, path, detail))?,
);
}
}
if let Some(browser) = parsed.browser {
policy.blocked_urls.extend(browser.blocked_urls);
}
Ok(())
}
fn rule_from(entry: PolicyEntry, effect: RuleEffect, source: &'static str) -> Result<Rule, String> {
if entry.bundle_id.is_none() && entry.path.is_none() && entry.name.is_none() {
return Err("policy allow and deny entries need bundle_id, path, or name".into());
}
let path = entry.path.map(|path| {
crate::target::canonical_exe_path(Path::new(&path))
.to_string_lossy()
.into_owned()
});
Ok(Rule {
effect,
bundle_id: entry.bundle_id,
path,
name: entry.name,
source,
})
}
fn policy_io(origin: PolicyOrigin, path: &Path, error: std::io::Error) -> ComputerError {
origin_error(origin, path, error.to_string())
}
fn line_of_key(text: &str, key: &str) -> usize {
text.lines()
.position(|line| {
let trimmed = line.trim_start();
trimmed.starts_with(key) && trimmed[key.len()..].trim_start().starts_with('=')
})
.map(|index| index + 1)
.unwrap_or(1)
}
fn parse_error(
origin: PolicyOrigin,
path: &Path,
text: &str,
error: &toml::de::Error,
) -> ComputerError {
let line = error
.span()
.map(|span| {
text[..span.start.min(text.len())]
.chars()
.filter(|ch| *ch == '\n')
.count()
+ 1
})
.or_else(|| line_from_display(&error.to_string()))
.unwrap_or(1);
origin_error(origin, path, format!("line {line}: {error}"))
}
fn line_from_display(text: &str) -> Option<usize> {
let marker = "at line ";
let start = text.find(marker)? + marker.len();
text[start..]
.split(|ch: char| !ch.is_ascii_digit())
.next()?
.parse()
.ok()
}
fn origin_error(origin: PolicyOrigin, path: &Path, detail: String) -> ComputerError {
match origin {
PolicyOrigin::User => {
ComputerError::InvalidArguments(format!("{}: {detail}", path.display()))
}
PolicyOrigin::Admin => ComputerError::PolicyDenied(format!("{}: {detail}", path.display())),
}
}
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct PolicyFile {
#[serde(default)]
version: Option<u32>,
#[serde(default)]
default: Option<String>,
#[serde(default)]
pause_on_user_input: Option<bool>,
#[serde(default)]
allow: Vec<PolicyEntry>,
#[serde(default)]
deny: Vec<PolicyEntry>,
#[serde(default)]
confirm: Option<ConfirmSection>,
#[serde(default)]
browser: Option<BrowserSection>,
}
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct PolicyEntry {
#[serde(default)]
bundle_id: Option<String>,
#[serde(default)]
path: Option<String>,
#[serde(default)]
name: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct ConfirmSection {
#[serde(default)]
command: Option<String>,
#[serde(default)]
timeout_secs: Option<u64>,
}
#[derive(Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct BrowserSection {
#[serde(default)]
blocked_urls: Vec<String>,
}
pub fn confirm_program(policy: &LoadedPolicy, flag: Option<&str>) -> Option<String> {
if let Some(flag) = flag.map(str::trim).filter(|text| !text.is_empty()) {
return Some(flag.to_string());
}
#[cfg(not(test))]
if let Ok(env) = std::env::var("COMPUTER_USE_CONFIRM_CMD") {
if !env.trim().is_empty() {
return Some(env);
}
}
policy
.user_confirm
.clone()
.or_else(|| policy.admin_confirm.clone())
}
#[derive(Debug, serde::Serialize)]
pub struct ConfirmDocument<'a> {
pub action: &'a str,
pub bundle_id: Option<&'a str>,
pub path: Option<&'a str>,
pub pid: Option<i32>,
pub window_id: Option<u32>,
pub element: Option<u32>,
pub reason: &'a str,
}
pub fn run_confirm(
command: Option<&str>,
timeout_secs: u64,
document: &ConfirmDocument<'_>,
) -> Result<(), ComputerError> {
let Some(command) = command.map(str::trim).filter(|text| !text.is_empty()) else {
return Err(ComputerError::ConfirmRequired(format!(
"{} requires a confirm hook ({})",
document.action, document.reason
)));
};
let (program, args) = split_command(command)?;
let payload = serde_json::json!({
"action": document.action,
"app": {
"bundle_id": document.bundle_id,
"path": document.path,
},
"pid": document.pid,
"window_id": document.window_id,
"element": document.element,
"reason": document.reason,
});
let bytes = serde_json::to_vec(&payload).map_err(|error| {
ComputerError::ExecutionFailed(format!("confirm document encode failed: {error}"))
})?;
let timeout = std::time::Duration::from_secs(timeout_secs.clamp(1, 120));
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
match crate::backend::run_with_stdin(&program, &arg_refs, &bytes, timeout) {
Ok(output) if output.status.success() => Ok(()),
Ok(output) => Err(ComputerError::ConfirmDenied(format!(
"confirm hook exited {}",
output
.status
.code()
.map(|code| code.to_string())
.unwrap_or_else(|| "without a status".into())
))),
Err(error) => Err(ComputerError::ConfirmDenied(error)),
}
}
pub fn split_command(input: &str) -> Result<(String, Vec<String>), ComputerError> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut quote: Option<char> = None;
let mut any = false;
for ch in input.chars() {
match quote {
Some(mark) if ch == mark => quote = None,
Some(_) => current.push(ch),
None if ch == '"' || ch == '\'' => quote = Some(ch),
None if ch.is_whitespace() => {
if any {
tokens.push(std::mem::take(&mut current));
any = false;
}
}
None => {
current.push(ch);
any = true;
}
}
}
if quote.is_some() {
return Err(ComputerError::InvalidArguments(
"confirm command has an unmatched quote".into(),
));
}
if any {
tokens.push(current);
}
let mut tokens = tokens.into_iter();
let program = tokens
.next()
.ok_or_else(|| ComputerError::InvalidArguments("confirm command is empty".into()))?;
Ok((program, tokens.collect()))
}
pub fn sensitive_title(title: &str) -> bool {
title
.split(|ch: char| !ch.is_ascii_alphanumeric())
.any(|word| {
matches!(
word.to_ascii_lowercase().as_str(),
"delete" | "erase" | "pay" | "purchase" | "send" | "submit"
)
})
}
pub fn key_needs_confirm(keys: &str) -> bool {
let Ok(combos) = crate::keys::parse_key_sequence(keys) else {
return false;
};
combos.iter().any(|combo| {
let command =
combo.modifiers.len() == 1 && combo.modifiers[0] == crate::types::Modifier::Command;
let alt = combo.modifiers.len() == 1 && combo.modifiers[0] == crate::types::Modifier::Alt;
(command && combo.key.eq_ignore_ascii_case("q"))
|| (alt && combo.key.eq_ignore_ascii_case("f4"))
})
}
pub fn is_browser(bundle_id: Option<&str>, path: Option<&Path>, name: Option<&str>) -> bool {
if bundle_id.is_some_and(|id| {
BROWSER_BUNDLES
.iter()
.any(|browser| browser.eq_ignore_ascii_case(id))
}) {
return true;
}
if let Some(path) = path {
if let Some(file) = path.file_name().and_then(|file| file.to_str()) {
if BROWSER_EXES
.iter()
.any(|exe| exe.eq_ignore_ascii_case(file))
{
return true;
}
}
}
name.is_some_and(|name| {
BROWSER_EXES
.iter()
.any(|exe| exe.eq_ignore_ascii_case(name.trim()))
|| name.eq_ignore_ascii_case("Google Chrome")
|| name.eq_ignore_ascii_case("Safari")
|| name.eq_ignore_ascii_case("Firefox")
|| name.eq_ignore_ascii_case("Microsoft Edge")
|| name.eq_ignore_ascii_case("Brave Browser")
|| name.eq_ignore_ascii_case("Arc")
})
}
pub fn url_in_text(text: &str) -> Option<&str> {
for token in text.split_whitespace() {
let token =
token.trim_matches(|ch: char| matches!(ch, '"' | '\'' | '<' | '>' | '(' | ')' | ','));
if token.starts_with("https://")
|| token.starts_with("http://")
|| token.starts_with("file://")
{
return Some(token);
}
}
None
}
pub fn url_blocked<'a>(url: &str, patterns: &'a [String]) -> Option<&'a str> {
patterns.iter().find_map(|pattern| {
if glob_match(pattern, url) || slash_star_origin(pattern, url) {
Some(pattern.as_str())
} else {
None
}
})
}
fn slash_star_origin(pattern: &str, url: &str) -> bool {
let Some(origin) = pattern.strip_suffix("/*") else {
return false;
};
url == origin || url.starts_with(&format!("{origin}?"))
}
fn glob_match(pattern: &str, text: &str) -> bool {
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 1 {
return pattern == text;
}
if !text.starts_with(parts[0]) {
return false;
}
let mut rest = &text[parts[0].len()..];
for (index, part) in parts.iter().enumerate().skip(1) {
if part.is_empty() {
if index + 1 == parts.len() {
return true;
}
continue;
}
if index + 1 == parts.len() {
return rest.ends_with(part);
}
match rest.find(part) {
Some(pos) => rest = &rest[pos + part.len()..],
None => return false,
}
}
true
}
std::thread_local! {
static TEST_PAUSED: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
}
pub struct PauseOverride {
previous: Option<bool>,
}
impl Drop for PauseOverride {
fn drop(&mut self) {
let previous = self.previous.take();
TEST_PAUSED.with(|slot| slot.set(previous));
}
}
pub fn override_paused(paused: bool) -> PauseOverride {
let previous = TEST_PAUSED.with(|slot| slot.replace(Some(paused)));
PauseOverride { previous }
}
pub fn is_paused() -> bool {
#[cfg(test)]
if let Some(paused) = TEST_PAUSED.with(|slot| slot.get()) {
return paused;
}
if pause_file_present() {
return true;
}
paused_without_file()
}
#[cfg(test)]
fn paused_without_file() -> bool {
false
}
#[cfg(not(test))]
fn paused_without_file() -> bool {
if FILE_IO_OK.load(std::sync::atomic::Ordering::Acquire) {
PAUSED.store(false, std::sync::atomic::Ordering::Release);
false
} else {
PAUSED.load(std::sync::atomic::Ordering::Acquire)
}
}
#[cfg(not(test))]
static PAUSED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
static FILE_IO_OK: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
pub fn set_paused(paused: bool) -> Result<(), ComputerError> {
#[cfg(not(test))]
PAUSED.store(paused, std::sync::atomic::Ordering::Release);
match write_pause_file(paused) {
Ok(()) => {
FILE_IO_OK.store(true, std::sync::atomic::Ordering::Release);
Ok(())
}
Err(error) => {
FILE_IO_OK.store(false, std::sync::atomic::Ordering::Release);
Err(error)
}
}
}
fn pause_file_present() -> bool {
crate::audit::state_dir()
.map(|dir| dir.join("paused").is_file())
.unwrap_or(false)
}
fn write_pause_file(paused: bool) -> Result<(), ComputerError> {
let Some(dir) = crate::audit::state_dir() else {
return Ok(());
};
std::fs::create_dir_all(&dir).map_err(ComputerError::Io)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
}
let path = dir.join("paused");
if paused {
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
#[cfg(target_os = "linux")]
options.custom_flags(0x20000);
#[cfg(target_os = "macos")]
options.custom_flags(0x0100);
}
options.open(&path).map_err(ComputerError::Io)?;
Ok(())
} else if path.exists() {
std::fs::remove_file(&path).map_err(ComputerError::Io)
} else {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
use std::fs;
use std::path::PathBuf;
#[test]
fn terminal_bundle_id_and_fixture_path_are_denied() {
let by_id = identity_denial(Some("com.apple.Terminal"), None, None, None).unwrap();
assert_eq!(by_id.source, "bundle");
assert!(by_id.message.contains("com.apple.Terminal"));
let path = Path::new("/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal");
let by_path = identity_denial(None, Some(path), None, None).unwrap();
assert_eq!(by_path.source, "path");
assert!(by_path.message.contains("Terminal.app"));
let selector = AppSelector::BundleId("com.apple.Terminal".into());
assert!(selector_denial(&selector).is_some());
let named = AppSelector::Name(r#"Foo "Bar""#.into());
assert!(selector_denial(&named).is_none());
assert!(identity_denial(Some("app.crynta.terax"), None, None, None).is_some());
assert!(selector_denial(&AppSelector::Name("Terax".into())).is_some());
let terax = Path::new("/Applications/Terax.app/Contents/MacOS/Terax");
assert!(identity_denial(None, Some(terax), None, None).is_some());
}
#[test]
fn every_listed_bundle_and_exe_is_denied_and_notes_is_not() {
for bundle_id in DENIED_BUNDLE_IDS {
assert!(
identity_denial(Some(bundle_id), None, None, None).is_some(),
"{bundle_id}"
);
}
for exe in DENIED_EXE_NAMES {
let path = PathBuf::from("/usr/bin").join(exe);
assert!(
identity_denial(None, Some(&path), None, None).is_some(),
"{exe}"
);
}
assert!(identity_denial(
Some("com.apple.Notes"),
Some(Path::new(
"/System/Applications/Notes.app/Contents/MacOS/Notes"
)),
None,
None
)
.is_none());
assert!(identity_denial(
None,
Some(Path::new("/usr/local/bin/TerminalHelper")),
None,
None
)
.is_none());
}
#[test]
fn system_settings_privacy_window_is_denied_and_other_windows_are_not() {
assert!(identity_denial(
Some("com.apple.systempreferences"),
Some(Path::new(
"/System/Applications/System Settings.app/Contents/MacOS/System Settings"
)),
Some("Privacy & Security"),
None,
)
.is_some());
assert!(identity_denial(
Some("com.apple.systempreferences"),
None,
Some("Displays"),
Some("AXIdentifier"),
)
.is_none());
assert!(identity_denial(Some("com.apple.systempreferences"), None, None, None).is_none());
assert!(identity_denial(
Some("com.apple.Settings"),
None,
Some("Privacy & Security"),
None,
)
.is_some());
assert!(
identity_denial(Some("com.apple.Settings"), None, Some("Displays"), None).is_none()
);
assert!(identity_denial(
Some("com.apple.systempreferences"),
None,
None,
Some("Privacy_Security"),
)
.is_some());
}
#[test]
fn powershell_cmd_and_wt_paths_are_denied() {
for exe in ["powershell.exe", "cmd.exe", "wt.exe", "gnome-terminal"] {
let path = PathBuf::from(r"C:\Windows\System32").join(exe);
assert!(path_denial(&path).is_some(), "{exe}");
}
}
#[test]
fn quit_protects_loginwindow_windowserver_and_self() {
assert!(quit_is_protected(9, 9, None, "computer-use", None));
assert!(quit_is_protected(
2,
9,
Some("com.apple.loginwindow"),
"loginwindow",
None
));
assert!(quit_is_protected(3, 9, None, "WindowServer", None));
assert!(!quit_is_protected(
10,
9,
Some("com.apple.Notes"),
"Notes",
Some("/System/Applications/Notes.app/Contents/MacOS/Notes")
));
}
#[cfg(unix)]
#[test]
fn symlink_to_a_terminal_executable_is_denied() {
let dir = std::env::temp_dir().join(format!(
"computer-use-policy-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0)
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let real = dir.join("Terminal");
fs::write(&real, b"fixture").unwrap();
let link = dir.join("alias");
std::os::unix::fs::symlink(&real, &link).unwrap();
let canonical = fs::canonicalize(&link).unwrap();
assert_eq!(
canonical.file_name().and_then(|name| name.to_str()),
Some("Terminal")
);
assert!(identity_denial(None, Some(&canonical), None, None).is_some());
let user = dir.join("user.toml");
std::fs::write(&user, "version = 1\n[[allow]]\npath = \"alias\"\n").unwrap();
let user_text = format!("version = 1\n[[allow]]\npath = \"{}\"\n", link.display());
std::fs::write(&user, user_text).unwrap();
let policy = load_paths(Some(&user), None).unwrap();
let decision = decide(
&policy,
&Subject {
path: Some(link.display().to_string()),
..Subject::default()
},
&PolicyAction::Mutate,
);
assert!(
matches!(
decision,
Decision::Deny {
source: "builtin",
..
}
),
"{decision:?}"
);
let _ = fs::remove_dir_all(&dir);
}
fn temp_dir(label: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"computer-use-{label}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0)
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn subject_bundle(bundle: &str) -> Subject {
Subject {
bundle_id: Some(bundle.into()),
..Subject::default()
}
}
#[test]
fn default_deny_allows_only_listed_apps() {
let dir = temp_dir("default-deny");
let user = dir.join("user.toml");
std::fs::write(
&user,
"version = 1\ndefault = \"deny\"\n\n[[allow]]\nbundle_id = \"com.apple.Notes\"\n",
)
.unwrap();
let policy = load_paths(Some(&user), None).unwrap();
assert!(matches!(
app_verdict(&policy, &subject_bundle("com.apple.Notes")),
Decision::Allow { source: "user" }
));
assert!(matches!(
app_verdict(&policy, &subject_bundle("com.apple.Finder")),
Decision::Deny { source: "user", .. }
));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn admin_deny_beats_user_allow_and_builtin_beats_user_allow() {
let dir = temp_dir("merge");
let admin = dir.join("admin.toml");
let user = dir.join("user.toml");
std::fs::write(
&admin,
"version = 1\n[[deny]]\nbundle_id = \"com.apple.Notes\"\n",
)
.unwrap();
std::fs::write(
&user,
"version = 1\n[[allow]]\nbundle_id = \"com.apple.Notes\"\n[[allow]]\nbundle_id = \"com.apple.Terminal\"\n",
)
.unwrap();
let policy = load_paths(Some(&user), Some(&admin)).unwrap();
assert!(matches!(
app_verdict(&policy, &subject_bundle("com.apple.Notes")),
Decision::Deny {
source: "admin",
..
}
));
assert!(matches!(
app_verdict(&policy, &subject_bundle("com.apple.Terminal")),
Decision::Deny {
source: "builtin",
..
}
));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn glob_blocks_accounts_host_and_allows_example() {
let patterns = vec!["https://accounts.example.com/*".to_string()];
assert_eq!(
url_blocked("https://accounts.example.com/login", &patterns),
Some("https://accounts.example.com/*")
);
assert!(url_blocked("https://accounts.example.com/", &patterns).is_some());
assert!(url_blocked("https://accounts.example.com", &patterns).is_some());
assert!(url_blocked("https://example.com/", &patterns).is_none());
assert!(url_blocked("https://example.com/accounts.example.com/", &patterns).is_none());
}
#[test]
fn user_parse_error_names_the_line_and_admin_fails_closed() {
let dir = temp_dir("parse");
let user = dir.join("user.toml");
std::fs::write(&user, "version = 1\n\ndefault = \"sometimes\"\n").unwrap();
let error = load_paths(Some(&user), None).unwrap_err();
assert_eq!(error.code(), "invalid_arguments");
assert!(error.to_string().contains("line "), "{error}");
let admin = dir.join("admin.toml");
std::fs::write(&admin, "version = 1\nnope = true\n").unwrap();
let error = load_paths(None, Some(&admin)).unwrap_err();
assert_eq!(error.code(), "policy_denied");
assert!(
error.to_string().contains(&admin.display().to_string()),
"{error}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn confirm_hook_exit_codes() {
let dir = temp_dir("confirm");
let document = ConfirmDocument {
action: "quit",
bundle_id: Some("com.apple.Notes"),
path: None,
pid: Some(10),
window_id: None,
element: None,
reason: "quit",
};
#[cfg(unix)]
let (allow, deny) = {
let allow = dir.join("allow.sh");
let deny = dir.join("deny.sh");
std::fs::write(&allow, "#!/bin/sh\ncat >/dev/null\nexit 0\n").unwrap();
std::fs::write(&deny, "#!/bin/sh\ncat >/dev/null\nexit 1\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&allow, std::fs::Permissions::from_mode(0o700)).unwrap();
std::fs::set_permissions(&deny, std::fs::Permissions::from_mode(0o700)).unwrap();
}
(allow.display().to_string(), deny.display().to_string())
};
#[cfg(windows)]
let (allow, deny) = ("cmd /C exit 0".to_string(), "cmd /C exit 1".to_string());
assert!(run_confirm(Some(&allow), 30, &document).is_ok());
let denied = run_confirm(Some(&deny), 30, &document).unwrap_err();
assert_eq!(denied.code(), "confirm_denied");
let missing = run_confirm(None, 30, &document).unwrap_err();
assert_eq!(missing.code(), "confirm_required");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn sensitive_titles_and_quit_shortcuts() {
assert!(sensitive_title("Delete Note"));
assert!(sensitive_title("SEND"));
assert!(!sensitive_title("Save"));
assert!(key_needs_confirm("cmd+q"));
assert!(key_needs_confirm("alt+f4"));
assert!(!key_needs_confirm("cmd+s"));
assert!(!key_needs_confirm("meta+q"));
assert!(!event_is_synthetic(1));
assert!(event_is_synthetic(SYNTHETIC_EVENT_USER_DATA));
}
}