diff --git a/src/openhuman/security/policy.rs b/src/openhuman/security/policy.rs index c9b0a6063..6f4dc25ac 100644 --- a/src/openhuman/security/policy.rs +++ b/src/openhuman/security/policy.rs @@ -814,6 +814,23 @@ const NODE_PKG_READ_VERBS: &[&str] = &[ /// run build scripts, so they are fail-closed to `Write`. const CARGO_READ_VERBS: &[&str] = &["tree", "metadata", "search", "info", "version", "help"]; +/// Detect a pacman *install/upgrade* from its bundled operation flag. +/// +/// pacman packs its operation and modifiers into a single flag (`-Syu`, `-Ss`), +/// and `args` reach us already lowercased — so the `-S` (sync) operation is +/// indistinguishable from a literal `-s` by case alone. We therefore key off +/// the *modifier* letters instead of a blanket `starts_with("-s")`, which would +/// over-match every read-only `-S` query: a `-S`-family flag mutates the host +/// only when it carries none of pacman's read-only query modifiers — search +/// (`s`), info (`i`), list (`l`), groups (`g`) or print (`p`). So `-S pkg`, +/// `-Sy`, `-Syu` are installs while `-Ss`/`-Si`/`-Sl`/`-Sg`/`-Sp` are reads. +fn is_pacman_install(args: &[String]) -> bool { + args.iter().any(|a| { + a.strip_prefix("-s") + .is_some_and(|modifiers| !modifiers.contains(['s', 'i', 'l', 'g', 'p'])) + }) +} + /// Detect a package-manager *install* invocation. These mutate the host / /// global environment, so they are the always-ask `Install` bucket (even in /// Full) — the same gate the dedicated `install_tool` enforces, applied to the @@ -826,7 +843,7 @@ fn is_install_command(base: &str, args: &[String]) -> bool { match base { // System package managers. "apt" | "apt-get" | "dnf" | "yum" | "zypper" => has("install"), - "pacman" => args.iter().any(|a| a.starts_with("-s")), // -S / -Sy / -Syu (lowercased) + "pacman" => is_pacman_install(args), "apk" => has("add"), "brew" | "snap" | "flatpak" | "winget" | "choco" | "scoop" => has("install"), // Language package managers — host/global-modifying installs only. diff --git a/src/openhuman/security/policy_tests.rs b/src/openhuman/security/policy_tests.rs index 1afe427bb..e478bd342 100644 --- a/src/openhuman/security/policy_tests.rs +++ b/src/openhuman/security/policy_tests.rs @@ -427,6 +427,8 @@ fn classify_installs_are_install_bucket() { "apt-get install -y curl", "brew install ripgrep", "pacman -S vim", + "pacman -Sy", + "pacman -Syu", "apk add bash", "dnf install nginx", "pip install requests", @@ -456,6 +458,23 @@ fn classify_local_installs_are_write_not_install() { assert_eq!(p.classify_command("cargo add serde"), CommandClass::Write); } +#[test] +fn classify_pacman_readonly_queries_are_not_install() { + let p = default_policy(); + // pacman's `-S` family includes read-only queries (search/info/list/groups/ + // print). A blanket `starts_with("-s")` mis-bucketed these as always-ask + // Install; they must fall through to the fail-closed Write default instead. + for c in [ + "pacman -Ss firefox", // search + "pacman -Si vim", // info + "pacman -Sl core", // list a repo + "pacman -Sg", // list groups + "pacman -Sp vim", // print download URLs + ] { + assert_eq!(p.classify_command(c), CommandClass::Write, "{c}"); + } +} + #[test] fn gate_decision_install_always_asks_even_in_full() { assert_eq!(