Skip to main content

zeroclaw_config/
policy.rs

1use parking_lot::Mutex;
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4use std::time::Instant;
5
6// Re-export from zeroclaw-config.
7pub use crate::autonomy::AutonomyLevel;
8
9/// Risk score for shell command execution.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum CommandRiskLevel {
12    Low,
13    Medium,
14    High,
15}
16
17/// Classifies whether a tool operation is read-only or side-effecting.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum ToolOperation {
20    Read,
21    Act,
22}
23
24/// Sliding-window action tracker for rate limiting.
25#[derive(Debug)]
26pub struct ActionTracker {
27    /// Timestamps of recent actions (kept within the last hour).
28    actions: Mutex<Vec<Instant>>,
29}
30
31impl Default for ActionTracker {
32    fn default() -> Self {
33        Self::new()
34    }
35}
36
37impl ActionTracker {
38    pub fn new() -> Self {
39        Self {
40            actions: Mutex::new(Vec::new()),
41        }
42    }
43
44    /// Record an action and return the current count within the window.
45    pub fn record(&self) -> usize {
46        let mut actions = self.actions.lock();
47        let cutoff = Instant::now()
48            .checked_sub(std::time::Duration::from_secs(3600))
49            .unwrap_or_else(Instant::now);
50        actions.retain(|t| *t > cutoff);
51        actions.push(Instant::now());
52        actions.len()
53    }
54
55    /// Count of actions in the current window without recording.
56    pub fn count(&self) -> usize {
57        let mut actions = self.actions.lock();
58        let cutoff = Instant::now()
59            .checked_sub(std::time::Duration::from_secs(3600))
60            .unwrap_or_else(Instant::now);
61        actions.retain(|t| *t > cutoff);
62        actions.len()
63    }
64}
65
66impl Clone for ActionTracker {
67    fn clone(&self) -> Self {
68        let actions = self.actions.lock();
69        Self {
70            actions: Mutex::new(actions.clone()),
71        }
72    }
73}
74
75/// Per-sender sliding-window rate limiter.
76///
77/// Each unique sender key (Telegram thread ID, Discord channel, etc.) gets
78/// its own independent [`ActionTracker`] bucket. When no sender is in scope
79/// (cron jobs, CLI), the `GLOBAL_KEY` bucket is used.
80///
81/// The bucket map is shared via `Arc` so a `SubAgent` policy that clones
82/// from its parent observes the same live counts. SubAgent budget
83/// inheritance relies on this: a child run consuming an action sees the
84/// shared bucket update, so the parent's `max_actions_per_hour` ceiling
85/// applies across both runs rather than each getting a fresh allocation.
86///
87/// Note: sender buckets accumulate for the daemon lifetime with no eviction.
88/// This is acceptable for bounded sets of chat IDs; in high-cardinality deployments,
89/// consider periodic cleanup.
90#[derive(Debug)]
91pub struct PerSenderTracker {
92    buckets: std::sync::Arc<parking_lot::Mutex<HashMap<String, ActionTracker>>>,
93}
94
95impl PerSenderTracker {
96    /// Bucket key used when no per-sender context is available (cron, CLI).
97    pub const GLOBAL_KEY: &'static str = "__global__";
98
99    /// Create an empty tracker with no sender buckets.
100    pub fn new() -> Self {
101        Self {
102            buckets: std::sync::Arc::new(parking_lot::Mutex::new(HashMap::new())),
103        }
104    }
105
106    /// Resolve the current sender key from the task-local, falling back to GLOBAL_KEY.
107    fn current_key() -> String {
108        zeroclaw_api::TOOL_LOOP_THREAD_ID
109            .try_with(|v| v.clone())
110            .ok()
111            .flatten()
112            .unwrap_or_else(|| Self::GLOBAL_KEY.to_string())
113    }
114
115    /// Record one action for the current sender. Returns `true` if allowed
116    /// (count after recording <= max), `false` if budget exhausted.
117    pub fn record_for_current(&self, max: u32) -> bool {
118        let key = Self::current_key();
119        self.record_within(&key, max)
120    }
121
122    /// Record one action for `key`. Allows the action when count == max (≤ max);
123    /// blocks and returns false when count > max.
124    pub fn record_within(&self, key: &str, max: u32) -> bool {
125        let mut buckets = self.buckets.lock();
126        let tracker = buckets.entry(key.to_string()).or_default();
127        let count = tracker.record();
128        count <= max as usize
129    }
130
131    /// Check if the current sender is at or over the limit (without recording).
132    pub fn is_limited_for_current(&self, max: u32) -> bool {
133        let key = Self::current_key();
134        self.is_exhausted(&key, max)
135    }
136
137    /// Check if `key` is at or over `max` (without recording).
138    /// Does NOT insert a bucket for unseen keys.
139    /// A max of 0 is always exhausted (zero budget means no actions allowed).
140    /// Returns true when count has reached or exceeded max. Note: acquires write lock
141    /// because ActionTracker::count prunes stale entries internally. Also note: returns
142    /// true one count earlier than record_within would block.
143    pub fn is_exhausted(&self, key: &str, max: u32) -> bool {
144        if max == 0 {
145            return true;
146        }
147        let mut buckets = self.buckets.lock();
148        match buckets.get_mut(key) {
149            Some(tracker) => tracker.count() >= max as usize,
150            None => false,
151        }
152    }
153}
154
155impl Clone for PerSenderTracker {
156    /// Cloning a `PerSenderTracker` shares the bucket map by `Arc`.
157    /// SubAgent runs consume from the same buckets as their parent
158    /// so per-hour and per-day budgets are not bypassed by spawning
159    /// children. Tests that need an isolated tracker construct a
160    /// fresh one via [`Self::new`] rather than cloning.
161    fn clone(&self) -> Self {
162        Self {
163            buckets: std::sync::Arc::clone(&self.buckets),
164        }
165    }
166}
167
168impl Default for PerSenderTracker {
169    fn default() -> Self {
170        Self::new()
171    }
172}
173
174/// Security policy enforced on all tool executions.
175///
176/// Three cross-agent allowlist tiers drive the multi-agent design:
177///
178/// - `allowed_roots`: read AND write. Populated from
179///   `RiskProfileConfig.allowed_roots` and from
180///   `AccessMode::ReadWrite` grants in `agent.workspace.access`.
181/// - `allowed_roots_read_only`: read but NOT write. Populated from
182///   `AccessMode::Read` grants.
183/// - `allowed_roots_write_only`: write but NOT read. Populated from
184///   `AccessMode::Write` grants. The bot can append/overwrite under
185///   the path but `file_read` / `pdf_read` / `glob_search` /
186///   `content_search` reject it.
187///
188/// Read-side tools call [`SecurityPolicy::is_resolved_path_readable`],
189/// which sees `allowed_roots` ∪ `allowed_roots_read_only` plus the
190/// universal POSIX device files. Write-side tools call
191/// [`SecurityPolicy::is_resolved_path_allowed`], which sees
192/// `allowed_roots` ∪ `allowed_roots_write_only`. The two tiers stay
193/// disjoint by construction so `AccessMode::Write` and
194/// `AccessMode::Read` grant exactly what they say.
195#[derive(Debug, Clone)]
196pub struct SecurityPolicy {
197    pub autonomy: AutonomyLevel,
198    pub workspace_dir: PathBuf,
199    pub workspace_only: bool,
200    pub allowed_commands: Vec<String>,
201    pub forbidden_paths: Vec<String>,
202    /// Directories the agent can read AND write under. Includes
203    /// `RiskProfileConfig.allowed_roots` plus any cross-agent
204    /// `AccessMode::ReadWrite` grants resolved from
205    /// `agent.workspace.access` at policy construction time.
206    pub allowed_roots: Vec<PathBuf>,
207    /// Directories the agent can read but NOT write under. Populated
208    /// from cross-agent `AccessMode::Read` grants at policy
209    /// construction time. Empty when no read-only cross-agent access
210    /// is configured.
211    pub allowed_roots_read_only: Vec<PathBuf>,
212    /// Directories the agent can write but NOT read under. Populated
213    /// from cross-agent `AccessMode::Write` grants at policy
214    /// construction time. Empty when no write-only cross-agent access
215    /// is configured. Read-side tools (`file_read`, `pdf_read`,
216    /// `glob_search`, `content_search`) ignore this list; write-side
217    /// tools (`file_write`, `file_edit`, `git_operations`) honor it.
218    pub allowed_roots_write_only: Vec<PathBuf>,
219    pub max_actions_per_hour: u32,
220    pub max_cost_per_day_cents: u32,
221    pub require_approval_for_medium_risk: bool,
222    pub block_high_risk_commands: bool,
223    pub shell_env_passthrough: Vec<String>,
224    pub shell_timeout_secs: u64,
225    /// Tool name allowlist. `None` is unrestricted (default for agents
226    /// without an explicit `risk_profile.allowed_tools` setting).
227    /// `Some(vec![])` denies every tool. `Some(list)` admits only the
228    /// listed names. Enforced at the agent loop's tool-dispatch site.
229    pub allowed_tools: Option<Vec<String>>,
230    /// Tool name denylist. Subtracts from the allowed set (whether the
231    /// allowed set comes from `allowed_tools` or from the unrestricted
232    /// default). `None` and `Some(vec![])` both mean "exclude nothing".
233    pub excluded_tools: Option<Vec<String>>,
234    /// Tools that never require approval in this profile. Mirrors
235    /// `RiskProfileConfig.auto_approve`.
236    pub auto_approve: Vec<String>,
237    /// Tools that always require approval in this profile. Mirrors
238    /// `RiskProfileConfig.always_ask`.
239    pub always_ask: Vec<String>,
240    /// Whether the sandbox is enabled for this profile. `None`
241    /// inherits the global default at the call site.
242    pub sandbox_enabled: Option<bool>,
243    /// Sandbox backend identifier (e.g. `"firejail"`, `"landlock"`).
244    /// `None` inherits the global default.
245    pub sandbox_backend: Option<String>,
246    /// Extra arguments forwarded to firejail when `sandbox_backend`
247    /// resolves to `"firejail"`.
248    pub firejail_args: Vec<String>,
249    pub tracker: PerSenderTracker,
250}
251
252impl SecurityPolicy {
253    /// True when `name` is admissible under the current policy.
254    ///
255    /// `allowed_tools = None` is unrestricted; `Some(list)` is the
256    /// allowlist. `excluded_tools` always subtracts.
257    pub fn is_tool_allowed(&self, name: &str) -> bool {
258        let allowed = self
259            .allowed_tools
260            .as_ref()
261            .is_none_or(|list| list.iter().any(|t| t == name));
262        let excluded = self
263            .excluded_tools
264            .as_ref()
265            .is_some_and(|list| list.iter().any(|t| t == name));
266        allowed && !excluded
267    }
268}
269
270/// Default allowed commands for Unix platforms.
271#[cfg(not(target_os = "windows"))]
272pub(crate) fn default_allowed_commands() -> Vec<String> {
273    #[allow(unused_mut)]
274    let mut cmds = vec![
275        "git".into(),
276        "npm".into(),
277        "cargo".into(),
278        "ls".into(),
279        "cat".into(),
280        "grep".into(),
281        "find".into(),
282        "echo".into(),
283        "pwd".into(),
284        "wc".into(),
285        "head".into(),
286        "tail".into(),
287        "date".into(),
288        "df".into(),
289        "du".into(),
290        "uname".into(),
291        "uptime".into(),
292        "hostname".into(),
293        "python".into(),
294        "python3".into(),
295        "pip".into(),
296        "node".into(),
297    ];
298    // `free` is Linux-only; it does not exist on macOS or other BSDs.
299    #[cfg(target_os = "linux")]
300    cmds.push("free".into());
301    cmds
302}
303
304/// Default allowed commands for Windows platforms.
305///
306/// Includes both native Windows commands and their Unix equivalents
307/// (available via Git for Windows, WSL, etc.).
308#[cfg(target_os = "windows")]
309pub(crate) fn default_allowed_commands() -> Vec<String> {
310    vec![
311        // Cross-platform tools
312        "git".into(),
313        "npm".into(),
314        "cargo".into(),
315        "echo".into(),
316        // Windows-native equivalents
317        "dir".into(),
318        "type".into(),
319        "findstr".into(),
320        "where".into(),
321        "more".into(),
322        "date".into(),
323        // Unix commands (available via Git for Windows / MSYS2)
324        "ls".into(),
325        "cat".into(),
326        "grep".into(),
327        "find".into(),
328        "pwd".into(),
329        "wc".into(),
330        "head".into(),
331        "tail".into(),
332        "df".into(),
333        "du".into(),
334        "uname".into(),
335        "uptime".into(),
336        "hostname".into(),
337        "python".into(),
338        "python3".into(),
339        "pip".into(),
340        "node".into(),
341    ]
342}
343
344/// Default forbidden paths for Unix platforms.
345#[cfg(not(target_os = "windows"))]
346pub(crate) fn default_forbidden_paths() -> Vec<String> {
347    vec![
348        "/etc".into(),
349        "/root".into(),
350        "/home".into(),
351        "/usr".into(),
352        "/bin".into(),
353        "/sbin".into(),
354        "/lib".into(),
355        "/opt".into(),
356        "/boot".into(),
357        "/dev".into(),
358        "/proc".into(),
359        "/sys".into(),
360        "/var".into(),
361        "/tmp".into(),
362        "~/.ssh".into(),
363        "~/.gnupg".into(),
364        "~/.aws".into(),
365        "~/.config".into(),
366    ]
367}
368
369/// Default forbidden paths for Windows platforms.
370#[cfg(target_os = "windows")]
371pub(crate) fn default_forbidden_paths() -> Vec<String> {
372    vec![
373        "C:\\Windows".into(),
374        "C:\\Windows\\System32".into(),
375        "C:\\Program Files".into(),
376        "C:\\Program Files (x86)".into(),
377        "C:\\ProgramData".into(),
378        "~/.ssh".into(),
379        "~/.gnupg".into(),
380        "~/.aws".into(),
381        "~/.config".into(),
382    ]
383}
384
385/// Shared helper for the two `is_under_*_allowed_root` checks: returns
386/// `true` when `expanded` falls under any entry of `roots`. Each entry
387/// is canonicalized when possible so symlinked roots match the on-disk
388/// shape, and the literal path is also tried as a fallback for cases
389/// where canonicalization fails (missing parent dir, permission, etc.).
390fn roots_contain(roots: &[PathBuf], expanded: &Path) -> bool {
391    roots.iter().any(|root| {
392        let canonical = root.canonicalize().unwrap_or_else(|_| root.clone());
393        expanded.starts_with(&canonical) || expanded.starts_with(root)
394    })
395}
396
397/// Subset check on two filesystem paths: returns `true` when `child`
398/// is the same as `parent` or a descendant of it. Used by the SubAgent
399/// escalation validator so a child can legitimately narrow `/srv` to
400/// `/srv/app` without the validator rejecting the narrowing as if it
401/// were a foreign path. Tries the canonical form first to handle
402/// symlinks consistently, then falls back to the literal path so
403/// not-yet-existing per-agent dirs (which do not canonicalize) still
404/// match.
405fn path_contains(parent: &Path, child: &Path) -> bool {
406    let canonical_parent = parent
407        .canonicalize()
408        .unwrap_or_else(|_| parent.to_path_buf());
409    let canonical_child = child.canonicalize().unwrap_or_else(|_| child.to_path_buf());
410    canonical_child.starts_with(&canonical_parent) || child.starts_with(parent)
411}
412
413/// Specific kind of escalation violation returned by
414/// [`SecurityPolicy::ensure_no_escalation_beyond`]. Each variant names
415/// the field that violated subset semantics so the SubAgent spawn path
416/// can produce a precise error to the caller.
417#[derive(Debug, Clone, PartialEq, Eq)]
418pub enum EscalationViolation {
419    /// Child raises `autonomy` above the parent (e.g. parent
420    /// `Supervised`, child `Full`). The autonomy level gates the
421    /// entire `can_act` and approval flow, so silent escalation here
422    /// would bypass every other guard.
423    AutonomyAboveParent {
424        child: AutonomyLevel,
425        parent: AutonomyLevel,
426    },
427    /// `child.allowed_roots` contains a path the parent cannot rw.
428    ReadWriteRootNotInParent { path: PathBuf },
429    /// `child.allowed_roots_read_only` contains a path the parent
430    /// cannot read at all (not in parent rw or read-only lists).
431    ReadOnlyRootNotInParent { path: PathBuf },
432    /// `child.allowed_roots_write_only` contains a path the parent
433    /// cannot write at all (not in parent rw or write-only lists).
434    WriteOnlyRootNotInParent { path: PathBuf },
435    /// `child.allowed_commands` contains a shell command the parent
436    /// has no allowance for.
437    CommandNotInParent { command: String },
438    /// Parent enforces workspace_only but the child override tries to
439    /// turn it off.
440    WorkspaceOnlyDisabledByChild,
441    /// Child drops a forbidden_paths entry the parent enforces. Subset
442    /// semantics on forbidden lists run the opposite direction from
443    /// allowlists: parent ⊆ child, so the child can ADD entries but
444    /// never DROP them.
445    ForbiddenPathDroppedByChild { path: String },
446    /// Child raises `shell_env_passthrough` to leak env vars the
447    /// parent declined to forward.
448    ShellEnvPassthroughExpanded { variable: String },
449    /// Child override raises `max_actions_per_hour` above the
450    /// parent's ceiling.
451    MaxActionsExceeded { child: u32, parent: u32 },
452    /// Child override raises `max_cost_per_day_cents` above the
453    /// parent's ceiling.
454    MaxCostExceeded { child: u32, parent: u32 },
455    /// Child override raises `shell_timeout_secs` above the parent's
456    /// ceiling. The shell budget is a runaway-process guard; raising
457    /// it on the child side defeats the parent's intent.
458    ShellTimeoutExceeded { child: u64, parent: u64 },
459    /// Child flips `block_high_risk_commands` from `true` (parent) to
460    /// `false`, opening the high-risk command surface the parent
461    /// closed.
462    BlockHighRiskCommandsDisabledByChild,
463    /// Child flips `require_approval_for_medium_risk` from `true`
464    /// (parent) to `false`, bypassing the human-in-the-loop step the
465    /// parent required.
466    RequireApprovalDisabledByChild,
467}
468
469impl std::fmt::Display for EscalationViolation {
470    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
471        match self {
472            Self::AutonomyAboveParent { child, parent } => {
473                write!(f, "subagent autonomy={child:?} exceeds parent's {parent:?}")
474            }
475            Self::ReadWriteRootNotInParent { path } => write!(
476                f,
477                "subagent allowed_roots entry {path:?} is not contained within any of the parent's allowed_roots entries"
478            ),
479            Self::ReadOnlyRootNotInParent { path } => write!(
480                f,
481                "subagent allowed_roots_read_only entry {path:?} is not contained within the parent's allowed_roots or allowed_roots_read_only"
482            ),
483            Self::WriteOnlyRootNotInParent { path } => write!(
484                f,
485                "subagent allowed_roots_write_only entry {path:?} is not contained within the parent's allowed_roots or allowed_roots_write_only"
486            ),
487            Self::CommandNotInParent { command } => write!(
488                f,
489                "subagent allowed_commands entry {command:?} is not present on the parent's allowed_commands"
490            ),
491            Self::WorkspaceOnlyDisabledByChild => write!(
492                f,
493                "subagent attempts to disable workspace_only but the parent enforces it"
494            ),
495            Self::ForbiddenPathDroppedByChild { path } => write!(
496                f,
497                "subagent drops forbidden_paths entry {path:?} that the parent enforces"
498            ),
499            Self::ShellEnvPassthroughExpanded { variable } => write!(
500                f,
501                "subagent shell_env_passthrough entry {variable:?} is not present on the parent's list"
502            ),
503            Self::MaxActionsExceeded { child, parent } => write!(
504                f,
505                "subagent max_actions_per_hour={child} exceeds parent's {parent}"
506            ),
507            Self::MaxCostExceeded { child, parent } => write!(
508                f,
509                "subagent max_cost_per_day_cents={child} exceeds parent's {parent}"
510            ),
511            Self::ShellTimeoutExceeded { child, parent } => write!(
512                f,
513                "subagent shell_timeout_secs={child} exceeds parent's {parent}"
514            ),
515            Self::BlockHighRiskCommandsDisabledByChild => write!(
516                f,
517                "subagent attempts to set block_high_risk_commands=false but the parent enforces it"
518            ),
519            Self::RequireApprovalDisabledByChild => write!(
520                f,
521                "subagent attempts to set require_approval_for_medium_risk=false but the parent enforces it"
522            ),
523        }
524    }
525}
526
527impl std::error::Error for EscalationViolation {}
528
529impl Default for SecurityPolicy {
530    fn default() -> Self {
531        Self {
532            autonomy: AutonomyLevel::Supervised,
533            workspace_dir: PathBuf::from("."),
534            workspace_only: true,
535            allowed_commands: default_allowed_commands(),
536            forbidden_paths: default_forbidden_paths(),
537            allowed_roots: Vec::new(),
538            allowed_roots_read_only: Vec::new(),
539            allowed_roots_write_only: Vec::new(),
540            max_actions_per_hour: 20,
541            max_cost_per_day_cents: 500,
542            require_approval_for_medium_risk: true,
543            block_high_risk_commands: true,
544            shell_env_passthrough: vec![],
545            shell_timeout_secs: 60,
546            allowed_tools: None,
547            excluded_tools: None,
548            auto_approve: vec![],
549            always_ask: vec![],
550            sandbox_enabled: None,
551            sandbox_backend: None,
552            firejail_args: vec![],
553            tracker: PerSenderTracker::new(),
554        }
555    }
556}
557
558fn home_dir() -> Option<PathBuf> {
559    #[cfg(not(target_os = "windows"))]
560    {
561        std::env::var_os("HOME").map(PathBuf::from)
562    }
563    #[cfg(target_os = "windows")]
564    {
565        std::env::var_os("USERPROFILE")
566            .or_else(|| std::env::var_os("HOME"))
567            .map(PathBuf::from)
568    }
569}
570
571fn expand_user_path(path: &str) -> PathBuf {
572    if path == "~"
573        && let Some(home) = home_dir()
574    {
575        return home;
576    }
577
578    if let Some(stripped) = path.strip_prefix("~/")
579        && let Some(home) = home_dir()
580    {
581        return home.join(stripped);
582    }
583
584    PathBuf::from(path)
585}
586
587/// Returns `true` if `path` is exactly the OS null device.
588///
589/// `/dev/null` is unconditionally permitted because redirecting output
590/// there is a common, harmless shell pattern. The rest of `/dev` remains
591/// blocked by the default forbidden-path list.
592fn is_null_device(path: &Path) -> bool {
593    #[cfg(not(target_os = "windows"))]
594    {
595        path == Path::new("/dev/null")
596    }
597    #[cfg(target_os = "windows")]
598    {
599        let s = path.to_string_lossy();
600        let lower = s.to_ascii_lowercase();
601        lower == "nul" || lower == r"\\.\nul"
602    }
603}
604
605fn rootless_path(path: &Path) -> Option<PathBuf> {
606    let mut relative = PathBuf::new();
607
608    for component in path.components() {
609        match component {
610            std::path::Component::Prefix(_)
611            | std::path::Component::RootDir
612            | std::path::Component::CurDir => {}
613            std::path::Component::ParentDir => return None,
614            std::path::Component::Normal(part) => relative.push(part),
615        }
616    }
617
618    if relative.as_os_str().is_empty() {
619        None
620    } else {
621        Some(relative)
622    }
623}
624
625// ── Shell Command Parsing Utilities ───────────────────────────────────────
626// These helpers implement a minimal quote-aware shell lexer. They exist
627// because security validation must reason about the *structure* of a
628// command (separators, operators, quoting) rather than treating it as a
629// flat string — otherwise an attacker could hide dangerous sub-commands
630// inside quoted arguments or chained operators.
631/// Skip leading environment variable assignments (e.g. `FOO=bar cmd args`).
632/// Returns the remainder starting at the first non-assignment word.
633fn skip_env_assignments(s: &str) -> &str {
634    let mut rest = s;
635    loop {
636        let Some(word) = rest.split_whitespace().next() else {
637            return rest;
638        };
639        // Environment assignment: contains '=' and starts with a letter or underscore
640        if word.contains('=')
641            && word
642                .chars()
643                .next()
644                .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
645        {
646            // Advance past this word
647            rest = rest[word.len()..].trim_start();
648        } else {
649            return rest;
650        }
651    }
652}
653
654#[derive(Debug, Clone, Copy, PartialEq, Eq)]
655enum QuoteState {
656    None,
657    Single,
658    Double,
659}
660
661/// Split a shell command into sub-commands by unquoted separators.
662///
663/// Separators:
664/// - `;` and newline
665/// - `|`
666/// - `&&`, `||`
667///
668/// Characters inside single or double quotes are treated as literals, so
669/// `sqlite3 db "SELECT 1; SELECT 2;"` remains a single segment.
670///
671/// Heredoc bodies (`<<WORD ... WORD`) are kept as part of the same segment
672/// as the command that opens them; newlines inside the body do not split.
673fn split_unquoted_segments(command: &str) -> Vec<String> {
674    let mut segments = Vec::new();
675    let mut current = String::new();
676    let mut quote = QuoteState::None;
677    let mut escaped = false;
678    // Heredoc state: Some(delim) while inside a heredoc body.
679    let mut heredoc_delimiter: Option<String> = None;
680    // Accumulates the current line while inside a heredoc body, for terminator detection.
681    let mut heredoc_line_buf = String::new();
682    // True while reading the delimiter word that follows `<<`.
683    let mut reading_heredoc_word = false;
684    let mut heredoc_word_buf = String::new();
685    let mut chars = command.chars().peekable();
686
687    let push_segment = |segments: &mut Vec<String>, current: &mut String| {
688        let trimmed = current.trim();
689        if !trimmed.is_empty() {
690            segments.push(trimmed.to_string());
691        }
692        current.clear();
693    };
694
695    while let Some(ch) = chars.next() {
696        match quote {
697            QuoteState::Single => {
698                if ch == '\'' {
699                    quote = QuoteState::None;
700                }
701                current.push(ch);
702            }
703            QuoteState::Double => {
704                if escaped {
705                    escaped = false;
706                    current.push(ch);
707                    continue;
708                }
709                if ch == '\\' {
710                    escaped = true;
711                    current.push(ch);
712                    continue;
713                }
714                if ch == '"' {
715                    quote = QuoteState::None;
716                }
717                current.push(ch);
718            }
719            QuoteState::None => {
720                if escaped {
721                    escaped = false;
722                    current.push(ch);
723                    if heredoc_delimiter.is_some() {
724                        heredoc_line_buf.push(ch);
725                    }
726                    continue;
727                }
728                if ch == '\\' {
729                    escaped = true;
730                    current.push(ch);
731                    if heredoc_delimiter.is_some() {
732                        heredoc_line_buf.push(ch);
733                    }
734                    continue;
735                }
736
737                // Reading the delimiter word that follows `<<`.
738                if reading_heredoc_word {
739                    if ch == '\n' {
740                        // Finalise the delimiter and enter the heredoc body.
741                        let raw = heredoc_word_buf.trim().trim_start_matches('-');
742                        let delim = raw
743                            .trim_matches(|c| c == '\'' || c == '"' || c == '\\')
744                            .to_string();
745                        if !delim.is_empty() {
746                            heredoc_delimiter = Some(delim);
747                        }
748                        heredoc_word_buf.clear();
749                        reading_heredoc_word = false;
750                        // The newline after `<<WORD` belongs to the same segment.
751                        current.push(ch);
752                    } else {
753                        heredoc_word_buf.push(ch);
754                        current.push(ch);
755                    }
756                    continue;
757                }
758
759                // Inside a heredoc body: don't split on newlines.
760                if let Some(delim) = heredoc_delimiter.as_deref() {
761                    if ch == '\n' {
762                        if heredoc_line_buf.trim() == delim {
763                            // Terminator line reached — end of heredoc body.
764                            heredoc_delimiter = None;
765                            heredoc_line_buf.clear();
766                            push_segment(&mut segments, &mut current);
767                        } else {
768                            heredoc_line_buf.clear();
769                            current.push(ch);
770                        }
771                    } else {
772                        heredoc_line_buf.push(ch);
773                        current.push(ch);
774                    }
775                    continue;
776                }
777
778                match ch {
779                    '\'' => {
780                        quote = QuoteState::Single;
781                        current.push(ch);
782                    }
783                    '"' => {
784                        quote = QuoteState::Double;
785                        current.push(ch);
786                    }
787                    ';' | '\n' => push_segment(&mut segments, &mut current),
788                    '|' => {
789                        if chars.next_if_eq(&'|').is_some() {
790                            // Consume full `||`; both characters are separators.
791                        }
792                        push_segment(&mut segments, &mut current);
793                    }
794                    '&' => {
795                        if chars.next_if_eq(&'&').is_some() {
796                            // `&&` is a separator; single `&` is handled separately.
797                            push_segment(&mut segments, &mut current);
798                        } else {
799                            current.push(ch);
800                        }
801                    }
802                    '<' => {
803                        current.push(ch);
804                        // Detect `<<` (heredoc) but not `<<<` (here-string).
805                        if chars.peek() == Some(&'<') {
806                            let second = chars.next().unwrap();
807                            current.push(second);
808                            if chars.peek() != Some(&'<') {
809                                reading_heredoc_word = true;
810                            }
811                            // `<<<` falls through with no heredoc tracking.
812                        }
813                    }
814                    _ => current.push(ch),
815                }
816            }
817        }
818    }
819
820    let trimmed = current.trim();
821    if !trimmed.is_empty() {
822        segments.push(trimmed.to_string());
823    }
824
825    segments
826}
827
828/// Detect a single unquoted `&` operator (background/chain). `&&` is allowed.
829///
830/// Strip fd-merge redirect patterns (`N>&M`, `N<&M`, `>&N`, `<&N`, `N>&-`, etc.)
831/// so their `&` doesn't get flagged as a background operator.
832fn strip_fd_merge_redirects(command: &str) -> String {
833    use std::sync::OnceLock;
834    // Matches patterns like: 2>&1, 1>&2, >&2, <&0, 2<&-, >&-
835    static FD_MERGE_RE: OnceLock<regex::Regex> = OnceLock::new();
836    let re = FD_MERGE_RE.get_or_init(|| regex::Regex::new(r"\d*[><]&[\d-]").unwrap());
837    re.replace_all(command, "").to_string()
838}
839
840/// We treat any standalone `&` as unsafe in policy validation because it can
841/// chain hidden sub-commands and escape foreground timeout expectations.
842fn contains_unquoted_single_ampersand(command: &str) -> bool {
843    let mut quote = QuoteState::None;
844    let mut escaped = false;
845    let mut chars = command.chars().peekable();
846
847    while let Some(ch) = chars.next() {
848        match quote {
849            QuoteState::Single => {
850                if ch == '\'' {
851                    quote = QuoteState::None;
852                }
853            }
854            QuoteState::Double => {
855                if escaped {
856                    escaped = false;
857                    continue;
858                }
859                if ch == '\\' {
860                    escaped = true;
861                    continue;
862                }
863                if ch == '"' {
864                    quote = QuoteState::None;
865                }
866            }
867            QuoteState::None => {
868                if escaped {
869                    escaped = false;
870                    continue;
871                }
872                if ch == '\\' {
873                    escaped = true;
874                    continue;
875                }
876                match ch {
877                    '\'' => quote = QuoteState::Single,
878                    '"' => quote = QuoteState::Double,
879                    // This must consume the second '&' so `&&` is not later
880                    // re-read as a lone trailing '&'.
881                    '&' if chars.next_if_eq(&'&').is_none() => {
882                        return true;
883                    }
884                    _ => {}
885                }
886            }
887        }
888    }
889
890    false
891}
892
893/// Detect an unquoted character in a shell command.
894fn contains_unquoted_char(command: &str, target: char) -> bool {
895    let mut quote = QuoteState::None;
896    let mut escaped = false;
897
898    for ch in command.chars() {
899        match quote {
900            QuoteState::Single => {
901                if ch == '\'' {
902                    quote = QuoteState::None;
903                }
904            }
905            QuoteState::Double => {
906                if escaped {
907                    escaped = false;
908                    continue;
909                }
910                if ch == '\\' {
911                    escaped = true;
912                    continue;
913                }
914                if ch == '"' {
915                    quote = QuoteState::None;
916                }
917            }
918            QuoteState::None => {
919                if escaped {
920                    escaped = false;
921                    continue;
922                }
923                if ch == '\\' {
924                    escaped = true;
925                    continue;
926                }
927                match ch {
928                    '\'' => quote = QuoteState::Single,
929                    '"' => quote = QuoteState::Double,
930                    _ if ch == target => return true,
931                    _ => {}
932                }
933            }
934        }
935    }
936
937    false
938}
939
940/// Returns true if `command` contains an unquoted `>` that is NOT a safe
941/// stderr form (`2>/dev/null`, `2>&1`).
942fn contains_unsafe_output_redirect(command: &str) -> bool {
943    // Strip safe redirect-to-dev patterns (with word boundary enforcement),
944    // then fd-merge patterns, then check for remaining `>`.
945    use regex::Regex;
946    use std::sync::OnceLock;
947
948    static SAFE_OUTPUT_RE: OnceLock<Regex> = OnceLock::new();
949    let re = SAFE_OUTPUT_RE.get_or_init(|| {
950        // Match >SPACE?/dev/{null,zero,stdout,stderr} followed by whitespace,
951        // end-of-string, or a shell operator. A dot, slash, or any other
952        // non-operator character after the device name prevents the match —
953        // blocking bypasses like `2>/dev/stderr.log` or `>/dev/zero/path`.
954        // The terminator is captured and preserved in the replacement.
955        Regex::new(&format!(
956            r"\d*>[ ]?/dev/({})(\s|[;&|)]|$)",
957            safe_device_redirect_names_pattern()
958        ))
959        .unwrap()
960    });
961
962    let safe = re.replace_all(command, "$2").to_string();
963    // Also strip fd-merge redirects (2>&1, 1>&2, >&N, etc.)
964    let safe = strip_fd_merge_redirects(&safe);
965    contains_unquoted_char(&safe, '>')
966}
967
968/// Returns true if `command` contains an unquoted `<` that is NOT a heredoc (`<<`)
969/// or a safe input redirect from `/dev/*`.
970fn contains_unquoted_input_redirect(command: &str) -> bool {
971    // Strip here-strings (`<<<`) first, then heredocs (`<<`), then safe /dev/* sources
972    // with word boundary enforcement.
973    use regex::Regex;
974    use std::sync::OnceLock;
975
976    static SAFE_INPUT_RE: OnceLock<Regex> = OnceLock::new();
977    let re =
978        SAFE_INPUT_RE.get_or_init(|| Regex::new(r"<[ ]?/dev/(null|zero)(\s|[;&|)]|$)").unwrap());
979
980    let safe = command.replace("<<<", "").replace("<<", "");
981    let safe = re.replace_all(&safe, "$2").to_string();
982    // Also strip fd-merge redirects (<&0, <&-, etc.) so they don't leave a bare `<`
983    let safe = strip_fd_merge_redirects(&safe);
984    contains_unquoted_char(&safe, '<')
985}
986
987/// Detect unquoted shell variable expansions like `$HOME`, `$1`, `$?`.
988///
989/// Escaped dollars (`\$`) are ignored. Variables inside single quotes are
990/// treated as literals and therefore ignored.
991fn contains_unquoted_shell_variable_expansion(command: &str) -> bool {
992    let mut quote = QuoteState::None;
993    let mut escaped = false;
994    let chars: Vec<char> = command.chars().collect();
995
996    for i in 0..chars.len() {
997        let ch = chars[i];
998
999        match quote {
1000            QuoteState::Single => {
1001                if ch == '\'' {
1002                    quote = QuoteState::None;
1003                }
1004                continue;
1005            }
1006            QuoteState::Double => {
1007                if escaped {
1008                    escaped = false;
1009                    continue;
1010                }
1011                if ch == '\\' {
1012                    escaped = true;
1013                    continue;
1014                }
1015                if ch == '"' {
1016                    quote = QuoteState::None;
1017                    continue;
1018                }
1019            }
1020            QuoteState::None => {
1021                if escaped {
1022                    escaped = false;
1023                    continue;
1024                }
1025                if ch == '\\' {
1026                    escaped = true;
1027                    continue;
1028                }
1029                if ch == '\'' {
1030                    quote = QuoteState::Single;
1031                    continue;
1032                }
1033                if ch == '"' {
1034                    quote = QuoteState::Double;
1035                    continue;
1036                }
1037            }
1038        }
1039
1040        if ch != '$' {
1041            continue;
1042        }
1043
1044        let Some(next) = chars.get(i + 1).copied() else {
1045            continue;
1046        };
1047        if next.is_ascii_alphanumeric()
1048            || matches!(
1049                next,
1050                '_' | '{' | '(' | '#' | '?' | '!' | '$' | '*' | '@' | '-'
1051            )
1052        {
1053            return true;
1054        }
1055    }
1056
1057    false
1058}
1059
1060fn strip_wrapping_quotes(token: &str) -> &str {
1061    token.trim_matches(|c| c == '"' || c == '\'')
1062}
1063
1064fn looks_like_path(candidate: &str) -> bool {
1065    candidate.starts_with('/')
1066        || candidate.starts_with("./")
1067        || candidate.starts_with("../")
1068        || candidate.starts_with('~')
1069        || candidate == "."
1070        || candidate == ".."
1071        || candidate.contains('/')
1072        // Windows path patterns: drive letters (C:\, D:\) and UNC paths (\\server\share)
1073        || (cfg!(target_os = "windows")
1074            && (candidate
1075                .get(1..3)
1076                .is_some_and(|s| s == ":\\" || s == ":/")
1077                || candidate.starts_with("\\\\")))
1078}
1079
1080fn attached_short_option_value(token: &str) -> Option<&str> {
1081    // Examples:
1082    // -f/etc/passwd   -> /etc/passwd
1083    // -C../outside    -> ../outside
1084    // -I./include     -> ./include
1085    let body = token.strip_prefix('-')?;
1086    if body.starts_with('-') || body.len() < 2 {
1087        return None;
1088    }
1089    let value = body[1..].trim_start_matches('=').trim();
1090    if value.is_empty() { None } else { Some(value) }
1091}
1092
1093enum RedirectionArgument<'a> {
1094    Target { prefix: &'a str, target: &'a str },
1095    NeedsNextToken { prefix: &'a str },
1096    FdOnly { prefix: &'a str },
1097    None,
1098}
1099
1100fn parse_redirection_argument(token: &str) -> RedirectionArgument<'_> {
1101    let Some(marker_idx) = token.find(['<', '>']) else {
1102        return RedirectionArgument::None;
1103    };
1104    let prefix = token[..marker_idx].trim();
1105    let mut rest = &token[marker_idx + 1..];
1106    rest = rest.trim_start_matches(['<', '>']);
1107    if let Some(after_amp) = rest.strip_prefix('&') {
1108        let remaining = after_amp.trim_start_matches(|c: char| c.is_ascii_digit() || c == '-');
1109        if remaining.is_empty() {
1110            return RedirectionArgument::FdOnly { prefix };
1111        }
1112    }
1113    rest = rest.trim_start_matches('&');
1114    rest = rest.trim_start_matches(|c: char| c.is_ascii_digit());
1115    let trimmed = rest.trim();
1116    if trimmed.is_empty() {
1117        RedirectionArgument::NeedsNextToken { prefix }
1118    } else {
1119        RedirectionArgument::Target {
1120            prefix,
1121            target: trimmed,
1122        }
1123    }
1124}
1125
1126const SAFE_DEVICE_REDIRECT_TARGETS: [&str; 4] =
1127    ["/dev/null", "/dev/stdout", "/dev/stderr", "/dev/zero"];
1128
1129fn safe_device_redirect_names_pattern() -> String {
1130    SAFE_DEVICE_REDIRECT_TARGETS
1131        .iter()
1132        .map(|target| target.trim_start_matches("/dev/"))
1133        .collect::<Vec<_>>()
1134        .join("|")
1135}
1136
1137fn is_safe_device_redirect_target(target: &str) -> bool {
1138    SAFE_DEVICE_REDIRECT_TARGETS.contains(&strip_wrapping_quotes(target).trim())
1139}
1140
1141/// Extract the basename from a command path, handling both Unix (`/`) and
1142/// Windows (`\`) separators so that `C:\Git\bin\git.exe` resolves to `git.exe`.
1143fn command_basename(raw: &str) -> &str {
1144    let after_fwd = raw.rsplit('/').next().unwrap_or(raw);
1145    after_fwd.rsplit('\\').next().unwrap_or(after_fwd)
1146}
1147
1148/// Strip common Windows executable suffixes (.exe, .cmd, .bat) for uniform
1149/// matching against allowlists and risk tables. On non-Windows platforms this
1150/// is a no-op that returns the input unchanged.
1151fn strip_windows_exe_suffix(name: &str) -> &str {
1152    if cfg!(target_os = "windows") {
1153        name.strip_suffix(".exe")
1154            .or_else(|| name.strip_suffix(".cmd"))
1155            .or_else(|| name.strip_suffix(".bat"))
1156            .unwrap_or(name)
1157    } else {
1158        name
1159    }
1160}
1161
1162fn is_allowlist_entry_match(allowed: &str, executable: &str, executable_base: &str) -> bool {
1163    let allowed = strip_wrapping_quotes(allowed).trim();
1164    if allowed.is_empty() {
1165        return false;
1166    }
1167
1168    // Explicit wildcard support for "allow any command name/path".
1169    if allowed == "*" {
1170        return true;
1171    }
1172
1173    // Path-like allowlist entries must match the executable token exactly
1174    // after "~" expansion.
1175    if looks_like_path(allowed) {
1176        let allowed_path = expand_user_path(allowed);
1177        let executable_path = expand_user_path(executable);
1178        return executable_path == allowed_path;
1179    }
1180
1181    // Command-name entries continue to match by basename.
1182    // On Windows, also match when the executable has a .exe/.cmd/.bat suffix
1183    // that the allowlist entry omits (e.g., allowlist "git" matches "git.exe").
1184    if allowed == executable_base {
1185        return true;
1186    }
1187
1188    #[cfg(target_os = "windows")]
1189    {
1190        let base_lower = executable_base.to_ascii_lowercase();
1191        let allowed_lower = allowed.to_ascii_lowercase();
1192        for ext in &[".exe", ".cmd", ".bat"] {
1193            if base_lower == format!("{allowed_lower}{ext}") {
1194                return true;
1195            }
1196            if allowed_lower == format!("{base_lower}{ext}") {
1197                return true;
1198            }
1199        }
1200    }
1201
1202    false
1203}
1204
1205impl SecurityPolicy {
1206    // ── Risk Classification ──────────────────────────────────────────────
1207    // Risk is assessed per-segment (split on shell operators), and the
1208    // highest risk across all segments wins. This prevents bypasses like
1209    // `ls && rm -rf /` from being classified as Low just because `ls` is safe.
1210
1211    /// Classify command risk. Any high-risk segment marks the whole command high.
1212    pub fn command_risk_level(&self, command: &str) -> CommandRiskLevel {
1213        let mut saw_medium = false;
1214
1215        for segment in split_unquoted_segments(command) {
1216            let cmd_part = skip_env_assignments(&segment);
1217            let mut words = cmd_part.split_whitespace();
1218            let Some(base_raw) = words.next() else {
1219                continue;
1220            };
1221
1222            let base_owned = command_basename(base_raw).to_ascii_lowercase();
1223            let base = strip_windows_exe_suffix(&base_owned);
1224
1225            let args: Vec<String> = words.map(|w| w.to_ascii_lowercase()).collect();
1226            let joined_segment = cmd_part.to_ascii_lowercase();
1227
1228            // High-risk commands (Unix and Windows)
1229            if matches!(
1230                base,
1231                "rm" | "mkfs"
1232                    | "dd"
1233                    | "shutdown"
1234                    | "reboot"
1235                    | "halt"
1236                    | "poweroff"
1237                    | "sudo"
1238                    | "su"
1239                    | "chown"
1240                    | "chmod"
1241                    | "useradd"
1242                    | "userdel"
1243                    | "usermod"
1244                    | "passwd"
1245                    | "mount"
1246                    | "umount"
1247                    | "iptables"
1248                    | "ufw"
1249                    | "firewall-cmd"
1250                    | "curl"
1251                    | "wget"
1252                    | "nc"
1253                    | "ncat"
1254                    | "netcat"
1255                    | "scp"
1256                    | "ssh"
1257                    | "ftp"
1258                    | "telnet"
1259                    // Windows-specific high-risk commands
1260                    | "del"
1261                    | "rmdir"
1262                    | "format"
1263                    | "reg"
1264                    | "net"
1265                    | "runas"
1266                    | "icacls"
1267                    | "takeown"
1268                    | "powershell"
1269                    | "pwsh"
1270                    | "wmic"
1271                    | "sc"
1272                    | "netsh"
1273            ) {
1274                return CommandRiskLevel::High;
1275            }
1276
1277            if joined_segment.contains("rm -rf /")
1278                || joined_segment.contains("rm -fr /")
1279                || joined_segment.contains(":(){:|:&};:")
1280                // Windows destructive patterns
1281                || joined_segment.contains("del /s /q")
1282                || joined_segment.contains("rmdir /s /q")
1283                || joined_segment.contains("format c:")
1284            {
1285                return CommandRiskLevel::High;
1286            }
1287
1288            // Medium-risk commands (state-changing, but not inherently destructive)
1289            let medium = match base {
1290                "git" => args.first().is_some_and(|verb| {
1291                    matches!(
1292                        verb.as_str(),
1293                        "commit"
1294                            | "push"
1295                            | "reset"
1296                            | "clean"
1297                            | "rebase"
1298                            | "merge"
1299                            | "cherry-pick"
1300                            | "revert"
1301                            | "branch"
1302                            | "checkout"
1303                            | "switch"
1304                            | "tag"
1305                    )
1306                }),
1307                "npm" | "pnpm" | "yarn" => args.first().is_some_and(|verb| {
1308                    matches!(
1309                        verb.as_str(),
1310                        "install" | "add" | "remove" | "uninstall" | "update" | "publish"
1311                    )
1312                }),
1313                "cargo" => args.first().is_some_and(|verb| {
1314                    matches!(
1315                        verb.as_str(),
1316                        "add" | "remove" | "install" | "clean" | "publish"
1317                    )
1318                }),
1319                "touch" | "mkdir" | "mv" | "cp" | "ln"
1320                // Windows medium-risk equivalents
1321                | "copy" | "xcopy" | "robocopy" | "move" | "ren" | "rename" | "mklink" => true,
1322                _ => false,
1323            };
1324
1325            saw_medium |= medium;
1326        }
1327
1328        if saw_medium {
1329            CommandRiskLevel::Medium
1330        } else {
1331            CommandRiskLevel::Low
1332        }
1333    }
1334
1335    // ── Command Execution Policy Gate ──────────────────────────────────────
1336    // Validation follows a strict precedence order:
1337    //   1. Allowlist check (is the base command permitted at all?)
1338    //   2. Risk classification (high / medium / low)
1339    //   3. Policy flags (block_high_risk_commands, require_approval_for_medium_risk)
1340    //      — explicit allowlist entries exempt a command from the high-risk block,
1341    //        but the wildcard "*" does NOT grant an exemption.
1342    //   4. Autonomy level × approval status (supervised requires explicit approval)
1343    // This ordering ensures deny-by-default: unknown commands are rejected
1344    // before any risk or autonomy logic runs.
1345
1346    /// Validate full command execution policy (allowlist + risk gate).
1347    pub fn validate_command_execution(
1348        &self,
1349        command: &str,
1350        approved: bool,
1351    ) -> Result<CommandRiskLevel, String> {
1352        if !self.is_command_allowed(command) {
1353            return Err(format!("Command not allowed by security policy: {command}"));
1354        }
1355
1356        let risk = self.command_risk_level(command);
1357
1358        if risk == CommandRiskLevel::High {
1359            if self.block_high_risk_commands && !self.is_command_explicitly_allowed(command) {
1360                return Err("Command blocked: high-risk command is disallowed by policy".into());
1361            }
1362            if self.autonomy == AutonomyLevel::Supervised && !approved {
1363                return Err(
1364                    "Command requires explicit approval (approved=true): high-risk operation"
1365                        .into(),
1366                );
1367            }
1368        }
1369
1370        if risk == CommandRiskLevel::Medium
1371            && self.autonomy == AutonomyLevel::Supervised
1372            && self.require_approval_for_medium_risk
1373            && !approved
1374        {
1375            return Err(
1376                "Command requires explicit approval (approved=true): medium-risk operation".into(),
1377            );
1378        }
1379
1380        Ok(risk)
1381    }
1382
1383    /// Check whether **every** segment of a command is explicitly listed in
1384    /// `allowed_commands` — i.e., matched by a concrete entry rather than by
1385    /// the wildcard `"*"`.
1386    ///
1387    /// This is used to exempt explicitly-allowlisted high-risk commands from
1388    /// the `block_high_risk_commands` gate. The wildcard entry intentionally
1389    /// does **not** qualify as an explicit allowlist match, so that operators
1390    /// who set `allowed_commands = ["*"]` still get the high-risk safety net.
1391    fn is_command_explicitly_allowed(&self, command: &str) -> bool {
1392        let segments = split_unquoted_segments(command);
1393        for segment in &segments {
1394            let cmd_part = skip_env_assignments(segment);
1395            let mut words = cmd_part.split_whitespace();
1396            let raw_executable = strip_wrapping_quotes(words.next().unwrap_or("")).trim();
1397            let executable = if let Some(idx) = raw_executable.find(['<', '>']) {
1398                &raw_executable[..idx]
1399            } else {
1400                raw_executable
1401            };
1402            let base_cmd_owned = command_basename(executable).to_ascii_lowercase();
1403            let base_cmd = strip_windows_exe_suffix(&base_cmd_owned);
1404
1405            if base_cmd.is_empty() {
1406                continue;
1407            }
1408
1409            let explicitly_listed = self.allowed_commands.iter().any(|allowed| {
1410                let allowed = strip_wrapping_quotes(allowed).trim();
1411                // Skip wildcard — it does not count as an explicit entry.
1412                if allowed.is_empty() || allowed == "*" {
1413                    return false;
1414                }
1415                is_allowlist_entry_match(allowed, executable, base_cmd)
1416            });
1417
1418            if !explicitly_listed {
1419                return false;
1420            }
1421        }
1422
1423        // At least one real command must be present.
1424        segments.iter().any(|s| {
1425            let s = skip_env_assignments(s.trim());
1426            s.split_whitespace().next().is_some_and(|w| !w.is_empty())
1427        })
1428    }
1429
1430    // ── Layered Command Allowlist ──────────────────────────────────────────
1431    // Defence-in-depth: five independent gates run in order before the
1432    // per-segment allowlist check. Each gate targets a specific bypass
1433    // technique. If any gate rejects, the whole command is blocked.
1434
1435    /// Check if a shell command is allowed.
1436    ///
1437    /// Validates the **entire** command string, not just the first word:
1438    /// - Blocks subshell operators (`` ` ``, `$(`) that hide arbitrary execution
1439    /// - Splits on command separators (`|`, `&&`, `||`, `;`, newlines) and
1440    ///   validates each sub-command against the allowlist
1441    /// - Blocks single `&` background chaining (`&&` remains supported)
1442    /// - Blocks shell redirections (`<`, `>`, `>>`) that can bypass path policy
1443    /// - Blocks dangerous arguments (e.g. `find -exec`, `git config`)
1444    pub fn is_command_allowed(&self, command: &str) -> bool {
1445        if self.autonomy == AutonomyLevel::ReadOnly {
1446            return false;
1447        }
1448
1449        // When the operator has explicitly opted out of all command-level
1450        // restrictions (wildcard + no high-risk blocking), skip the
1451        // subshell/expansion guard entirely. This allows backticks,
1452        // $(), heredocs, etc. in trusted environments.
1453        let has_wildcard = self.allowed_commands.iter().any(|c| c.trim() == "*");
1454        if has_wildcard && !self.block_high_risk_commands {
1455            return true;
1456        }
1457
1458        // Block subshell/expansion operators — these allow hiding arbitrary
1459        // commands inside an allowed command (e.g. `echo $(rm -rf /)`) and
1460        // bypassing path checks through variable indirection. The helper below
1461        // ignores escapes and literals inside single quotes, so `$(` or `${`
1462        // literals are permitted there.
1463        if command.contains('`')
1464            || contains_unquoted_shell_variable_expansion(command)
1465            || command.contains("<(")
1466            || command.contains(">(")
1467        {
1468            return false;
1469        }
1470
1471        // Block shell redirections that target files. Allow safe forms:
1472        //   - `2>/dev/null`, `>/dev/null`, `1>/dev/null` (output suppression)
1473        //   - `2>&1`, `1>&2` (fd merging)
1474        //   - `<<` heredocs, `<<<` here-strings (input literals)
1475        if contains_unsafe_output_redirect(command) {
1476            return false;
1477        }
1478        if contains_unquoted_input_redirect(command) {
1479            return false;
1480        }
1481
1482        // Block `tee` — it can write to arbitrary files, bypassing the
1483        // redirect check above (e.g. `echo secret | tee /etc/crontab`)
1484        if command
1485            .split_whitespace()
1486            .any(|w| w == "tee" || w.ends_with("/tee"))
1487        {
1488            return false;
1489        }
1490
1491        // Block background command chaining (`&`), which can hide extra
1492        // sub-commands and outlive timeout expectations. Keep `&&` allowed.
1493        // Strip fd-merge redirects (N>&M, N<&M) first so their `&` isn't
1494        // flagged as background chaining.
1495        let ampersand_check = strip_fd_merge_redirects(command);
1496        if contains_unquoted_single_ampersand(&ampersand_check) {
1497            return false;
1498        }
1499
1500        // Split on unquoted command separators and validate each sub-command.
1501        let segments = split_unquoted_segments(command);
1502        for segment in &segments {
1503            // Strip leading env var assignments (e.g. FOO=bar cmd)
1504            let cmd_part = skip_env_assignments(segment);
1505
1506            let mut words = cmd_part.split_whitespace();
1507            let raw_executable = strip_wrapping_quotes(words.next().unwrap_or("")).trim();
1508            // Strip inline redirections from the executable token, e.g.
1509            // `cat</dev/null` -> `cat`, so the allowlist check sees the real
1510            // command name rather than the redirect target path.
1511            let executable = if let Some(idx) = raw_executable.find(['<', '>']) {
1512                &raw_executable[..idx]
1513            } else {
1514                raw_executable
1515            };
1516            let base_cmd_owned = command_basename(executable).to_ascii_lowercase();
1517            let base_cmd = strip_windows_exe_suffix(&base_cmd_owned);
1518
1519            if base_cmd.is_empty() {
1520                continue;
1521            }
1522
1523            if !self
1524                .allowed_commands
1525                .iter()
1526                .any(|allowed| is_allowlist_entry_match(allowed, executable, base_cmd))
1527            {
1528                return false;
1529            }
1530
1531            // Validate arguments for the command.
1532            // Both case-preserved and lowercased argument lists are provided:
1533            //   - `args_cased` for case-sensitive comparisons (e.g. git -C vs -c)
1534            //   - `args` (lowercased) for case-insensitive matches (e.g. subcommand names)
1535            let args_cased: Vec<String> = words.map(|w| w.to_string()).collect();
1536            let args: Vec<String> = args_cased.iter().map(|w| w.to_ascii_lowercase()).collect();
1537            if !self.is_args_safe(base_cmd, &args, &args_cased) {
1538                return false;
1539            }
1540        }
1541
1542        // At least one command must be present
1543        segments.iter().any(|s| {
1544            let s = skip_env_assignments(s.trim());
1545            s.split_whitespace().next().is_some_and(|w| !w.is_empty())
1546        })
1547    }
1548
1549    /// Check for dangerous arguments that allow sub-command execution or
1550    /// fetch+execute untrusted external code.
1551    ///
1552    /// Local workspace operations (cargo build, npm test, python script.py)
1553    /// are NOT blocked — the user trusts their own project.
1554    ///
1555    /// References:
1556    /// - ZeptoClaw GHSA-5wp8-q9mx-8jx8 (CVSS 9.8): same vulnerability class
1557    /// - OpenClaw strictInlineEval: blocks python -c, node -e, etc.
1558    /// - OWASP OS Command Injection Defense Cheat Sheet
1559    fn is_args_safe(&self, base: &str, args: &[String], args_cased: &[String]) -> bool {
1560        let base = base.to_ascii_lowercase();
1561        match base.as_str() {
1562            "find" => {
1563                // find -exec and find -ok allow arbitrary command execution
1564                !args.iter().any(|arg| arg == "-exec" || arg == "-ok")
1565            }
1566            "git" => {
1567                // git config, alias, and -c can be used to set dangerous options
1568                // (e.g. git config core.editor "rm -rf /").
1569                // NOTE: `-c` (lowercase) is compared case-sensitively against
1570                // `args_cased` because git's `-C` (uppercase, change directory)
1571                // is a distinct, benign option that must not be conflated with
1572                // `-c` (set config override).
1573                !args_cased.iter().any(|arg| arg == "-c")
1574                    && !args.iter().any(|arg| {
1575                        arg == "config"
1576                            || arg.starts_with("config.")
1577                            || arg == "alias"
1578                            || arg.starts_with("alias.")
1579                    })
1580            }
1581            "python" | "python3" => {
1582                // -c executes arbitrary code from argument string
1583                // -m runs any installed module as a script — broad block is intentional:
1584                //   -m http.server opens a local exfil vector
1585                //   -m pip install double-covers the pip arm
1586                //   -m pytest, -m mypy, -m venv are blocked as collateral;
1587                //   narrowing to a curated module list is a future option
1588                // starts_with covers glued form: python3 -c'code' (one whitespace token)
1589                // Ref: https://docs.python.org/3/using/cmdline.html
1590                !args
1591                    .iter()
1592                    .any(|arg| arg.starts_with("-c") || arg.starts_with("-m"))
1593            }
1594            "node" => {
1595                // -e/--eval evaluates argument as JavaScript
1596                // -p/--print same as --eval but prints the result
1597                // starts_with covers glued form: node -e'code' (one whitespace token)
1598                // Ref: https://nodejs.org/api/cli.html
1599                !args.iter().any(|arg| {
1600                    arg.starts_with("-e")
1601                        || arg.starts_with("--eval")
1602                        || arg.starts_with("-p")
1603                        || arg.starts_with("--print")
1604                })
1605            }
1606            "pip" | "pip3" => {
1607                // install/download fetch external packages; setup.py runs arbitrary code
1608                // Ref: https://blog.phylum.io/python-package-installation-attacks/
1609                !args.iter().any(|arg| arg == "install" || arg == "download")
1610            }
1611            "npm" => {
1612                // exec can fetch+run remote packages (npx behavior)
1613                // install fetches external packages; lifecycle scripts run arbitrary code
1614                // Ref: https://cheatsheetseries.owasp.org/cheatsheets/NPM_Security_Cheat_Sheet.html
1615                !args.iter().any(|arg| {
1616                    arg == "exec" || arg == "install" || arg == "i" || arg == "add" || arg == "ci"
1617                })
1618            }
1619            "cargo" => {
1620                // install fetches+builds external crate; build.rs executes arbitrary code
1621                // Ref: https://shnatsel.medium.com/do-not-run-any-cargo-commands-on-untrusted-projects
1622                !args.iter().any(|arg| arg == "install")
1623            }
1624            _ => true,
1625        }
1626    }
1627
1628    /// Return the first path-like argument blocked by path policy.
1629    ///
1630    /// This is best-effort token parsing for shell commands and is intended
1631    /// as a safety gate before command execution.
1632    pub fn forbidden_path_argument(&self, command: &str) -> Option<String> {
1633        let forbidden_candidate = |raw: &str| {
1634            let candidate = strip_wrapping_quotes(raw).trim();
1635            if candidate.is_empty() || candidate.contains("://") {
1636                return None;
1637            }
1638            if looks_like_path(candidate) && !self.is_path_allowed(candidate) {
1639                Some(candidate.to_string())
1640            } else {
1641                None
1642            }
1643        };
1644        let forbidden_non_redirect_candidate = |raw: &str| {
1645            let candidate = strip_wrapping_quotes(raw).trim();
1646            if candidate.is_empty() || candidate.contains("://") {
1647                return None;
1648            }
1649            if candidate.starts_with('-') {
1650                if let Some((_, value)) = candidate.split_once('=')
1651                    && let Some(blocked) = forbidden_candidate(value)
1652                {
1653                    return Some(blocked);
1654                }
1655                if let Some(value) = attached_short_option_value(candidate)
1656                    && let Some(blocked) = forbidden_candidate(value)
1657                {
1658                    return Some(blocked);
1659                }
1660                return None;
1661            }
1662            forbidden_candidate(candidate)
1663        };
1664
1665        for segment in split_unquoted_segments(command) {
1666            let cmd_part = skip_env_assignments(&segment);
1667            let mut words = cmd_part.split_whitespace();
1668            let Some(executable) = words.next() else {
1669                continue;
1670            };
1671
1672            let executable_redirect = parse_redirection_argument(strip_wrapping_quotes(executable));
1673            let mut next_is_redirect_target = false;
1674            // Cover inline forms like `cat</etc/passwd`.
1675            match executable_redirect {
1676                RedirectionArgument::Target { target, .. } => {
1677                    if !is_safe_device_redirect_target(target)
1678                        && let Some(blocked) = forbidden_candidate(target)
1679                    {
1680                        return Some(blocked);
1681                    }
1682                }
1683                RedirectionArgument::NeedsNextToken { .. } => {
1684                    next_is_redirect_target = true;
1685                }
1686                RedirectionArgument::FdOnly { .. } | RedirectionArgument::None => {}
1687            }
1688
1689            for token in words {
1690                let candidate = strip_wrapping_quotes(token).trim();
1691                if candidate.is_empty() {
1692                    continue;
1693                }
1694
1695                if next_is_redirect_target {
1696                    next_is_redirect_target = false;
1697                    if is_safe_device_redirect_target(candidate) {
1698                        continue;
1699                    }
1700                    if let Some(blocked) = forbidden_candidate(candidate) {
1701                        return Some(blocked);
1702                    }
1703                    continue;
1704                }
1705
1706                if candidate.contains("://") {
1707                    continue;
1708                }
1709
1710                match parse_redirection_argument(candidate) {
1711                    RedirectionArgument::Target { prefix, target } => {
1712                        if let Some(blocked) = forbidden_non_redirect_candidate(prefix) {
1713                            return Some(blocked);
1714                        }
1715                        if is_safe_device_redirect_target(target) {
1716                            continue;
1717                        }
1718                        if let Some(blocked) = forbidden_candidate(target) {
1719                            return Some(blocked);
1720                        }
1721                    }
1722                    RedirectionArgument::NeedsNextToken { prefix } => {
1723                        if let Some(blocked) = forbidden_non_redirect_candidate(prefix) {
1724                            return Some(blocked);
1725                        }
1726                        next_is_redirect_target = true;
1727                        continue;
1728                    }
1729                    RedirectionArgument::FdOnly { prefix } => {
1730                        if let Some(blocked) = forbidden_non_redirect_candidate(prefix) {
1731                            return Some(blocked);
1732                        }
1733                        continue;
1734                    }
1735                    RedirectionArgument::None => {}
1736                }
1737
1738                // Handle option assignment forms like `--file=/etc/passwd`.
1739                if let Some(blocked) = forbidden_non_redirect_candidate(candidate) {
1740                    return Some(blocked);
1741                }
1742                if candidate.starts_with('-') {
1743                    continue;
1744                }
1745            }
1746        }
1747
1748        None
1749    }
1750
1751    // ── Path Validation ────────────────────────────────────────────────
1752    // Layered checks: null-byte injection → component-level traversal →
1753    // URL-encoded traversal → tilde expansion → absolute-path block →
1754    // forbidden-prefix match. Each layer addresses a distinct escape
1755    // technique; together they enforce workspace confinement.
1756
1757    /// Check if a file path is allowed (no path traversal, within workspace)
1758    pub fn is_path_allowed(&self, path: &str) -> bool {
1759        // Block null bytes (can truncate paths in C-backed syscalls)
1760        if path.contains('\0') {
1761            return false;
1762        }
1763
1764        // Block path traversal: check for ".." as a path component
1765        if Path::new(path)
1766            .components()
1767            .any(|c| matches!(c, std::path::Component::ParentDir))
1768        {
1769            return false;
1770        }
1771
1772        // Block URL-encoded traversal attempts (e.g. ..%2f)
1773        let lower = path.to_lowercase();
1774        if lower.contains("..%2f") || lower.contains("%2f..") {
1775            return false;
1776        }
1777
1778        // Reject "~user" forms because the shell expands them at runtime and
1779        // they can escape workspace policy.
1780        if path.starts_with('~') && path != "~" && !path.starts_with("~/") {
1781            return false;
1782        }
1783
1784        // Expand "~" for consistent matching with forbidden paths and allowlists.
1785        let expanded_path = expand_user_path(path);
1786
1787        // The null device is always permitted regardless of workspace or
1788        // forbidden-path config; the rest of /dev remains blocked as usual.
1789        if is_null_device(&expanded_path) {
1790            return true;
1791        }
1792
1793        // When workspace_only is set and the path is absolute, only allow it
1794        // if it falls within the workspace directory or an explicit allowed
1795        // root.  The workspace/allowed-root check runs BEFORE the forbidden
1796        // prefix list so that workspace paths under broad defaults like
1797        // "/home" are not rejected.  This mirrors the priority order in
1798        // `is_resolved_path_allowed`.
1799        if expanded_path.is_absolute() {
1800            let in_workspace = expanded_path.starts_with(&self.workspace_dir);
1801            let in_allowed_root = self
1802                .allowed_roots
1803                .iter()
1804                .any(|root| expanded_path.starts_with(root));
1805            // String-level safety check is shared between read and
1806            // write side tools, so accept paths under either grant
1807            // tier here. The grant-direction enforcement happens at
1808            // the resolved-path methods (`is_resolved_path_readable`
1809            // / `is_resolved_path_allowed`), which split read-only
1810            // and write-only entries into different code paths.
1811            let in_read_only_root = self
1812                .allowed_roots_read_only
1813                .iter()
1814                .any(|root| expanded_path.starts_with(root));
1815            let in_write_only_root = self
1816                .allowed_roots_write_only
1817                .iter()
1818                .any(|root| expanded_path.starts_with(root));
1819
1820            if in_workspace || in_allowed_root || in_read_only_root || in_write_only_root {
1821                return true;
1822            }
1823
1824            // Absolute path outside workspace/allowed roots — block when
1825            // workspace_only, or fall through to forbidden-prefix check.
1826            if self.workspace_only {
1827                return false;
1828            }
1829        }
1830
1831        // Block forbidden paths using path-component-aware matching
1832        for forbidden in &self.forbidden_paths {
1833            let forbidden_path = expand_user_path(forbidden);
1834            if expanded_path.starts_with(forbidden_path) {
1835                return false;
1836            }
1837        }
1838
1839        true
1840    }
1841
1842    /// Validate that a resolved path is readable by the current
1843    /// security policy. Used by read-side tools (`file_read`,
1844    /// `pdf_read`, `glob_search`, `content_search`) that should honor
1845    /// the read-write `allowed_roots` AND the read-only
1846    /// `allowed_roots_read_only` lists, plus the universal POSIX
1847    /// device files (`/dev/null`, `/dev/zero`, `/dev/random`,
1848    /// `/dev/urandom`) that operators legitimately use for shell-
1849    /// idiom CLI commands and standard input/output redirection.
1850    ///
1851    /// Importantly: this method does NOT consult
1852    /// `allowed_roots_write_only`. `AccessMode::Write` grants write
1853    /// access without read access; surfacing those paths through a
1854    /// read-side tool would silently elevate the grant.
1855    ///
1856    /// Write-side tools (`file_write`, `file_edit`,
1857    /// `git_operations`, `shell` write paths) call
1858    /// [`Self::is_resolved_path_allowed`] instead.
1859    pub fn is_resolved_path_readable(&self, resolved: &Path) -> bool {
1860        // Universal POSIX device files: any operator running on Linux,
1861        // macOS, or BSD expects these to be readable. Adding them to
1862        // the per-agent config would be friction without security
1863        // benefit (they have no agent-relevant content).
1864        const POSIX_DEVICE_READS: &[&str] =
1865            &["/dev/null", "/dev/zero", "/dev/random", "/dev/urandom"];
1866        for device in POSIX_DEVICE_READS {
1867            if resolved == Path::new(device) {
1868                return true;
1869            }
1870        }
1871
1872        // Workspace + read-write allowlist + read-only allowlist.
1873        // Inlined rather than delegating to `is_resolved_path_allowed`
1874        // so the write-only allowlist is intentionally NOT in scope
1875        // here.
1876        let workspace_root = self
1877            .workspace_dir
1878            .canonicalize()
1879            .unwrap_or_else(|_| self.workspace_dir.clone());
1880        if resolved.starts_with(&workspace_root) {
1881            return true;
1882        }
1883        for root in &self.allowed_roots {
1884            let canonical = root.canonicalize().unwrap_or_else(|_| root.clone());
1885            if resolved.starts_with(&canonical) {
1886                return true;
1887            }
1888        }
1889        for root in &self.allowed_roots_read_only {
1890            let canonical = root.canonicalize().unwrap_or_else(|_| root.clone());
1891            if resolved.starts_with(&canonical) {
1892                return true;
1893            }
1894        }
1895        for root in &self.allowed_roots_write_only {
1896            let canonical = root.canonicalize().unwrap_or_else(|_| root.clone());
1897            if resolved.starts_with(&canonical) {
1898                return false;
1899            }
1900        }
1901
1902        // Forbidden paths gate after the explicit allowlists so the
1903        // allowlists can coexist with broad default forbidden roots
1904        // such as `/home` and `/tmp`.
1905        for forbidden in &self.forbidden_paths {
1906            let forbidden_path = expand_user_path(forbidden);
1907            if resolved.starts_with(&forbidden_path) {
1908                return false;
1909            }
1910        }
1911        if !self.workspace_only {
1912            return true;
1913        }
1914        false
1915    }
1916
1917    /// Validate that a resolved path is inside the workspace or an
1918    /// allowed root for write-side tools. Call this AFTER joining
1919    /// `workspace_dir` + relative path and canonicalizing.
1920    ///
1921    /// Sees `allowed_roots` (read+write) AND
1922    /// `allowed_roots_write_only` (write-only). Read-only allowlist
1923    /// entries are NOT honored; that's the read-side tier.
1924    pub fn is_resolved_path_allowed(&self, resolved: &Path) -> bool {
1925        if is_null_device(resolved) {
1926            return true;
1927        }
1928
1929        // Prefer canonical workspace root so `/a/../b` style config paths don't
1930        // cause false positives or negatives.
1931        let workspace_root = self
1932            .workspace_dir
1933            .canonicalize()
1934            .unwrap_or_else(|_| self.workspace_dir.clone());
1935        if resolved.starts_with(&workspace_root) {
1936            return true;
1937        }
1938
1939        // Check extra allowed roots (e.g. shared skills directories) before
1940        // forbidden checks so explicit allowlists can coexist with broad
1941        // default forbidden roots such as `/home` and `/tmp`.
1942        for root in &self.allowed_roots {
1943            let canonical = root.canonicalize().unwrap_or_else(|_| root.clone());
1944            if resolved.starts_with(&canonical) {
1945                return true;
1946            }
1947        }
1948
1949        // Write-only cross-agent grants land here. The bot can write
1950        // under these paths but `is_resolved_path_readable` does not
1951        // see them — `AccessMode::Write` is one-way by design.
1952        for root in &self.allowed_roots_write_only {
1953            let canonical = root.canonicalize().unwrap_or_else(|_| root.clone());
1954            if resolved.starts_with(&canonical) {
1955                return true;
1956            }
1957        }
1958
1959        // For paths outside workspace/allowlist, block forbidden roots to
1960        // prevent symlink escapes and sensitive directory access.
1961        for forbidden in &self.forbidden_paths {
1962            let forbidden_path = expand_user_path(forbidden);
1963            if resolved.starts_with(&forbidden_path) {
1964                return false;
1965            }
1966        }
1967
1968        // When workspace_only is disabled the user explicitly opted out of
1969        // workspace confinement after forbidden-path checks are applied.
1970        if !self.workspace_only {
1971            return true;
1972        }
1973
1974        false
1975    }
1976
1977    fn runtime_config_dir(&self) -> Option<PathBuf> {
1978        let parent = self.workspace_dir.parent()?;
1979        Some(
1980            parent
1981                .canonicalize()
1982                .unwrap_or_else(|_| parent.to_path_buf()),
1983        )
1984    }
1985
1986    pub fn is_runtime_config_path(&self, resolved: &Path) -> bool {
1987        let Some(config_dir) = self.runtime_config_dir() else {
1988            return false;
1989        };
1990        if !resolved.starts_with(&config_dir) {
1991            return false;
1992        }
1993        if resolved.parent() != Some(config_dir.as_path()) {
1994            return false;
1995        }
1996
1997        let Some(file_name) = resolved.file_name().and_then(|value| value.to_str()) else {
1998            return false;
1999        };
2000
2001        file_name == "config.toml"
2002            || file_name == "config.toml.bak"
2003            || file_name.starts_with(".config.toml.tmp-")
2004    }
2005
2006    pub fn runtime_config_violation_message(&self, resolved: &Path) -> String {
2007        format!(
2008            "Refusing to modify ZeroClaw runtime config/state file: {}. Use dedicated config tools or edit it manually outside the agent loop.",
2009            resolved.display()
2010        )
2011    }
2012
2013    pub fn resolved_path_violation_message(&self, resolved: &Path) -> String {
2014        let guidance = if self.allowed_roots.is_empty() {
2015            "Add the directory to [autonomy].allowed_roots (for example: allowed_roots = [\"/absolute/path\"]), or move the file into the workspace."
2016        } else {
2017            "Add a matching parent directory to [autonomy].allowed_roots, or move the file into the workspace."
2018        };
2019
2020        format!(
2021            "Resolved path escapes workspace allowlist: {}. {}",
2022            resolved.display(),
2023            guidance
2024        )
2025    }
2026
2027    /// Check if autonomy level permits any action at all
2028    pub fn can_act(&self) -> bool {
2029        self.autonomy != AutonomyLevel::ReadOnly
2030    }
2031
2032    // ── Tool Operation Gating ──────────────────────────────────────────────
2033    // Read operations bypass autonomy and rate checks because they have
2034    // no side effects. Act operations must pass both the autonomy gate
2035    // (not read-only) and the sliding-window rate limiter.
2036
2037    /// Enforce policy for a tool operation.
2038    ///
2039    /// Read operations are always allowed by autonomy/rate gates.
2040    /// Act operations require non-readonly autonomy and available action budget.
2041    pub fn enforce_tool_operation(
2042        &self,
2043        operation: ToolOperation,
2044        operation_name: &str,
2045    ) -> Result<(), String> {
2046        match operation {
2047            ToolOperation::Read => Ok(()),
2048            ToolOperation::Act => {
2049                if !self.can_act() {
2050                    return Err(format!(
2051                        "Security policy: read-only mode, cannot perform '{operation_name}'"
2052                    ));
2053                }
2054
2055                if !self.record_action() {
2056                    return Err("Rate limit exceeded: action budget exhausted".to_string());
2057                }
2058
2059                Ok(())
2060            }
2061        }
2062    }
2063
2064    /// Record an action for the current sender and check if rate-limited.
2065    /// Returns `true` if allowed, `false` if budget exhausted.
2066    pub fn record_action(&self) -> bool {
2067        self.tracker.record_for_current(self.max_actions_per_hour)
2068    }
2069
2070    /// Check if the current sender would be rate-limited without recording.
2071    pub fn is_rate_limited(&self) -> bool {
2072        self.tracker
2073            .is_limited_for_current(self.max_actions_per_hour)
2074    }
2075
2076    /// Resolve a user-provided path for tool use.
2077    ///
2078    /// Expands `~` prefixes and resolves relative paths against the workspace
2079    /// directory. This should be called **after** `is_path_allowed` to obtain
2080    /// the filesystem path that the tool actually operates on.
2081    pub fn resolve_tool_path(&self, path: &str) -> PathBuf {
2082        let expanded = expand_user_path(path);
2083        if expanded.is_absolute() {
2084            expanded
2085        } else if let Some(workspace_hint) = rootless_path(&self.workspace_dir) {
2086            if let Ok(stripped) = expanded.strip_prefix(&workspace_hint) {
2087                if stripped.as_os_str().is_empty() {
2088                    self.workspace_dir.clone()
2089                } else {
2090                    self.workspace_dir.join(stripped)
2091                }
2092            } else {
2093                self.workspace_dir.join(expanded)
2094            }
2095        } else {
2096            self.workspace_dir.join(expanded)
2097        }
2098    }
2099
2100    /// Check whether the given raw path (before canonicalization)
2101    /// falls under an `allowed_roots` (read+write) OR
2102    /// `allowed_roots_write_only` entry. Tilde expansion is applied to
2103    /// the path before comparison. This is useful for tool-level
2104    /// pre-checks that want to allow absolute paths the policy
2105    /// explicitly permits to write.
2106    ///
2107    /// **Write-side semantics.** Use this from write-side tools
2108    /// (`file_write`, `git_operations`, shell). Read-side tools
2109    /// should use [`Self::is_under_any_allowed_root`] so a cross-agent
2110    /// `AccessMode::Read` grant allows the read.
2111    pub fn is_under_allowed_root(&self, path: &str) -> bool {
2112        let expanded = expand_user_path(path);
2113        if !expanded.is_absolute() {
2114            return false;
2115        }
2116        roots_contain(&self.allowed_roots, &expanded)
2117            || roots_contain(&self.allowed_roots_write_only, &expanded)
2118    }
2119
2120    /// Check whether the given raw path falls under a read-only allowed
2121    /// root. Returns false for the read-write list; callers that want
2122    /// the union should use [`Self::is_under_any_allowed_root`].
2123    ///
2124    /// Populated for multi-agent: an agent's `workspace.access`
2125    /// entries with `AccessMode::Read` become read-only roots on the
2126    /// policy.
2127    #[must_use]
2128    pub fn is_under_read_only_allowed_root(&self, path: &str) -> bool {
2129        let expanded = expand_user_path(path);
2130        if !expanded.is_absolute() {
2131            return false;
2132        }
2133        roots_contain(&self.allowed_roots_read_only, &expanded)
2134    }
2135
2136    /// Check whether the given raw path falls under
2137    /// `allowed_roots` (rw), `allowed_roots_read_only`, OR
2138    /// `allowed_roots_write_only`. Read-side tools (`file_read`,
2139    /// `pdf_read`, `glob_search`, `content_search`) call
2140    /// [`Self::is_resolved_path_readable`] for the resolved-path form,
2141    /// which intentionally excludes the write-only tier. This raw-path
2142    /// helper is the union of all three, used where read+write tools
2143    /// share an entry point and the resolved-path check splits the
2144    /// directionality afterward.
2145    #[must_use]
2146    pub fn is_under_any_allowed_root(&self, path: &str) -> bool {
2147        self.is_under_allowed_root(path) || self.is_under_read_only_allowed_root(path)
2148    }
2149
2150    /// Verify this policy does not escalate any permission beyond
2151    /// `parent` (SubAgent inheritance subset check).
2152    ///
2153    /// Subset rules:
2154    /// - Every `allowed_roots` entry on `self` must appear on
2155    ///   `parent.allowed_roots`. (Read+write grants can never be
2156    ///   wider than the parent's read+write list.)
2157    /// - Every `allowed_roots_read_only` entry on `self` must appear
2158    ///   on `parent.allowed_roots` OR on
2159    ///   `parent.allowed_roots_read_only`. (A SubAgent can downgrade
2160    ///   a parent's rw root to read-only, but it cannot grant read
2161    ///   access to a path the parent could not even read.)
2162    /// - Every `allowed_commands` entry on `self` must appear on
2163    ///   `parent.allowed_commands`.
2164    /// - `self.workspace_only` must be `true` whenever
2165    ///   `parent.workspace_only` is `true`. A SubAgent cannot disable
2166    ///   workspace_only when the parent enforces it.
2167    /// - `self.max_actions_per_hour <= parent.max_actions_per_hour`
2168    ///   and `self.max_cost_per_day_cents <=
2169    ///   parent.max_cost_per_day_cents`. A SubAgent cannot raise the
2170    ///   parent's rate or cost ceiling.
2171    ///
2172    /// Returns `Err(EscalationViolation)` describing the first
2173    /// violation found. Callers should reject the spawn on `Err` so
2174    /// a misconfigured override never lands as a constructed policy.
2175    pub fn ensure_no_escalation_beyond(
2176        &self,
2177        parent: &SecurityPolicy,
2178    ) -> Result<(), EscalationViolation> {
2179        // Autonomy: child must not exceed parent. ReadOnly < Supervised
2180        // < Full per the AutonomyLevel ordering.
2181        if self.autonomy > parent.autonomy {
2182            return Err(EscalationViolation::AutonomyAboveParent {
2183                child: self.autonomy,
2184                parent: parent.autonomy,
2185            });
2186        }
2187
2188        // Allowed roots: every child rw root must be CONTAINED in some
2189        // parent rw root (so a child of `/srv/app` under a parent of
2190        // `/srv` accepts; a child of `/srv` under a parent of
2191        // `/srv/app` does not). Containment, not exact equality, lets
2192        // the child legitimately narrow scope.
2193        for root in &self.allowed_roots {
2194            if !parent.allowed_roots.iter().any(|p| path_contains(p, root)) {
2195                return Err(EscalationViolation::ReadWriteRootNotInParent { path: root.clone() });
2196            }
2197        }
2198        for root in &self.allowed_roots_read_only {
2199            let in_parent_rw = parent.allowed_roots.iter().any(|p| path_contains(p, root));
2200            let in_parent_ro = parent
2201                .allowed_roots_read_only
2202                .iter()
2203                .any(|p| path_contains(p, root));
2204            if !in_parent_rw && !in_parent_ro {
2205                return Err(EscalationViolation::ReadOnlyRootNotInParent { path: root.clone() });
2206            }
2207        }
2208        for root in &self.allowed_roots_write_only {
2209            let in_parent_rw = parent.allowed_roots.iter().any(|p| path_contains(p, root));
2210            let in_parent_wo = parent
2211                .allowed_roots_write_only
2212                .iter()
2213                .any(|p| path_contains(p, root));
2214            if !in_parent_rw && !in_parent_wo {
2215                return Err(EscalationViolation::WriteOnlyRootNotInParent { path: root.clone() });
2216            }
2217        }
2218        for cmd in &self.allowed_commands {
2219            if !parent.allowed_commands.iter().any(|p| p == cmd) {
2220                return Err(EscalationViolation::CommandNotInParent {
2221                    command: cmd.clone(),
2222                });
2223            }
2224        }
2225        if parent.workspace_only && !self.workspace_only {
2226            return Err(EscalationViolation::WorkspaceOnlyDisabledByChild);
2227        }
2228
2229        // Forbidden paths run the OPPOSITE direction from allowlists:
2230        // the parent's forbidden set must be a subset of the child's,
2231        // i.e. the child cannot drop a parent's forbidden entry.
2232        for parent_forbidden in &parent.forbidden_paths {
2233            if !self.forbidden_paths.iter().any(|c| c == parent_forbidden) {
2234                return Err(EscalationViolation::ForbiddenPathDroppedByChild {
2235                    path: parent_forbidden.clone(),
2236                });
2237            }
2238        }
2239
2240        // shell_env_passthrough is a leak surface: every child entry
2241        // must already be on the parent's list.
2242        for var in &self.shell_env_passthrough {
2243            if !parent.shell_env_passthrough.iter().any(|p| p == var) {
2244                return Err(EscalationViolation::ShellEnvPassthroughExpanded {
2245                    variable: var.clone(),
2246                });
2247            }
2248        }
2249
2250        if self.max_actions_per_hour > parent.max_actions_per_hour {
2251            return Err(EscalationViolation::MaxActionsExceeded {
2252                child: self.max_actions_per_hour,
2253                parent: parent.max_actions_per_hour,
2254            });
2255        }
2256        if self.max_cost_per_day_cents > parent.max_cost_per_day_cents {
2257            return Err(EscalationViolation::MaxCostExceeded {
2258                child: self.max_cost_per_day_cents,
2259                parent: parent.max_cost_per_day_cents,
2260            });
2261        }
2262        if self.shell_timeout_secs > parent.shell_timeout_secs {
2263            return Err(EscalationViolation::ShellTimeoutExceeded {
2264                child: self.shell_timeout_secs,
2265                parent: parent.shell_timeout_secs,
2266            });
2267        }
2268        if parent.block_high_risk_commands && !self.block_high_risk_commands {
2269            return Err(EscalationViolation::BlockHighRiskCommandsDisabledByChild);
2270        }
2271        if parent.require_approval_for_medium_risk && !self.require_approval_for_medium_risk {
2272            return Err(EscalationViolation::RequireApprovalDisabledByChild);
2273        }
2274
2275        Ok(())
2276    }
2277
2278    /// Legacy entry point: build a `SecurityPolicy` from a risk profile
2279    /// without a runtime profile. Budget caps default to zero (interpreted
2280    /// as "no enforcement"). Tests and pre-multi-agent callsites use this;
2281    /// production code should call `from_profiles` or `for_agent` so the
2282    /// runtime profile's budget caps actually take effect.
2283    pub fn from_risk_profile(
2284        risk_profile: &crate::schema::RiskProfileConfig,
2285        workspace_dir: &Path,
2286    ) -> Self {
2287        Self::from_profiles(risk_profile, None, workspace_dir)
2288    }
2289
2290    /// Build a `SecurityPolicy` from a resolved risk + runtime profile pair.
2291    ///
2292    /// Authorization fields (autonomy level, allowlists, sandbox) come from
2293    /// the risk profile. Budget caps (`max_actions_per_hour`,
2294    /// `max_cost_per_day_cents`, `shell_timeout_secs`) come from the
2295    /// runtime profile but are enforced with parent-subset discipline on
2296    /// SubAgent spawn (see `ensure_no_escalation_beyond`).
2297    pub fn from_profiles(
2298        risk_profile: &crate::schema::RiskProfileConfig,
2299        runtime_profile: Option<&crate::schema::RuntimeProfileConfig>,
2300        workspace_dir: &Path,
2301    ) -> Self {
2302        // When autonomy is Full, disable workspace_only so the agent can
2303        // access paths outside the workspace. Forbidden-path checks still
2304        // apply, preventing access to sensitive system directories.
2305        // See issue #5463.
2306        let effective_workspace_only = if risk_profile.level == AutonomyLevel::Full {
2307            false
2308        } else {
2309            risk_profile.workspace_only
2310        };
2311
2312        let runtime_default = crate::schema::RuntimeProfileConfig::default();
2313        let runtime = runtime_profile.unwrap_or(&runtime_default);
2314
2315        Self {
2316            autonomy: risk_profile.level,
2317            workspace_dir: workspace_dir.to_path_buf(),
2318            workspace_only: effective_workspace_only,
2319            allowed_commands: risk_profile.allowed_commands.clone(),
2320            forbidden_paths: risk_profile.forbidden_paths.clone(),
2321            allowed_roots: risk_profile
2322                .allowed_roots
2323                .iter()
2324                .map(|root| {
2325                    let expanded = expand_user_path(root);
2326                    if expanded.is_absolute() {
2327                        expanded
2328                    } else {
2329                        workspace_dir.join(expanded)
2330                    }
2331                })
2332                .collect(),
2333            // RiskProfileConfig has no read-only or write-only roots
2334            // concept; the multi-agent runtime populates these lists
2335            // when it builds a per-agent policy from the
2336            // workspace.access map, turning `AccessMode::Read` and
2337            // `AccessMode::Write` entries into the corresponding
2338            // tiers.
2339            allowed_roots_read_only: Vec::new(),
2340            allowed_roots_write_only: Vec::new(),
2341            max_actions_per_hour: runtime.max_actions_per_hour,
2342            max_cost_per_day_cents: runtime.max_cost_per_day_cents,
2343            require_approval_for_medium_risk: risk_profile.require_approval_for_medium_risk,
2344            block_high_risk_commands: risk_profile.block_high_risk_commands,
2345            shell_env_passthrough: risk_profile.shell_env_passthrough.clone(),
2346            shell_timeout_secs: runtime.shell_timeout_secs,
2347            allowed_tools: if risk_profile.allowed_tools.is_empty() {
2348                None
2349            } else {
2350                Some(risk_profile.allowed_tools.clone())
2351            },
2352            excluded_tools: if risk_profile.excluded_tools.is_empty() {
2353                None
2354            } else {
2355                Some(risk_profile.excluded_tools.clone())
2356            },
2357            auto_approve: risk_profile.auto_approve.clone(),
2358            always_ask: risk_profile.always_ask.clone(),
2359            sandbox_enabled: risk_profile.sandbox_enabled,
2360            sandbox_backend: risk_profile.sandbox_backend.clone(),
2361            firejail_args: risk_profile.firejail_args.clone(),
2362            tracker: PerSenderTracker::new(),
2363        }
2364    }
2365
2366    /// Resolve the risk + runtime profiles owned by `agent_alias` and build
2367    /// a `SecurityPolicy`. Bails when the agent isn't configured or when its
2368    /// `risk_profile` field doesn't name a configured profile — there is no
2369    /// global fallback, every security context is per-agent. Missing
2370    /// `runtime_profile` falls back to zero budgets (treated as "inherit /
2371    /// no enforcement"), matching the previous default when the budget
2372    /// fields lived on the risk profile.
2373    pub fn for_agent(config: &crate::schema::Config, agent_alias: &str) -> anyhow::Result<Self> {
2374        let risk_profile = config.risk_profile_for_agent(agent_alias).ok_or_else(|| {
2375            ::zeroclaw_log::record!(
2376                ERROR,
2377                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
2378                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
2379                    .with_attrs(::serde_json::json!({"agent_alias": agent_alias})),
2380                "SecurityPolicy::for_agent: agent has no resolvable risk_profile"
2381            );
2382            anyhow::Error::msg(format!(
2383                "agents.{agent_alias} has no resolvable risk_profile (load-time validation should have caught this)"
2384            ))
2385        })?;
2386        let runtime_profile = config.runtime_profile_for_agent(agent_alias);
2387        // Per-agent workspace becomes the SecurityPolicy boundary so
2388        // file_read/write/edit and the shell tool jail to the agent's
2389        // own dir, not the install-wide legacy path.
2390        let agent_workspace = config.agent_workspace_dir(agent_alias);
2391        let mut policy = Self::from_profiles(risk_profile, runtime_profile, &agent_workspace);
2392
2393        // Shared skills directory: every agent reads from
2394        // `<install>/shared/skills/` so the `read_skills` tool resolves
2395        // bundle directories no matter which bundle the agent is
2396        // assigned. Read-only — bundle writes go through the SkillsService
2397        // (gateway/CLI/TUI), not through the agent's filesystem tools.
2398        // Archive root (`shared/skills/_deleted/`) is excluded to keep it
2399        // out of agent context.
2400        policy
2401            .allowed_roots_read_only
2402            .push(config.shared_workspace_dir().join("skills"));
2403
2404        // Cross-agent filesystem access: the agent's
2405        // [agents.<alias>.workspace.access] map declares which sibling
2406        // workspaces this agent may read or write. Resolve each
2407        // sibling's workspace dir and append to the appropriate
2408        // allowlist tier.
2409        if let Some(agent_cfg) = config.agents.get(agent_alias) {
2410            for (sibling_alias, mode) in &agent_cfg.workspace.access {
2411                let sibling_dir = config.agent_workspace_dir(sibling_alias.as_str());
2412                match mode {
2413                    crate::multi_agent::AccessMode::Read => {
2414                        policy.allowed_roots_read_only.push(sibling_dir);
2415                    }
2416                    crate::multi_agent::AccessMode::Write => {
2417                        policy.allowed_roots_write_only.push(sibling_dir);
2418                    }
2419                    crate::multi_agent::AccessMode::ReadWrite => {
2420                        policy.allowed_roots.push(sibling_dir);
2421                    }
2422                }
2423            }
2424
2425            // The escape-hatch flag retains its all-paths semantics —
2426            // agents that genuinely need to read or write outside any
2427            // per-agent scope opt in here. Defaults to false.
2428            if agent_cfg.workspace.unrestricted_filesystem {
2429                policy.workspace_only = false;
2430            }
2431        }
2432
2433        Ok(policy)
2434    }
2435
2436    /// Render a human-readable summary of the active security constraints
2437    /// suitable for injection into the LLM system prompt.
2438    ///
2439    /// Giving the LLM visibility into these constraints prevents it from
2440    /// wasting tokens on commands / paths that will be rejected at runtime.
2441    /// See issue #2404.
2442    pub fn prompt_summary(&self) -> String {
2443        use std::fmt::Write;
2444
2445        let mut out = String::new();
2446
2447        // Autonomy level
2448        let _ = writeln!(out, "**Autonomy level**: {:?}", self.autonomy);
2449
2450        // Workspace constraint
2451        if self.workspace_only {
2452            let _ = writeln!(
2453                out,
2454                "**Workspace boundary**: file operations are restricted to `{}`.",
2455                self.workspace_dir.display()
2456            );
2457        }
2458
2459        // Allowed roots
2460        if !self.allowed_roots.is_empty() {
2461            let roots: Vec<String> = self
2462                .allowed_roots
2463                .iter()
2464                .map(|p| format!("`{}`", p.display()))
2465                .collect();
2466            let _ = writeln!(out, "**Additional allowed paths**: {}", roots.join(", "));
2467        }
2468
2469        // Allowed commands
2470        if !self.allowed_commands.is_empty() {
2471            let cmds: Vec<String> = self
2472                .allowed_commands
2473                .iter()
2474                .map(|c| format!("`{c}`"))
2475                .collect();
2476            let _ = writeln!(
2477                out,
2478                "**Allowed shell commands**: {}. \
2479                 You may execute these commands freely.",
2480                cmds.join(", ")
2481            );
2482        }
2483
2484        // Forbidden paths
2485        if !self.forbidden_paths.is_empty() {
2486            let paths: Vec<String> = self
2487                .forbidden_paths
2488                .iter()
2489                .map(|p| format!("`{p}`"))
2490                .collect();
2491            let _ = writeln!(
2492                out,
2493                "**Forbidden paths**: {}. \
2494                 Avoid accessing these paths.",
2495                paths.join(", ")
2496            );
2497        }
2498
2499        // Risk controls
2500        if self.block_high_risk_commands {
2501            let _ = writeln!(
2502                out,
2503                "Exercise caution with destructive commands (rm, kill, reboot, etc.)."
2504            );
2505        }
2506        if self.require_approval_for_medium_risk {
2507            let _ = writeln!(
2508                out,
2509                "**Medium-risk commands** require user approval before execution."
2510            );
2511        }
2512
2513        // Rate limit
2514        let _ = writeln!(
2515            out,
2516            "**Rate limit**: max {} actions per hour per chat (each conversation has its own independent budget).",
2517            self.max_actions_per_hour
2518        );
2519
2520        out
2521    }
2522}
2523
2524#[cfg(test)]
2525mod tests {
2526    use super::*;
2527
2528    fn default_policy() -> SecurityPolicy {
2529        SecurityPolicy::default()
2530    }
2531
2532    // Platform-specific test paths: Unix uses `/…` paths, Windows uses
2533    // `C:\…` paths so that `Path::is_absolute()` returns the correct
2534    // value on each platform.
2535
2536    #[cfg(not(target_os = "windows"))]
2537    fn tp_ws() -> PathBuf {
2538        PathBuf::from("/home/user/.zeroclaw/workspace")
2539    }
2540    #[cfg(target_os = "windows")]
2541    fn tp_ws() -> PathBuf {
2542        PathBuf::from("C:\\Users\\user\\.zeroclaw\\workspace")
2543    }
2544
2545    #[cfg(not(target_os = "windows"))]
2546    fn tp_ws_shared() -> PathBuf {
2547        PathBuf::from("/home/user/.zeroclaw/shared")
2548    }
2549    #[cfg(target_os = "windows")]
2550    fn tp_ws_shared() -> PathBuf {
2551        PathBuf::from("C:\\Users\\user\\.zeroclaw\\shared")
2552    }
2553
2554    #[cfg(not(target_os = "windows"))]
2555    fn tp_outside1() -> &'static str {
2556        "/home/user/other/file.txt"
2557    }
2558    #[cfg(target_os = "windows")]
2559    fn tp_outside1() -> &'static str {
2560        "C:\\Users\\user\\other\\file.txt"
2561    }
2562
2563    #[cfg(not(target_os = "windows"))]
2564    fn tp_outside2() -> &'static str {
2565        "/tmp/file.txt"
2566    }
2567    #[cfg(target_os = "windows")]
2568    fn tp_outside2() -> &'static str {
2569        "C:\\Users\\Public\\file.txt"
2570    }
2571
2572    #[cfg(not(target_os = "windows"))]
2573    fn tp_sys() -> &'static str {
2574        "/etc"
2575    }
2576    #[cfg(target_os = "windows")]
2577    fn tp_sys() -> &'static str {
2578        "C:\\Windows\\System32"
2579    }
2580
2581    #[cfg(not(target_os = "windows"))]
2582    fn tp_sys_sub(sub: &str) -> String {
2583        format!("/{sub}")
2584    }
2585    #[cfg(target_os = "windows")]
2586    fn tp_sys_sub(sub: &str) -> String {
2587        format!("C:\\Windows\\{}", sub.replace('/', "\\"))
2588    }
2589
2590    #[cfg(not(target_os = "windows"))]
2591    fn tp_proj() -> PathBuf {
2592        PathBuf::from("/projects")
2593    }
2594    #[cfg(target_os = "windows")]
2595    fn tp_proj() -> PathBuf {
2596        PathBuf::from("C:\\projects")
2597    }
2598
2599    #[cfg(not(target_os = "windows"))]
2600    fn tp_data() -> PathBuf {
2601        PathBuf::from("/data")
2602    }
2603    #[cfg(target_os = "windows")]
2604    fn tp_data() -> PathBuf {
2605        PathBuf::from("C:\\data")
2606    }
2607
2608    #[cfg(not(target_os = "windows"))]
2609    fn tp_rw() -> PathBuf {
2610        PathBuf::from("/rw-data")
2611    }
2612    #[cfg(target_os = "windows")]
2613    fn tp_rw() -> PathBuf {
2614        PathBuf::from("C:\\rw-data")
2615    }
2616
2617    #[cfg(not(target_os = "windows"))]
2618    fn tp_ro() -> PathBuf {
2619        PathBuf::from("/ro-shared")
2620    }
2621    #[cfg(target_os = "windows")]
2622    fn tp_ro() -> PathBuf {
2623        PathBuf::from("C:\\ro-shared")
2624    }
2625
2626    // ── is_tool_allowed truth table ──────────────────────────
2627    //
2628    // None         → unrestricted: every name allowed
2629    // Some(vec![]) → deny-all: every name rejected
2630    // Some(list)   → allowlist: only listed names allowed
2631    // excluded_tools: subtracts from the allowed set even when allowlist matches
2632
2633    #[test]
2634    fn is_tool_allowed_none_is_unrestricted() {
2635        let p = SecurityPolicy {
2636            allowed_tools: None,
2637            excluded_tools: None,
2638            ..SecurityPolicy::default()
2639        };
2640        assert!(p.is_tool_allowed("shell"));
2641        assert!(p.is_tool_allowed("spawn_subagent"));
2642        assert!(p.is_tool_allowed("anything_else"));
2643    }
2644
2645    #[test]
2646    fn is_tool_allowed_some_empty_denies_all() {
2647        let p = SecurityPolicy {
2648            allowed_tools: Some(vec![]),
2649            ..SecurityPolicy::default()
2650        };
2651        assert!(!p.is_tool_allowed("shell"));
2652        assert!(!p.is_tool_allowed("spawn_subagent"));
2653    }
2654
2655    #[test]
2656    fn is_tool_allowed_allowlist_admits_only_listed() {
2657        let p = SecurityPolicy {
2658            allowed_tools: Some(vec!["shell".into(), "memory_recall".into()]),
2659            ..SecurityPolicy::default()
2660        };
2661        assert!(p.is_tool_allowed("shell"));
2662        assert!(p.is_tool_allowed("memory_recall"));
2663        assert!(!p.is_tool_allowed("spawn_subagent"));
2664        assert!(!p.is_tool_allowed("file_write"));
2665    }
2666
2667    #[test]
2668    fn is_tool_allowed_excluded_overrides_allowlist() {
2669        let p = SecurityPolicy {
2670            allowed_tools: Some(vec!["shell".into(), "spawn_subagent".into()]),
2671            excluded_tools: Some(vec!["spawn_subagent".into()]),
2672            ..SecurityPolicy::default()
2673        };
2674        assert!(p.is_tool_allowed("shell"));
2675        assert!(
2676            !p.is_tool_allowed("spawn_subagent"),
2677            "excluded_tools must subtract from allowlist"
2678        );
2679    }
2680
2681    #[test]
2682    fn is_tool_allowed_excluded_alone_subtracts_from_unrestricted() {
2683        let p = SecurityPolicy {
2684            allowed_tools: None,
2685            excluded_tools: Some(vec!["spawn_subagent".into()]),
2686            ..SecurityPolicy::default()
2687        };
2688        assert!(p.is_tool_allowed("shell"));
2689        assert!(!p.is_tool_allowed("spawn_subagent"));
2690    }
2691
2692    // ── from_profiles propagation coverage ────────────────────
2693    //
2694    // Every authorization-shaped field on RiskProfileConfig must reach
2695    // SecurityPolicy. The test constructs a config with non-default
2696    // values across the full field set and asserts each one landed.
2697    // New risk_profile fields without an assertion here are silently
2698    // dead config; that's the failure mode this test exists to prevent.
2699
2700    #[test]
2701    fn from_profiles_propagates_every_risk_profile_field() {
2702        use crate::schema::RiskProfileConfig;
2703        use std::path::Path;
2704
2705        let rp = RiskProfileConfig {
2706            level: AutonomyLevel::ReadOnly,
2707            workspace_only: true,
2708            allowed_commands: vec!["only_this".into()],
2709            forbidden_paths: vec!["/secret".into()],
2710            require_approval_for_medium_risk: false,
2711            block_high_risk_commands: false,
2712            shell_env_passthrough: vec!["EDITOR".into(), "PAGER".into()],
2713            auto_approve: vec!["memory_recall".into()],
2714            always_ask: vec!["shell".into()],
2715            allowed_roots: vec!["/tmp/extra".into()],
2716            allowed_tools: vec!["shell".into(), "memory_recall".into()],
2717            excluded_tools: vec!["spawn_subagent".into()],
2718            sandbox_enabled: Some(true),
2719            sandbox_backend: Some("firejail".into()),
2720            firejail_args: vec!["--net=none".into()],
2721        };
2722
2723        let policy = SecurityPolicy::from_profiles(&rp, None, Path::new("/ws"));
2724
2725        assert_eq!(policy.autonomy, AutonomyLevel::ReadOnly, "level → autonomy");
2726        assert!(policy.workspace_only, "workspace_only");
2727        assert_eq!(policy.allowed_commands, vec!["only_this".to_string()]);
2728        assert_eq!(policy.forbidden_paths, vec!["/secret".to_string()]);
2729        assert!(!policy.require_approval_for_medium_risk);
2730        assert!(!policy.block_high_risk_commands);
2731        assert_eq!(
2732            policy.shell_env_passthrough,
2733            vec!["EDITOR".to_string(), "PAGER".to_string()]
2734        );
2735        assert_eq!(
2736            policy.auto_approve,
2737            vec!["memory_recall".to_string()],
2738            "auto_approve must reach the policy"
2739        );
2740        assert_eq!(
2741            policy.always_ask,
2742            vec!["shell".to_string()],
2743            "always_ask must reach the policy"
2744        );
2745        assert!(
2746            policy.allowed_roots.iter().any(|p| p.ends_with("extra")),
2747            "allowed_roots expansion must reach the policy"
2748        );
2749        assert_eq!(
2750            policy.allowed_tools.as_deref(),
2751            Some(&["shell".to_string(), "memory_recall".to_string()][..]),
2752            "allowed_tools must reach the policy"
2753        );
2754        assert_eq!(
2755            policy.excluded_tools.as_deref(),
2756            Some(&["spawn_subagent".to_string()][..]),
2757            "excluded_tools must reach the policy"
2758        );
2759        assert_eq!(policy.sandbox_enabled, Some(true), "sandbox_enabled");
2760        assert_eq!(
2761            policy.sandbox_backend.as_deref(),
2762            Some("firejail"),
2763            "sandbox_backend"
2764        );
2765        assert_eq!(
2766            policy.firejail_args,
2767            vec!["--net=none".to_string()],
2768            "firejail_args"
2769        );
2770    }
2771
2772    /// The Full-autonomy override on `workspace_only` is intentional
2773    /// (issue #5463). The propagation test above sets ReadOnly so the
2774    /// override is dormant; this companion test pins the override path
2775    /// so a future refactor of from_profiles can't quietly remove it.
2776    #[test]
2777    fn from_profiles_full_autonomy_drops_workspace_only() {
2778        use crate::schema::RiskProfileConfig;
2779        use std::path::Path;
2780
2781        let rp = RiskProfileConfig {
2782            level: AutonomyLevel::Full,
2783            workspace_only: true,
2784            ..RiskProfileConfig::default()
2785        };
2786
2787        let policy = SecurityPolicy::from_profiles(&rp, None, Path::new("/ws"));
2788        assert!(
2789            !policy.workspace_only,
2790            "Full autonomy must drop workspace_only even when the profile sets it true"
2791        );
2792    }
2793
2794    #[test]
2795    fn from_profiles_with_runtime_profile_propagates_budget_caps() {
2796        use crate::schema::RuntimeProfileConfig;
2797        use std::path::Path;
2798
2799        let risk = crate::schema::RiskProfileConfig {
2800            level: AutonomyLevel::Supervised,
2801            ..crate::schema::RiskProfileConfig::default()
2802        };
2803        let runtime = RuntimeProfileConfig {
2804            max_actions_per_hour: 99,
2805            max_cost_per_day_cents: 1234,
2806            shell_timeout_secs: 300,
2807            ..RuntimeProfileConfig::default()
2808        };
2809
2810        let policy = SecurityPolicy::from_profiles(&risk, Some(&runtime), Path::new("/ws"));
2811
2812        assert_eq!(policy.max_actions_per_hour, 99);
2813        assert_eq!(policy.max_cost_per_day_cents, 1234);
2814        assert_eq!(policy.shell_timeout_secs, 300);
2815    }
2816
2817    #[test]
2818    fn from_profiles_without_runtime_profile_uses_defaults() {
2819        use std::path::Path;
2820
2821        let risk = crate::schema::RiskProfileConfig {
2822            level: AutonomyLevel::Supervised,
2823            ..crate::schema::RiskProfileConfig::default()
2824        };
2825
2826        let policy = SecurityPolicy::from_profiles(&risk, None, Path::new("/ws"));
2827
2828        assert_eq!(policy.max_actions_per_hour, 20);
2829        assert_eq!(policy.max_cost_per_day_cents, 500);
2830        assert_eq!(policy.shell_timeout_secs, 60);
2831    }
2832
2833    fn unix_forbidden_path_policy() -> SecurityPolicy {
2834        SecurityPolicy {
2835            workspace_dir: PathBuf::from("/workspace"),
2836            forbidden_paths: vec!["/dev".into(), "/etc".into()],
2837            ..SecurityPolicy::default()
2838        }
2839    }
2840
2841    fn readonly_policy() -> SecurityPolicy {
2842        SecurityPolicy {
2843            autonomy: AutonomyLevel::ReadOnly,
2844            ..SecurityPolicy::default()
2845        }
2846    }
2847
2848    fn full_policy() -> SecurityPolicy {
2849        SecurityPolicy {
2850            autonomy: AutonomyLevel::Full,
2851            ..SecurityPolicy::default()
2852        }
2853    }
2854
2855    // ── AutonomyLevel ────────────────────────────────────────
2856
2857    #[test]
2858    fn autonomy_default_is_supervised() {
2859        assert_eq!(AutonomyLevel::default(), AutonomyLevel::Supervised);
2860    }
2861
2862    #[test]
2863    fn autonomy_serde_roundtrip() {
2864        let json = serde_json::to_string(&AutonomyLevel::Full).unwrap();
2865        assert_eq!(json, "\"full\"");
2866        let parsed: AutonomyLevel = serde_json::from_str("\"readonly\"").unwrap();
2867        assert_eq!(parsed, AutonomyLevel::ReadOnly);
2868        let parsed2: AutonomyLevel = serde_json::from_str("\"supervised\"").unwrap();
2869        assert_eq!(parsed2, AutonomyLevel::Supervised);
2870    }
2871
2872    #[test]
2873    fn can_act_readonly_false() {
2874        assert!(!readonly_policy().can_act());
2875    }
2876
2877    #[test]
2878    fn can_act_supervised_true() {
2879        assert!(default_policy().can_act());
2880    }
2881
2882    #[test]
2883    fn can_act_full_true() {
2884        assert!(full_policy().can_act());
2885    }
2886
2887    #[test]
2888    fn enforce_tool_operation_read_allowed_in_readonly_mode() {
2889        let p = readonly_policy();
2890        assert!(
2891            p.enforce_tool_operation(ToolOperation::Read, "memory_recall")
2892                .is_ok()
2893        );
2894    }
2895
2896    #[test]
2897    fn enforce_tool_operation_act_blocked_in_readonly_mode() {
2898        let p = readonly_policy();
2899        let err = p
2900            .enforce_tool_operation(ToolOperation::Act, "memory_store")
2901            .unwrap_err();
2902        assert!(err.contains("read-only mode"));
2903    }
2904
2905    #[test]
2906    fn enforce_tool_operation_act_uses_rate_budget() {
2907        let p = SecurityPolicy {
2908            max_actions_per_hour: 0,
2909            ..default_policy()
2910        };
2911        let err = p
2912            .enforce_tool_operation(ToolOperation::Act, "memory_store")
2913            .unwrap_err();
2914        assert!(err.contains("Rate limit exceeded"));
2915    }
2916
2917    // ── is_command_allowed ───────────────────────────────────
2918
2919    #[test]
2920    fn allowed_commands_basic() {
2921        let p = default_policy();
2922        assert!(p.is_command_allowed("ls"));
2923        assert!(p.is_command_allowed("git status"));
2924        assert!(p.is_command_allowed("cargo build --release"));
2925        assert!(p.is_command_allowed("cat file.txt"));
2926        assert!(p.is_command_allowed("grep -r pattern ."));
2927        assert!(p.is_command_allowed("date"));
2928    }
2929
2930    #[test]
2931    fn blocked_commands_basic() {
2932        let p = default_policy();
2933        assert!(!p.is_command_allowed("rm -rf /"));
2934        assert!(!p.is_command_allowed("sudo apt install"));
2935        assert!(!p.is_command_allowed("curl http://evil.com"));
2936        assert!(!p.is_command_allowed("wget http://evil.com"));
2937        assert!(!p.is_command_allowed("ruby exploit.rb"));
2938        assert!(!p.is_command_allowed("perl malicious.pl"));
2939    }
2940
2941    #[test]
2942    fn readonly_blocks_all_commands() {
2943        let p = readonly_policy();
2944        assert!(!p.is_command_allowed("ls"));
2945        assert!(!p.is_command_allowed("cat file.txt"));
2946        assert!(!p.is_command_allowed("echo hello"));
2947    }
2948
2949    #[test]
2950    fn full_autonomy_still_uses_allowlist() {
2951        let p = full_policy();
2952        assert!(p.is_command_allowed("ls"));
2953        assert!(!p.is_command_allowed("rm -rf /"));
2954    }
2955
2956    #[test]
2957    fn command_with_absolute_path_extracts_basename() {
2958        let p = default_policy();
2959        assert!(p.is_command_allowed("/usr/bin/git status"));
2960        assert!(p.is_command_allowed("/bin/ls -la"));
2961    }
2962
2963    #[test]
2964    fn allowlist_supports_explicit_executable_paths() {
2965        let p = SecurityPolicy {
2966            allowed_commands: vec!["/usr/bin/antigravity".into()],
2967            ..SecurityPolicy::default()
2968        };
2969
2970        assert!(p.is_command_allowed("/usr/bin/antigravity"));
2971        assert!(!p.is_command_allowed("antigravity"));
2972    }
2973
2974    #[test]
2975    fn allowlist_supports_wildcard_entry() {
2976        let p = SecurityPolicy {
2977            allowed_commands: vec!["*".into()],
2978            ..SecurityPolicy::default()
2979        };
2980
2981        assert!(p.is_command_allowed("python3 --version"));
2982        assert!(p.is_command_allowed("/usr/bin/antigravity"));
2983
2984        // Wildcard still respects risk gates in validate_command_execution.
2985        let blocked = p.validate_command_execution("rm -rf /tmp/test", true);
2986        assert!(blocked.is_err());
2987        assert!(blocked.unwrap_err().contains("high-risk"));
2988    }
2989
2990    #[test]
2991    fn empty_command_blocked() {
2992        let p = default_policy();
2993        assert!(!p.is_command_allowed(""));
2994        assert!(!p.is_command_allowed("   "));
2995    }
2996
2997    #[test]
2998    fn command_with_pipes_validates_all_segments() {
2999        let p = default_policy();
3000        // Both sides of the pipe are in the allowlist
3001        assert!(p.is_command_allowed("ls | grep foo"));
3002        assert!(p.is_command_allowed("cat file.txt | wc -l"));
3003        // Second command not in allowlist — blocked
3004        assert!(!p.is_command_allowed("ls | curl http://evil.com"));
3005        assert!(!p.is_command_allowed("echo hello | ruby -"));
3006    }
3007
3008    #[test]
3009    fn custom_allowlist() {
3010        let p = SecurityPolicy {
3011            allowed_commands: vec!["docker".into(), "kubectl".into()],
3012            ..SecurityPolicy::default()
3013        };
3014        assert!(p.is_command_allowed("docker ps"));
3015        assert!(p.is_command_allowed("kubectl get pods"));
3016        assert!(!p.is_command_allowed("ls"));
3017        assert!(!p.is_command_allowed("git status"));
3018    }
3019
3020    #[test]
3021    fn empty_allowlist_blocks_everything() {
3022        let p = SecurityPolicy {
3023            allowed_commands: vec![],
3024            ..SecurityPolicy::default()
3025        };
3026        assert!(!p.is_command_allowed("ls"));
3027        assert!(!p.is_command_allowed("echo hello"));
3028    }
3029
3030    #[test]
3031    fn command_risk_low_for_read_commands() {
3032        let p = default_policy();
3033        assert_eq!(p.command_risk_level("git status"), CommandRiskLevel::Low);
3034        assert_eq!(p.command_risk_level("ls -la"), CommandRiskLevel::Low);
3035    }
3036
3037    #[test]
3038    fn command_risk_medium_for_mutating_commands() {
3039        let p = SecurityPolicy {
3040            allowed_commands: vec!["git".into(), "touch".into()],
3041            ..SecurityPolicy::default()
3042        };
3043        assert_eq!(
3044            p.command_risk_level("git reset --hard HEAD~1"),
3045            CommandRiskLevel::Medium
3046        );
3047        assert_eq!(
3048            p.command_risk_level("touch file.txt"),
3049            CommandRiskLevel::Medium
3050        );
3051    }
3052
3053    #[test]
3054    fn command_risk_high_for_dangerous_commands() {
3055        let p = SecurityPolicy {
3056            allowed_commands: vec!["rm".into()],
3057            ..SecurityPolicy::default()
3058        };
3059        assert_eq!(
3060            p.command_risk_level("rm -rf /tmp/test"),
3061            CommandRiskLevel::High
3062        );
3063    }
3064
3065    #[test]
3066    fn validate_command_requires_approval_for_medium_risk() {
3067        let p = SecurityPolicy {
3068            autonomy: AutonomyLevel::Supervised,
3069            require_approval_for_medium_risk: true,
3070            allowed_commands: vec!["touch".into()],
3071            ..SecurityPolicy::default()
3072        };
3073
3074        let denied = p.validate_command_execution("touch test.txt", false);
3075        assert!(denied.is_err());
3076        assert!(denied.unwrap_err().contains("requires explicit approval"),);
3077
3078        let allowed = p.validate_command_execution("touch test.txt", true);
3079        assert_eq!(allowed.unwrap(), CommandRiskLevel::Medium);
3080    }
3081
3082    #[test]
3083    fn validate_command_blocks_high_risk_via_wildcard() {
3084        // Wildcard allows the command through is_command_allowed, but
3085        // block_high_risk_commands still rejects it because "*" does not
3086        // count as an explicit allowlist entry.
3087        let p = SecurityPolicy {
3088            autonomy: AutonomyLevel::Supervised,
3089            allowed_commands: vec!["*".into()],
3090            ..SecurityPolicy::default()
3091        };
3092
3093        let result = p.validate_command_execution("rm -rf /tmp/test", true);
3094        assert!(result.is_err());
3095        assert!(result.unwrap_err().contains("high-risk"));
3096    }
3097
3098    #[test]
3099    fn validate_command_allows_explicitly_listed_high_risk() {
3100        // When a high-risk command is explicitly in allowed_commands, the
3101        // block_high_risk_commands gate is bypassed — the operator has made
3102        // a deliberate decision to permit it.
3103        let p = SecurityPolicy {
3104            autonomy: AutonomyLevel::Full,
3105            allowed_commands: vec!["curl".into()],
3106            block_high_risk_commands: true,
3107            ..SecurityPolicy::default()
3108        };
3109
3110        let result = p.validate_command_execution("curl https://api.example.com/data", true);
3111        assert_eq!(result.unwrap(), CommandRiskLevel::High);
3112    }
3113
3114    #[test]
3115    fn validate_command_allows_wget_when_explicitly_listed() {
3116        let p = SecurityPolicy {
3117            autonomy: AutonomyLevel::Full,
3118            allowed_commands: vec!["wget".into()],
3119            block_high_risk_commands: true,
3120            ..SecurityPolicy::default()
3121        };
3122
3123        let result =
3124            p.validate_command_execution("wget https://releases.example.com/v1.tar.gz", true);
3125        assert_eq!(result.unwrap(), CommandRiskLevel::High);
3126    }
3127
3128    #[test]
3129    fn validate_command_blocks_non_listed_high_risk_when_another_is_allowed() {
3130        // Allowing curl explicitly should not exempt wget.
3131        let p = SecurityPolicy {
3132            autonomy: AutonomyLevel::Full,
3133            allowed_commands: vec!["curl".into()],
3134            block_high_risk_commands: true,
3135            ..SecurityPolicy::default()
3136        };
3137
3138        let result = p.validate_command_execution("wget https://evil.com", true);
3139        assert!(result.is_err());
3140        assert!(result.unwrap_err().contains("not allowed"));
3141    }
3142
3143    #[test]
3144    fn validate_command_explicit_rm_bypasses_high_risk_block() {
3145        // Operator explicitly listed "rm" — they accept the risk.
3146        let p = SecurityPolicy {
3147            autonomy: AutonomyLevel::Full,
3148            allowed_commands: vec!["rm".into()],
3149            block_high_risk_commands: true,
3150            ..SecurityPolicy::default()
3151        };
3152
3153        let result = p.validate_command_execution("rm -rf /tmp/test", true);
3154        assert_eq!(result.unwrap(), CommandRiskLevel::High);
3155    }
3156
3157    #[test]
3158    fn validate_command_high_risk_still_needs_approval_in_supervised() {
3159        // Even when explicitly allowed, supervised mode still requires
3160        // approval for high-risk commands (the approval gate is separate
3161        // from the block gate).
3162        let p = SecurityPolicy {
3163            autonomy: AutonomyLevel::Supervised,
3164            allowed_commands: vec!["curl".into()],
3165            block_high_risk_commands: true,
3166            ..SecurityPolicy::default()
3167        };
3168
3169        let denied = p.validate_command_execution("curl https://api.example.com", false);
3170        assert!(denied.is_err());
3171        assert!(denied.unwrap_err().contains("requires explicit approval"));
3172
3173        let allowed = p.validate_command_execution("curl https://api.example.com", true);
3174        assert_eq!(allowed.unwrap(), CommandRiskLevel::High);
3175    }
3176
3177    #[test]
3178    fn validate_command_pipe_needs_all_segments_explicitly_allowed() {
3179        // When a pipeline contains a high-risk command, every segment
3180        // must be explicitly allowed for the exemption to apply.
3181        let p = SecurityPolicy {
3182            autonomy: AutonomyLevel::Full,
3183            allowed_commands: vec!["curl".into(), "grep".into()],
3184            block_high_risk_commands: true,
3185            ..SecurityPolicy::default()
3186        };
3187
3188        let result = p.validate_command_execution("curl https://api.example.com | grep data", true);
3189        assert_eq!(result.unwrap(), CommandRiskLevel::High);
3190    }
3191
3192    #[test]
3193    fn validate_command_full_mode_skips_medium_risk_approval_gate() {
3194        let p = SecurityPolicy {
3195            autonomy: AutonomyLevel::Full,
3196            require_approval_for_medium_risk: true,
3197            allowed_commands: vec!["touch".into()],
3198            ..SecurityPolicy::default()
3199        };
3200
3201        let result = p.validate_command_execution("touch test.txt", false);
3202        assert_eq!(result.unwrap(), CommandRiskLevel::Medium);
3203    }
3204
3205    #[test]
3206    fn validate_command_rejects_background_chain_bypass() {
3207        let p = default_policy();
3208        let result = p.validate_command_execution("ls & python3 -c 'print(1)'", false);
3209        assert!(result.is_err());
3210        assert!(result.unwrap_err().contains("not allowed"));
3211    }
3212
3213    // ── is_path_allowed ─────────────────────────────────────
3214
3215    #[test]
3216    fn relative_paths_allowed() {
3217        let p = default_policy();
3218        assert!(p.is_path_allowed("file.txt"));
3219        assert!(p.is_path_allowed("src/main.rs"));
3220        assert!(p.is_path_allowed("deep/nested/dir/file.txt"));
3221    }
3222
3223    #[test]
3224    fn path_traversal_blocked() {
3225        let p = default_policy();
3226        assert!(!p.is_path_allowed("../etc/passwd"));
3227        assert!(!p.is_path_allowed("../../root/.ssh/id_rsa"));
3228        assert!(!p.is_path_allowed("foo/../../../etc/shadow"));
3229        assert!(!p.is_path_allowed(".."));
3230    }
3231
3232    #[test]
3233    fn absolute_paths_blocked_when_workspace_only() {
3234        let p = default_policy();
3235        assert!(!p.is_path_allowed(&tp_sys_sub("etc/passwd")));
3236        assert!(!p.is_path_allowed(&tp_sys_sub("root/.ssh/id_rsa")));
3237        assert!(!p.is_path_allowed(tp_outside2()));
3238    }
3239
3240    #[test]
3241    fn absolute_path_inside_workspace_allowed_when_workspace_only() {
3242        let ws = tp_ws();
3243        let p = SecurityPolicy {
3244            workspace_dir: ws.clone(),
3245            workspace_only: true,
3246            ..SecurityPolicy::default()
3247        };
3248        assert!(p.is_path_allowed(&format!("{}/images/example.png", ws.display())));
3249        assert!(p.is_path_allowed(&format!("{}/file.txt", ws.display())));
3250        assert!(!p.is_path_allowed(tp_outside1()));
3251        assert!(!p.is_path_allowed(tp_outside2()));
3252    }
3253
3254    #[test]
3255    fn absolute_path_in_allowed_root_permitted_when_workspace_only() {
3256        let ws = tp_ws();
3257        let shared = tp_ws_shared();
3258        let p = SecurityPolicy {
3259            workspace_dir: ws.clone(),
3260            workspace_only: true,
3261            allowed_roots: vec![shared.clone()],
3262            ..SecurityPolicy::default()
3263        };
3264        assert!(p.is_path_allowed(&format!("{}/data.txt", shared.display())));
3265        assert!(p.is_path_allowed(&format!("{}/file.txt", ws.display())));
3266        assert!(!p.is_path_allowed(tp_outside1()));
3267    }
3268
3269    #[test]
3270    fn absolute_paths_allowed_when_not_workspace_only() {
3271        let p = SecurityPolicy {
3272            workspace_only: false,
3273            forbidden_paths: vec![],
3274            ..SecurityPolicy::default()
3275        };
3276        assert!(p.is_path_allowed("/tmp/file.txt"));
3277    }
3278
3279    #[test]
3280    fn forbidden_paths_blocked() {
3281        let p = SecurityPolicy {
3282            workspace_only: false,
3283            ..SecurityPolicy::default()
3284        };
3285        assert!(!p.is_path_allowed(&tp_sys_sub("etc/passwd")));
3286        assert!(!p.is_path_allowed(&tp_sys_sub("root/.bashrc")));
3287        assert!(!p.is_path_allowed("~/.ssh/id_rsa"));
3288        assert!(!p.is_path_allowed("~/.gnupg/pubring.kbx"));
3289    }
3290
3291    #[test]
3292    fn empty_path_allowed() {
3293        let p = default_policy();
3294        assert!(p.is_path_allowed(""));
3295    }
3296
3297    #[test]
3298    fn dotfile_in_workspace_allowed() {
3299        let p = default_policy();
3300        assert!(p.is_path_allowed(".gitignore"));
3301        assert!(p.is_path_allowed(".env"));
3302    }
3303
3304    // ── from_config ─────────────────────────────────────────
3305
3306    #[test]
3307    fn from_config_maps_all_fields() {
3308        let risk = crate::schema::RiskProfileConfig {
3309            level: AutonomyLevel::Full,
3310            workspace_only: false,
3311            allowed_commands: vec!["docker".into()],
3312            forbidden_paths: vec!["/secret".into()],
3313            require_approval_for_medium_risk: false,
3314            block_high_risk_commands: false,
3315            shell_env_passthrough: vec!["DATABASE_URL".into()],
3316            ..crate::schema::RiskProfileConfig::default()
3317        };
3318        let runtime = crate::schema::RuntimeProfileConfig {
3319            max_actions_per_hour: 100,
3320            max_cost_per_day_cents: 1000,
3321            ..crate::schema::RuntimeProfileConfig::default()
3322        };
3323        let workspace = PathBuf::from("/tmp/test-workspace");
3324        let policy = SecurityPolicy::from_profiles(&risk, Some(&runtime), &workspace);
3325
3326        assert_eq!(policy.autonomy, AutonomyLevel::Full);
3327        assert!(!policy.workspace_only);
3328        assert_eq!(policy.allowed_commands, vec!["docker"]);
3329        assert_eq!(policy.forbidden_paths, vec!["/secret"]);
3330        assert_eq!(policy.max_actions_per_hour, 100);
3331        assert_eq!(policy.max_cost_per_day_cents, 1000);
3332        assert!(!policy.require_approval_for_medium_risk);
3333        assert!(!policy.block_high_risk_commands);
3334        assert_eq!(policy.shell_env_passthrough, vec!["DATABASE_URL"]);
3335        assert_eq!(policy.workspace_dir, PathBuf::from("/tmp/test-workspace"));
3336    }
3337
3338    #[test]
3339    fn from_config_full_autonomy_overrides_workspace_only() {
3340        //: Full autonomy should disable workspace_only even if the
3341        // config default keeps it true.
3342        let autonomy_config = crate::schema::RiskProfileConfig {
3343            level: AutonomyLevel::Full,
3344            ..crate::schema::RiskProfileConfig::default()
3345        };
3346        let workspace = PathBuf::from("/tmp/test-workspace");
3347        let policy = SecurityPolicy::from_risk_profile(&autonomy_config, &workspace);
3348
3349        assert_eq!(policy.autonomy, AutonomyLevel::Full);
3350        assert!(
3351            !policy.workspace_only,
3352            "Full autonomy must override workspace_only to false"
3353        );
3354    }
3355
3356    #[test]
3357    fn from_config_supervised_preserves_workspace_only() {
3358        let autonomy_config = crate::schema::RiskProfileConfig {
3359            level: AutonomyLevel::Supervised,
3360            ..crate::schema::RiskProfileConfig::default()
3361        };
3362        let workspace = PathBuf::from("/tmp/test-workspace");
3363        let policy = SecurityPolicy::from_risk_profile(&autonomy_config, &workspace);
3364
3365        assert!(
3366            policy.workspace_only,
3367            "Supervised autonomy must preserve workspace_only default (true)"
3368        );
3369    }
3370
3371    #[test]
3372    fn from_config_normalizes_allowed_roots() {
3373        let autonomy_config = crate::schema::RiskProfileConfig {
3374            allowed_roots: vec!["~/Desktop".into(), "shared-data".into()],
3375            ..crate::schema::RiskProfileConfig::default()
3376        };
3377        let workspace = tp_ws();
3378        let policy = SecurityPolicy::from_risk_profile(&autonomy_config, &workspace);
3379
3380        let expected_home_root = if let Some(home) = home_dir() {
3381            home.join("Desktop")
3382        } else {
3383            PathBuf::from("~/Desktop")
3384        };
3385
3386        assert_eq!(policy.allowed_roots[0], expected_home_root);
3387        assert_eq!(policy.allowed_roots[1], workspace.join("shared-data"));
3388    }
3389
3390    #[test]
3391    fn resolved_path_violation_message_includes_allowed_roots_guidance() {
3392        let p = default_policy();
3393        let msg = p.resolved_path_violation_message(Path::new("/tmp/outside.txt"));
3394        assert!(msg.contains("escapes workspace"));
3395        assert!(msg.contains("allowed_roots"));
3396    }
3397
3398    // ── Default policy ──────────────────────────────────────
3399
3400    #[test]
3401    fn default_policy_has_sane_values() {
3402        let p = SecurityPolicy::default();
3403        assert_eq!(p.autonomy, AutonomyLevel::Supervised);
3404        assert!(p.workspace_only);
3405        assert!(!p.allowed_commands.is_empty());
3406        assert!(!p.forbidden_paths.is_empty());
3407        assert!(p.max_actions_per_hour > 0);
3408        assert!(p.max_cost_per_day_cents > 0);
3409        assert!(p.require_approval_for_medium_risk);
3410        assert!(p.block_high_risk_commands);
3411        assert!(p.shell_env_passthrough.is_empty());
3412    }
3413
3414    // ── ActionTracker / rate limiting ───────────────────────
3415
3416    #[test]
3417    fn action_tracker_starts_at_zero() {
3418        let tracker = ActionTracker::new();
3419        assert_eq!(tracker.count(), 0);
3420    }
3421
3422    #[test]
3423    fn action_tracker_records_actions() {
3424        let tracker = ActionTracker::new();
3425        assert_eq!(tracker.record(), 1);
3426        assert_eq!(tracker.record(), 2);
3427        assert_eq!(tracker.record(), 3);
3428        assert_eq!(tracker.count(), 3);
3429    }
3430
3431    #[test]
3432    fn record_action_allows_within_limit() {
3433        let p = SecurityPolicy {
3434            max_actions_per_hour: 5,
3435            ..SecurityPolicy::default()
3436        };
3437        for _ in 0..5 {
3438            assert!(p.record_action(), "should allow actions within limit");
3439        }
3440    }
3441
3442    #[test]
3443    fn record_action_blocks_over_limit() {
3444        let p = SecurityPolicy {
3445            max_actions_per_hour: 3,
3446            ..SecurityPolicy::default()
3447        };
3448        assert!(p.record_action()); // 1
3449        assert!(p.record_action()); // 2
3450        assert!(p.record_action()); // 3
3451        assert!(!p.record_action()); // 4 — over limit
3452    }
3453
3454    #[test]
3455    fn is_rate_limited_reflects_count() {
3456        let p = SecurityPolicy {
3457            max_actions_per_hour: 2,
3458            ..SecurityPolicy::default()
3459        };
3460        assert!(!p.is_rate_limited());
3461        p.record_action();
3462        assert!(!p.is_rate_limited());
3463        p.record_action();
3464        assert!(p.is_rate_limited());
3465    }
3466
3467    #[test]
3468    fn action_tracker_clone_is_independent() {
3469        let tracker = ActionTracker::new();
3470        tracker.record();
3471        tracker.record();
3472        let cloned = tracker.clone();
3473        assert_eq!(cloned.count(), 2);
3474        tracker.record();
3475        assert_eq!(tracker.count(), 3);
3476        assert_eq!(cloned.count(), 2); // clone is independent
3477    }
3478
3479    // ── Edge cases: command injection ────────────────────────
3480
3481    #[test]
3482    fn command_injection_semicolon_blocked() {
3483        let p = default_policy();
3484        // First word is "ls;" (with semicolon) — doesn't match "ls" in allowlist.
3485        // This is a safe default: chained commands are blocked.
3486        assert!(!p.is_command_allowed("ls; rm -rf /"));
3487    }
3488
3489    #[test]
3490    fn command_injection_semicolon_no_space() {
3491        let p = default_policy();
3492        assert!(!p.is_command_allowed("ls;rm -rf /"));
3493    }
3494
3495    #[test]
3496    fn quoted_semicolons_do_not_split_sqlite_command() {
3497        let p = SecurityPolicy {
3498            allowed_commands: vec!["sqlite3".into()],
3499            ..SecurityPolicy::default()
3500        };
3501        assert!(p.is_command_allowed(
3502            "sqlite3 /tmp/test.db \"CREATE TABLE t(id INT); INSERT INTO t VALUES(1); SELECT * FROM t;\""
3503        ));
3504        assert_eq!(
3505            p.command_risk_level(
3506                "sqlite3 /tmp/test.db \"CREATE TABLE t(id INT); INSERT INTO t VALUES(1); SELECT * FROM t;\""
3507            ),
3508            CommandRiskLevel::Low
3509        );
3510    }
3511
3512    #[test]
3513    fn unquoted_semicolon_after_quoted_sql_still_splits_commands() {
3514        let p = SecurityPolicy {
3515            allowed_commands: vec!["sqlite3".into()],
3516            ..SecurityPolicy::default()
3517        };
3518        assert!(!p.is_command_allowed("sqlite3 /tmp/test.db \"SELECT 1;\"; rm -rf /"));
3519    }
3520
3521    #[test]
3522    fn command_injection_backtick_blocked() {
3523        let p = default_policy();
3524        assert!(!p.is_command_allowed("echo `whoami`"));
3525        assert!(!p.is_command_allowed("echo `rm -rf /`"));
3526    }
3527
3528    #[test]
3529    fn command_injection_dollar_paren_blocked() {
3530        let p = default_policy();
3531        assert!(!p.is_command_allowed("echo $(cat /etc/passwd)"));
3532        assert!(!p.is_command_allowed("echo $(rm -rf /)"));
3533    }
3534
3535    #[test]
3536    fn command_injection_dollar_paren_literal_inside_single_quotes_allowed() {
3537        let p = default_policy();
3538        assert!(p.is_command_allowed("echo '$(cat /etc/passwd)'"));
3539    }
3540
3541    #[test]
3542    fn command_injection_dollar_brace_literal_inside_single_quotes_allowed() {
3543        let p = default_policy();
3544        assert!(p.is_command_allowed("echo '${HOME}'"));
3545    }
3546
3547    #[test]
3548    fn command_injection_dollar_brace_unquoted_blocked() {
3549        let p = default_policy();
3550        assert!(!p.is_command_allowed("echo ${HOME}"));
3551    }
3552
3553    #[test]
3554    fn command_with_env_var_prefix() {
3555        let p = default_policy();
3556        // "FOO=bar" is the first word — not in allowlist
3557        assert!(!p.is_command_allowed("FOO=bar rm -rf /"));
3558    }
3559
3560    #[test]
3561    fn command_newline_injection_blocked() {
3562        let p = default_policy();
3563        // Newline splits into two commands; "rm" is not in allowlist
3564        assert!(!p.is_command_allowed("ls\nrm -rf /"));
3565        // Both allowed — OK
3566        assert!(p.is_command_allowed("ls\necho hello"));
3567    }
3568
3569    #[test]
3570    fn command_injection_and_chain_blocked() {
3571        let p = default_policy();
3572        assert!(!p.is_command_allowed("ls && rm -rf /"));
3573        assert!(!p.is_command_allowed("echo ok && curl http://evil.com"));
3574        // Both allowed — OK
3575        assert!(p.is_command_allowed("ls && echo done"));
3576    }
3577
3578    #[test]
3579    fn command_injection_or_chain_blocked() {
3580        let p = default_policy();
3581        assert!(!p.is_command_allowed("ls || rm -rf /"));
3582        // Both allowed — OK
3583        assert!(p.is_command_allowed("ls || echo fallback"));
3584    }
3585
3586    #[test]
3587    fn command_injection_background_chain_blocked() {
3588        let p = default_policy();
3589        assert!(!p.is_command_allowed("ls & rm -rf /"));
3590        assert!(!p.is_command_allowed("ls&rm -rf /"));
3591        assert!(!p.is_command_allowed("echo ok & python3 -c 'print(1)'"));
3592    }
3593
3594    #[test]
3595    fn command_injection_redirect_blocked() {
3596        let p = default_policy();
3597        assert!(!p.is_command_allowed("echo secret > /etc/crontab"));
3598        assert!(!p.is_command_allowed("ls >> /tmp/exfil.txt"));
3599        assert!(!p.is_command_allowed("cat < /etc/passwd"));
3600        assert!(!p.is_command_allowed("echo secret > output.txt"));
3601        // Path-prefix bypass: /dev/null followed by extra path component
3602        assert!(!p.is_command_allowed("echo secret>/dev/nullextra"));
3603        assert!(!p.is_command_allowed("echo secret > /dev/null/../../etc/passwd"));
3604        assert!(!p.is_command_allowed("echo secret>/dev/stderrfoo"));
3605        // Word→non-word boundary bypasses
3606        assert!(!p.is_command_allowed("ls 2>/dev/stderr.log"));
3607        assert!(!p.is_command_allowed("cat>/dev/zero/path"));
3608        assert!(!p.is_command_allowed("echo>/dev/stdout.bak"));
3609    }
3610
3611    // ── Interpreter argument injection ────────────────────
3612
3613    #[test]
3614    fn interpreter_inline_eval_blocked() {
3615        let p = default_policy();
3616        // python: -c executes code string, -m runs arbitrary module
3617        assert!(!p.is_command_allowed("python3 -c 'import os; os.system(\"id\")'"));
3618        assert!(!p.is_command_allowed("python -c '__import__(\"os\").system(\"id\")'"));
3619        assert!(!p.is_command_allowed("python3 -m http.server"));
3620        assert!(!p.is_command_allowed("python3 -m pip install evil"));
3621        // Broad -m block: these are intentional collateral
3622        assert!(!p.is_command_allowed("python3 -m pytest"));
3623        assert!(!p.is_command_allowed("python3 -m mypy src/"));
3624        assert!(!p.is_command_allowed("python3 -m venv .venv"));
3625        // Glued form: -mhttp.server is one token
3626        assert!(!p.is_command_allowed("python3 -mhttp.server"));
3627        // node: -e/--eval evaluates JS, -p/--print evaluates and prints
3628        assert!(!p.is_command_allowed("node -e 'require(\"child_process\").execSync(\"id\")'"));
3629        assert!(!p.is_command_allowed("node --eval 'process.exit(1)'"));
3630        assert!(!p.is_command_allowed("node --eval=process.exit(1)"));
3631        assert!(!p.is_command_allowed("node -p '1+1'"));
3632        assert!(!p.is_command_allowed("node --print 'process.env'"));
3633        assert!(!p.is_command_allowed("node --print=process.env"));
3634        // Glued form bypass: -c'code' is one whitespace token
3635        assert!(!p.is_command_allowed("python3 -c'import os'"));
3636        assert!(!p.is_command_allowed("node -e'process.exit()'"));
3637        // Flag with other args before it
3638        assert!(!p.is_command_allowed("python3 -W ignore -c 'import os'"));
3639    }
3640
3641    #[test]
3642    fn package_manager_install_blocked() {
3643        let p = default_policy();
3644        // pip: install/download fetch external packages and run setup.py
3645        assert!(!p.is_command_allowed("pip install evil-package"));
3646        assert!(!p.is_command_allowed("pip3 install evil-package"));
3647        assert!(!p.is_command_allowed("pip download evil-package"));
3648        // npm: exec fetches remote, install runs lifecycle scripts
3649        assert!(!p.is_command_allowed("npm exec -- malicious-pkg"));
3650        assert!(!p.is_command_allowed("npm install malicious-pkg"));
3651        assert!(!p.is_command_allowed("npm i malicious-pkg"));
3652        assert!(!p.is_command_allowed("npm add malicious-pkg"));
3653        assert!(!p.is_command_allowed("npm ci"));
3654        // cargo: install fetches+builds external crate (build.rs runs arbitrary code)
3655        assert!(!p.is_command_allowed("cargo install malicious-crate"));
3656    }
3657
3658    #[test]
3659    fn safe_interpreter_usage_allowed() {
3660        let p = default_policy();
3661        // Running local files is safe — user trusts their workspace
3662        assert!(p.is_command_allowed("python3 script.py"));
3663        assert!(p.is_command_allowed("node app.js"));
3664        // Read-only / local workspace operations
3665        assert!(p.is_command_allowed("pip list"));
3666        assert!(p.is_command_allowed("pip freeze"));
3667        assert!(p.is_command_allowed("pip show requests"));
3668        assert!(p.is_command_allowed("npm test"));
3669        assert!(p.is_command_allowed("npm list"));
3670        assert!(p.is_command_allowed("cargo build"));
3671        assert!(p.is_command_allowed("cargo test"));
3672        assert!(p.is_command_allowed("cargo run"));
3673    }
3674
3675    #[test]
3676    fn safe_redirect_to_dev_null_allowed() {
3677        let p = default_policy();
3678        assert!(p.is_command_allowed("echo secret > /dev/null"));
3679        assert!(p.is_command_allowed("ls 2> /dev/null"));
3680        assert!(p.is_command_allowed("find . 2>&1 > /dev/null"));
3681        assert!(p.is_command_allowed("cat</dev/null"));
3682    }
3683
3684    #[test]
3685    fn safe_redirect_to_dev_stdout_allowed() {
3686        let p = default_policy();
3687        assert!(p.is_command_allowed("echo hello > /dev/stdout"));
3688        assert!(p.is_command_allowed("cat /dev/zero > /dev/stdout"));
3689    }
3690
3691    #[test]
3692    fn safe_redirect_to_dev_stderr_allowed() {
3693        let p = default_policy();
3694        assert!(p.is_command_allowed("echo error > /dev/stderr"));
3695        assert!(p.is_command_allowed("ls 1> /dev/stderr"));
3696    }
3697
3698    #[test]
3699    fn safe_redirect_to_dev_zero_allowed() {
3700        let p = default_policy();
3701        assert!(p.is_command_allowed("cat /dev/zero > /dev/null"));
3702    }
3703
3704    #[test]
3705    fn safe_file_descriptor_redirect_allowed() {
3706        let p = default_policy();
3707        assert!(p.is_command_allowed("find . 2>&1"));
3708        assert!(p.is_command_allowed("echo hello 1>&2"));
3709        assert!(p.is_command_allowed("ls 2>&1 > /dev/null"));
3710        // Bare fd redirects (implicit fd number)
3711        assert!(p.is_command_allowed("echo error >&2"));
3712        assert!(p.is_command_allowed("cat <&0"));
3713        assert!(p.is_command_allowed("echo >&-"));
3714        assert!(p.is_command_allowed("echo 3>&-"));
3715    }
3716
3717    #[test]
3718    fn heredoc_and_herestring_allowed() {
3719        let p = default_policy();
3720        assert!(p.is_command_allowed("cat << 'EOF'"));
3721        assert!(p.is_command_allowed("cat <<EOF"));
3722        assert!(p.is_command_allowed("cat <<< 'hello'"));
3723        // Input redirects from files still blocked
3724        assert!(!p.is_command_allowed("cat < /etc/passwd"));
3725        // Output redirects to files still blocked
3726        assert!(!p.is_command_allowed("echo secret > output.txt"));
3727    }
3728
3729    #[test]
3730    fn multiline_heredoc_allowed() {
3731        let p = default_policy();
3732        // Multiline heredoc body must not be split into separate segments that
3733        // fail the allowlist check on the body lines.
3734        assert!(p.is_command_allowed("cat <<EOF\nhello world\nEOF"));
3735        assert!(p.is_command_allowed("cat <<'EOF'\nhello world\nEOF"));
3736        assert!(p.is_command_allowed("cat << EOF\nhello world\nEOF"));
3737        // Quoted delimiter variant
3738        assert!(p.is_command_allowed("cat <<\"EOF\"\nhello world\nEOF"));
3739        // Heredoc followed by an allowed command is still two valid segments
3740        assert!(p.is_command_allowed("cat <<EOF\nhello\nEOF\necho done"));
3741        // Heredoc followed by a disallowed command must be blocked
3742        assert!(!p.is_command_allowed("cat <<EOF\nhello\nEOF\nrm -rf /"));
3743        // Unterminated heredoc — entire input stays as one segment (safe: cat is allowed).
3744        assert!(p.is_command_allowed("cat <<EOF\nhello world"));
3745    }
3746
3747    #[test]
3748    fn redirect_helper_unit_tests() {
3749        assert!(!contains_unquoted_input_redirect("cat << 'EOF'"));
3750        assert!(!contains_unquoted_input_redirect("cat <<< 'hello'"));
3751        assert!(contains_unquoted_input_redirect("cat < /etc/passwd"));
3752        assert!(!contains_unquoted_input_redirect("echo 'a<b'"));
3753        assert!(!contains_unquoted_input_redirect("cat</dev/null"));
3754        // Input redirect word→non-word bypass (same fix as output redirects)
3755        assert!(contains_unquoted_input_redirect("cat</dev/null.secret"));
3756        assert!(contains_unquoted_input_redirect(
3757            "cat </dev/zero/etc/passwd"
3758        ));
3759        assert!(!contains_unsafe_output_redirect("cmd 2>/dev/null"));
3760        assert!(!contains_unsafe_output_redirect("cmd >/dev/null"));
3761        assert!(!contains_unsafe_output_redirect("cmd 1>/dev/null"));
3762        assert!(!contains_unsafe_output_redirect("cmd 2>&1"));
3763        assert!(!contains_unsafe_output_redirect("cmd 1>&2"));
3764        assert!(!contains_unsafe_output_redirect("echo > /dev/stdout"));
3765        assert!(!contains_unsafe_output_redirect("echo > /dev/stderr"));
3766        assert!(!contains_unsafe_output_redirect("echo > /dev/zero"));
3767        assert!(contains_unsafe_output_redirect("echo hi > file.txt"));
3768        assert!(!contains_unsafe_output_redirect("echo 'a>b'"));
3769        // Word→non-word boundary bypasses: dot, slash, or other non-operator chars
3770        // after a safe device name must NOT strip the redirect
3771        assert!(contains_unsafe_output_redirect("ls 2>/dev/stderr.log"));
3772        assert!(contains_unsafe_output_redirect("cat>/dev/zero/path"));
3773        assert!(contains_unsafe_output_redirect("echo>/dev/stdout.bak"));
3774    }
3775
3776    #[test]
3777    fn quoted_ampersand_and_redirect_literals_are_not_treated_as_operators() {
3778        let p = default_policy();
3779        assert!(p.is_command_allowed("echo \"A&B\""));
3780        assert!(p.is_command_allowed("echo \"A>B\""));
3781        assert!(p.is_command_allowed("echo \"A<B\""));
3782    }
3783
3784    #[test]
3785    fn git_dash_c_uppercase_is_allowed() {
3786        // Regression test for #5809: git -C (change directory) must not be
3787        // conflated with git -c (set config override) after arg lowercasing.
3788        let p = default_policy();
3789        assert!(
3790            p.is_command_allowed("git -C /home/user/repo status --short"),
3791            "git -C is benign and should be allowed"
3792        );
3793        assert!(
3794            p.is_command_allowed("git -C /home/user/repo log --oneline -1"),
3795            "git -C with log should be allowed"
3796        );
3797        // git -c (lowercase) is still blocked — config override injection
3798        assert!(
3799            !p.is_command_allowed("git -c core.editor=\"rm -rf /\" commit"),
3800            "git -c must remain blocked"
3801        );
3802    }
3803
3804    #[test]
3805    fn command_argument_injection_blocked() {
3806        let p = default_policy();
3807        // find -exec is a common bypass
3808        assert!(!p.is_command_allowed("find . -exec rm -rf {} +"));
3809        assert!(!p.is_command_allowed("find / -ok cat {} \\;"));
3810        // git config/alias can execute commands
3811        assert!(!p.is_command_allowed("git config core.editor \"rm -rf /\""));
3812        assert!(!p.is_command_allowed("git alias.st status"));
3813        assert!(!p.is_command_allowed("git -c core.editor=calc.exe commit"));
3814        // Legitimate commands should still work
3815        assert!(p.is_command_allowed("find . -name '*.txt'"));
3816        assert!(p.is_command_allowed("git status"));
3817        assert!(p.is_command_allowed("git add ."));
3818    }
3819
3820    #[test]
3821    fn command_injection_dollar_brace_blocked() {
3822        let p = default_policy();
3823        assert!(!p.is_command_allowed("echo ${IFS}cat${IFS}/etc/passwd"));
3824    }
3825
3826    #[test]
3827    fn command_injection_plain_dollar_var_blocked() {
3828        let p = default_policy();
3829        assert!(!p.is_command_allowed("cat $HOME/.ssh/id_rsa"));
3830        assert!(!p.is_command_allowed("cat $SECRET_FILE"));
3831    }
3832
3833    #[test]
3834    fn command_injection_tee_blocked() {
3835        let p = default_policy();
3836        assert!(!p.is_command_allowed("echo secret | tee /etc/crontab"));
3837        assert!(!p.is_command_allowed("ls | /usr/bin/tee outfile"));
3838        assert!(!p.is_command_allowed("tee file.txt"));
3839    }
3840
3841    #[test]
3842    fn command_injection_process_substitution_blocked() {
3843        let p = default_policy();
3844        assert!(!p.is_command_allowed("cat <(echo pwned)"));
3845        assert!(!p.is_command_allowed("ls >(cat /etc/passwd)"));
3846    }
3847
3848    #[test]
3849    fn command_env_var_prefix_with_allowed_cmd() {
3850        let p = default_policy();
3851        // env assignment + allowed command — OK
3852        assert!(p.is_command_allowed("FOO=bar ls"));
3853        assert!(p.is_command_allowed("LANG=C grep pattern file"));
3854        // env assignment + disallowed command — blocked
3855        assert!(!p.is_command_allowed("FOO=bar rm -rf /"));
3856    }
3857
3858    #[test]
3859    fn forbidden_path_argument_detects_absolute_path() {
3860        let p = unix_forbidden_path_policy();
3861        assert_eq!(
3862            p.forbidden_path_argument("cat /etc/passwd"),
3863            Some("/etc/passwd".into())
3864        );
3865    }
3866
3867    #[test]
3868    fn forbidden_path_argument_detects_parent_dir_reference() {
3869        let p = default_policy();
3870        assert_eq!(
3871            p.forbidden_path_argument("cat ../secret.txt"),
3872            Some("../secret.txt".into())
3873        );
3874        assert_eq!(
3875            p.forbidden_path_argument("find .. -name '*.rs'"),
3876            Some("..".into())
3877        );
3878    }
3879
3880    #[test]
3881    fn forbidden_path_argument_allows_workspace_relative_paths() {
3882        let p = default_policy();
3883        assert_eq!(p.forbidden_path_argument("cat src/main.rs"), None);
3884        assert_eq!(p.forbidden_path_argument("grep -r todo ./src"), None);
3885    }
3886
3887    #[test]
3888    fn forbidden_path_argument_detects_option_assignment_paths() {
3889        let p = unix_forbidden_path_policy();
3890        assert_eq!(
3891            p.forbidden_path_argument("grep --file=/etc/passwd root ./src"),
3892            Some("/etc/passwd".into())
3893        );
3894        assert_eq!(
3895            p.forbidden_path_argument("cat --input=../secret.txt"),
3896            Some("../secret.txt".into())
3897        );
3898    }
3899
3900    #[test]
3901    fn forbidden_path_argument_allows_safe_option_assignment_paths() {
3902        let p = default_policy();
3903        assert_eq!(
3904            p.forbidden_path_argument("grep --file=./patterns.txt root ./src"),
3905            None
3906        );
3907    }
3908
3909    #[test]
3910    fn forbidden_path_argument_detects_short_option_attached_paths() {
3911        let p = unix_forbidden_path_policy();
3912        assert_eq!(
3913            p.forbidden_path_argument("grep -f/etc/passwd root ./src"),
3914            Some("/etc/passwd".into())
3915        );
3916        assert_eq!(
3917            p.forbidden_path_argument("git -C../outside status"),
3918            Some("../outside".into())
3919        );
3920    }
3921
3922    #[test]
3923    fn forbidden_path_argument_allows_safe_short_option_attached_paths() {
3924        let p = default_policy();
3925        assert_eq!(
3926            p.forbidden_path_argument("grep -f./patterns.txt root ./src"),
3927            None
3928        );
3929        assert_eq!(p.forbidden_path_argument("git -C./repo status"), None);
3930    }
3931
3932    #[test]
3933    fn forbidden_path_argument_detects_tilde_user_paths() {
3934        let p = default_policy();
3935        assert_eq!(
3936            p.forbidden_path_argument("cat ~root/.ssh/id_rsa"),
3937            Some("~root/.ssh/id_rsa".into())
3938        );
3939        assert_eq!(
3940            p.forbidden_path_argument("ls ~nobody"),
3941            Some("~nobody".into())
3942        );
3943    }
3944
3945    #[test]
3946    fn forbidden_path_argument_detects_input_redirection_paths() {
3947        let p = unix_forbidden_path_policy();
3948        assert_eq!(
3949            p.forbidden_path_argument("cat </etc/passwd"),
3950            Some("/etc/passwd".into())
3951        );
3952        assert_eq!(
3953            p.forbidden_path_argument("cat</etc/passwd"),
3954            Some("/etc/passwd".into())
3955        );
3956    }
3957
3958    #[test]
3959    fn forbidden_path_argument_allows_safe_device_redirect_targets() {
3960        let p = unix_forbidden_path_policy();
3961        assert_eq!(p.forbidden_path_argument("ls missing 2>/dev/null"), None);
3962        assert_eq!(p.forbidden_path_argument("ls missing 2> /dev/null"), None);
3963        assert_eq!(p.forbidden_path_argument("echo hi >/dev/stdout"), None);
3964        assert_eq!(p.forbidden_path_argument("echo hi > /dev/stdout"), None);
3965        assert_eq!(p.forbidden_path_argument("echo err 1>/dev/stderr"), None);
3966        assert_eq!(p.forbidden_path_argument("echo err 1> /dev/stderr"), None);
3967        assert_eq!(p.forbidden_path_argument("cat </dev/zero"), None);
3968        assert_eq!(p.forbidden_path_argument("cat < /dev/zero"), None);
3969        #[cfg(not(target_os = "windows"))]
3970        assert_eq!(p.forbidden_path_argument("cat /dev/null"), None);
3971        assert_eq!(p.forbidden_path_argument("cat ./safe.txt>/dev/null"), None);
3972        assert_eq!(p.forbidden_path_argument("cat> /dev/null"), None);
3973        assert_eq!(p.forbidden_path_argument("cat ./safe.txt>&2"), None);
3974    }
3975
3976    #[test]
3977    fn forbidden_path_argument_blocks_unsafe_redirect_targets() {
3978        let p = unix_forbidden_path_policy();
3979        assert_eq!(
3980            p.forbidden_path_argument("echo hi >/etc/passwd"),
3981            Some("/etc/passwd".into())
3982        );
3983        assert_eq!(
3984            p.forbidden_path_argument("echo hi > /etc/passwd"),
3985            Some("/etc/passwd".into())
3986        );
3987        assert_eq!(
3988            p.forbidden_path_argument("echo hi >/dev/stderr.log"),
3989            Some("/dev/stderr.log".into())
3990        );
3991        assert_eq!(
3992            p.forbidden_path_argument("echo hi > /dev/stderr.log"),
3993            Some("/dev/stderr.log".into())
3994        );
3995        assert_eq!(
3996            p.forbidden_path_argument("cat </dev/zero/etc/passwd"),
3997            Some("/dev/zero/etc/passwd".into())
3998        );
3999        assert_eq!(
4000            p.forbidden_path_argument("echo hi >/dev/null/../../etc/passwd"),
4001            Some("/dev/null/../../etc/passwd".into())
4002        );
4003        assert_eq!(
4004            p.forbidden_path_argument("cat</dev/null /etc/passwd"),
4005            Some("/etc/passwd".into())
4006        );
4007        assert_eq!(
4008            p.forbidden_path_argument("cat /etc/passwd>/dev/null"),
4009            Some("/etc/passwd".into())
4010        );
4011        assert_eq!(
4012            p.forbidden_path_argument("cat /etc/passwd> /dev/null"),
4013            Some("/etc/passwd".into())
4014        );
4015        assert_eq!(
4016            p.forbidden_path_argument("cat /etc/passwd>&2"),
4017            Some("/etc/passwd".into())
4018        );
4019        assert_eq!(
4020            p.forbidden_path_argument("grep --file=/etc/passwd>/dev/null root"),
4021            Some("/etc/passwd".into())
4022        );
4023    }
4024
4025    // ── Edge cases: path traversal ──────────────────────────
4026
4027    #[test]
4028    fn path_traversal_encoded_dots() {
4029        let p = default_policy();
4030        // Literal ".." in path — always blocked
4031        assert!(!p.is_path_allowed("foo/..%2f..%2fetc/passwd"));
4032    }
4033
4034    #[test]
4035    fn path_traversal_double_dot_in_filename() {
4036        let p = default_policy();
4037        // ".." in a filename (not a path component) is allowed
4038        assert!(p.is_path_allowed("my..file.txt"));
4039        // But actual traversal components are still blocked
4040        assert!(!p.is_path_allowed("../etc/passwd"));
4041        assert!(!p.is_path_allowed("foo/../etc/passwd"));
4042    }
4043
4044    #[test]
4045    fn path_with_null_byte_blocked() {
4046        let p = default_policy();
4047        assert!(!p.is_path_allowed("file\0.txt"));
4048    }
4049
4050    #[test]
4051    fn path_symlink_style_absolute() {
4052        let p = default_policy();
4053        assert!(!p.is_path_allowed(&tp_sys_sub("proc/self/root/etc/passwd")));
4054    }
4055
4056    #[test]
4057    fn path_home_tilde_ssh() {
4058        let p = SecurityPolicy {
4059            workspace_only: false,
4060            ..SecurityPolicy::default()
4061        };
4062        assert!(!p.is_path_allowed("~/.ssh/id_rsa"));
4063        assert!(!p.is_path_allowed("~/.gnupg/secring.gpg"));
4064        assert!(!p.is_path_allowed("~root/.ssh/id_rsa"));
4065        assert!(!p.is_path_allowed("~nobody"));
4066    }
4067
4068    #[test]
4069    fn path_var_run_blocked() {
4070        let p = SecurityPolicy {
4071            workspace_only: false,
4072            ..SecurityPolicy::default()
4073        };
4074        assert!(!p.is_path_allowed(&tp_sys_sub("var/run/docker.sock")));
4075    }
4076
4077    // ── Edge cases: rate limiter boundary ────────────────────
4078
4079    #[test]
4080    fn rate_limit_exactly_at_boundary() {
4081        let p = SecurityPolicy {
4082            max_actions_per_hour: 1,
4083            ..SecurityPolicy::default()
4084        };
4085        assert!(p.record_action()); // 1 — exactly at limit
4086        assert!(!p.record_action()); // 2 — over
4087        assert!(!p.record_action()); // 3 — still over
4088    }
4089
4090    #[test]
4091    fn rate_limit_zero_blocks_everything() {
4092        let p = SecurityPolicy {
4093            max_actions_per_hour: 0,
4094            ..SecurityPolicy::default()
4095        };
4096        assert!(!p.record_action());
4097    }
4098
4099    #[test]
4100    fn rate_limit_high_allows_many() {
4101        let p = SecurityPolicy {
4102            max_actions_per_hour: 10000,
4103            ..SecurityPolicy::default()
4104        };
4105        for _ in 0..100 {
4106            assert!(p.record_action());
4107        }
4108    }
4109
4110    // ── Edge cases: autonomy + command combos ────────────────
4111
4112    #[test]
4113    fn readonly_blocks_even_safe_commands() {
4114        let p = SecurityPolicy {
4115            autonomy: AutonomyLevel::ReadOnly,
4116            allowed_commands: vec!["ls".into(), "cat".into()],
4117            ..SecurityPolicy::default()
4118        };
4119        assert!(!p.is_command_allowed("ls"));
4120        assert!(!p.is_command_allowed("cat"));
4121        assert!(!p.can_act());
4122    }
4123
4124    #[test]
4125    fn supervised_allows_listed_commands() {
4126        let p = SecurityPolicy {
4127            autonomy: AutonomyLevel::Supervised,
4128            allowed_commands: vec!["git".into()],
4129            ..SecurityPolicy::default()
4130        };
4131        assert!(p.is_command_allowed("git status"));
4132        assert!(!p.is_command_allowed("docker ps"));
4133    }
4134
4135    #[test]
4136    fn full_autonomy_still_respects_forbidden_paths() {
4137        let p = SecurityPolicy {
4138            autonomy: AutonomyLevel::Full,
4139            workspace_only: false,
4140            ..SecurityPolicy::default()
4141        };
4142        assert!(!p.is_path_allowed(&tp_sys_sub("etc/shadow")));
4143        assert!(!p.is_path_allowed(&tp_sys_sub("root/.bashrc")));
4144    }
4145
4146    #[test]
4147    fn workspace_only_false_allows_resolved_outside_workspace() {
4148        let workspace = std::env::temp_dir().join("zeroclaw_test_ws_only_false");
4149        let _ = std::fs::create_dir_all(&workspace);
4150        let canonical_workspace = workspace
4151            .canonicalize()
4152            .unwrap_or_else(|_| workspace.clone());
4153
4154        let p = SecurityPolicy {
4155            workspace_dir: canonical_workspace.clone(),
4156            workspace_only: false,
4157            forbidden_paths: vec!["/etc".into(), "/var".into()],
4158            ..SecurityPolicy::default()
4159        };
4160
4161        // Path outside workspace should be allowed when workspace_only=false
4162        let outside = std::env::var_os("HOME")
4163            .map(std::path::PathBuf::from)
4164            .unwrap_or_else(|| PathBuf::from("/home"))
4165            .join("zeroclaw_outside_ws");
4166        assert!(
4167            p.is_resolved_path_allowed(&outside),
4168            "workspace_only=false must allow resolved paths outside workspace"
4169        );
4170
4171        // Forbidden paths must still be blocked even with workspace_only=false
4172        assert!(
4173            !p.is_resolved_path_allowed(Path::new("/etc/passwd")),
4174            "forbidden paths must be blocked even when workspace_only=false"
4175        );
4176        assert!(
4177            !p.is_resolved_path_allowed(Path::new("/var/run/docker.sock")),
4178            "forbidden /var must be blocked even when workspace_only=false"
4179        );
4180
4181        let _ = std::fs::remove_dir_all(&workspace);
4182    }
4183
4184    #[test]
4185    fn workspace_only_true_blocks_resolved_outside_workspace() {
4186        let workspace = std::env::temp_dir().join("zeroclaw_test_ws_only_true");
4187        let _ = std::fs::create_dir_all(&workspace);
4188        let canonical_workspace = workspace
4189            .canonicalize()
4190            .unwrap_or_else(|_| workspace.clone());
4191
4192        let p = SecurityPolicy {
4193            workspace_dir: canonical_workspace.clone(),
4194            workspace_only: true,
4195            ..SecurityPolicy::default()
4196        };
4197
4198        // Path inside workspace — allowed
4199        let inside = canonical_workspace.join("subdir");
4200        assert!(
4201            p.is_resolved_path_allowed(&inside),
4202            "path inside workspace must be allowed"
4203        );
4204
4205        // Path outside workspace — blocked
4206        let outside = std::env::temp_dir()
4207            .canonicalize()
4208            .unwrap_or_else(|_| std::env::temp_dir())
4209            .join("zeroclaw_outside_ws_true");
4210        assert!(
4211            !p.is_resolved_path_allowed(&outside),
4212            "workspace_only=true must block resolved paths outside workspace"
4213        );
4214
4215        let _ = std::fs::remove_dir_all(&workspace);
4216    }
4217
4218    // ── is_resolved_path_readable: read-only allowlist + POSIX devs ──
4219
4220    #[test]
4221    fn readable_includes_posix_device_files() {
4222        // /dev/null and friends are universally-readable system paths
4223        // operators expect to work for shell-idiom CLI tooling.
4224        let p = SecurityPolicy {
4225            workspace_dir: PathBuf::from("/tmp/zeroclaw-test-ws"),
4226            workspace_only: true,
4227            ..SecurityPolicy::default()
4228        };
4229        for device in ["/dev/null", "/dev/zero", "/dev/random", "/dev/urandom"] {
4230            assert!(
4231                p.is_resolved_path_readable(Path::new(device)),
4232                "POSIX device file {device} must be readable"
4233            );
4234        }
4235    }
4236
4237    #[test]
4238    fn readable_includes_read_only_allowlist_paths() {
4239        let tmp = tempfile::tempdir().unwrap();
4240        let read_only_root = tmp.path().join("docs");
4241        std::fs::create_dir_all(&read_only_root).unwrap();
4242        let inside = read_only_root.join("guide.md");
4243        std::fs::write(&inside, "x").unwrap();
4244
4245        let canonical_inside = inside.canonicalize().unwrap();
4246        let p = SecurityPolicy {
4247            workspace_dir: PathBuf::from("/tmp/elsewhere"),
4248            workspace_only: true,
4249            allowed_roots_read_only: vec![read_only_root.clone()],
4250            ..SecurityPolicy::default()
4251        };
4252        assert!(
4253            p.is_resolved_path_readable(&canonical_inside),
4254            "read-only allowlist entries must be readable"
4255        );
4256        // The same path is NOT writable (is_resolved_path_allowed is
4257        // strict-rw and does not consult allowed_roots_read_only).
4258        assert!(
4259            !p.is_resolved_path_allowed(&canonical_inside),
4260            "read-only allowlist entries must NOT be writable via is_resolved_path_allowed"
4261        );
4262    }
4263
4264    // ── for_agent: workspace.access populates allowlist tiers ──
4265
4266    #[test]
4267    fn for_agent_routes_workspace_access_into_correct_allowlist_tier() {
4268        use crate::multi_agent::{AccessMode, AgentAlias};
4269        use crate::schema::{AliasedAgentConfig, Config, RiskProfileConfig};
4270
4271        let mut cfg = Config {
4272            data_dir: PathBuf::from("/tmp/zeroclaw-for-agent-test"),
4273            config_path: PathBuf::from("/tmp/zeroclaw-for-agent-test/config.toml"),
4274            ..Config::default()
4275        };
4276        cfg.risk_profiles.insert(
4277            "default".into(),
4278            RiskProfileConfig {
4279                workspace_only: true,
4280                ..RiskProfileConfig::default()
4281            },
4282        );
4283
4284        // Sibling agents the test agent will reference.
4285        cfg.agents.insert(
4286            "writable_sibling".into(),
4287            AliasedAgentConfig {
4288                risk_profile: "default".into(),
4289                ..AliasedAgentConfig::default()
4290            },
4291        );
4292        cfg.agents.insert(
4293            "readonly_sibling".into(),
4294            AliasedAgentConfig {
4295                risk_profile: "default".into(),
4296                ..AliasedAgentConfig::default()
4297            },
4298        );
4299
4300        // Test agent: write access to one sibling, read-only to another.
4301        let mut test_agent = AliasedAgentConfig {
4302            risk_profile: "default".into(),
4303            ..AliasedAgentConfig::default()
4304        };
4305        test_agent
4306            .workspace
4307            .access
4308            .insert(AgentAlias::from("writable_sibling"), AccessMode::Write);
4309        test_agent
4310            .workspace
4311            .access
4312            .insert(AgentAlias::from("readonly_sibling"), AccessMode::Read);
4313        cfg.agents.insert("test_agent".into(), test_agent);
4314
4315        let policy = SecurityPolicy::for_agent(&cfg, "test_agent").unwrap();
4316
4317        let writable_sibling_dir = cfg.agent_workspace_dir("writable_sibling");
4318        let readonly_sibling_dir = cfg.agent_workspace_dir("readonly_sibling");
4319
4320        assert!(
4321            policy
4322                .allowed_roots_write_only
4323                .contains(&writable_sibling_dir),
4324            "AccessMode::Write must land in allowed_roots_write_only; got {:?}",
4325            policy.allowed_roots_write_only
4326        );
4327        assert!(
4328            !policy.allowed_roots.contains(&writable_sibling_dir),
4329            "AccessMode::Write must NOT land in allowed_roots (read+write tier); got {:?}",
4330            policy.allowed_roots
4331        );
4332        assert!(
4333            policy
4334                .allowed_roots_read_only
4335                .contains(&readonly_sibling_dir),
4336            "AccessMode::Read must land in allowed_roots_read_only; got {:?}",
4337            policy.allowed_roots_read_only
4338        );
4339        assert!(
4340            !policy
4341                .allowed_roots_read_only
4342                .contains(&writable_sibling_dir),
4343            "Write-mode entry must NOT also appear on the read-only list"
4344        );
4345        assert!(
4346            !policy
4347                .allowed_roots_write_only
4348                .contains(&readonly_sibling_dir),
4349            "Read-mode entry must NOT also appear on the write-only list"
4350        );
4351        assert!(
4352            policy.workspace_only,
4353            "unrestricted_filesystem stays default-false → workspace_only stays true"
4354        );
4355    }
4356
4357    #[test]
4358    fn write_only_root_blocks_reads_and_admits_writes() {
4359        // AccessMode::Write grants write access without read access.
4360        // is_resolved_path_allowed (write-side) must accept paths under
4361        // a write-only root; is_resolved_path_readable (read-side) must
4362        // refuse them.
4363        let mut policy = SecurityPolicy::default();
4364        let write_only_root =
4365            std::env::temp_dir().join(format!("zeroclaw_wo_root_{}", uuid::Uuid::new_v4()));
4366        std::fs::create_dir_all(&write_only_root).unwrap();
4367        let canonical = write_only_root.canonicalize().unwrap();
4368        policy.allowed_roots_write_only.push(canonical.clone());
4369        policy.workspace_only = false;
4370
4371        let target = canonical.join("write_only_target.txt");
4372        assert!(
4373            policy.is_resolved_path_allowed(&target),
4374            "write-only root must be writable via is_resolved_path_allowed"
4375        );
4376        assert!(
4377            !policy.is_resolved_path_readable(&target),
4378            "write-only root must NOT be readable via is_resolved_path_readable"
4379        );
4380
4381        let _ = std::fs::remove_dir_all(canonical);
4382    }
4383
4384    #[test]
4385    fn for_agent_unrestricted_filesystem_disables_workspace_only() {
4386        use crate::schema::{AliasedAgentConfig, Config, RiskProfileConfig};
4387
4388        let mut cfg = Config {
4389            data_dir: PathBuf::from("/tmp/zeroclaw-for-agent-unrestricted"),
4390            config_path: PathBuf::from("/tmp/zeroclaw-for-agent-unrestricted/config.toml"),
4391            ..Config::default()
4392        };
4393        cfg.risk_profiles.insert(
4394            "default".into(),
4395            RiskProfileConfig {
4396                workspace_only: true,
4397                ..RiskProfileConfig::default()
4398            },
4399        );
4400        let mut test_agent = AliasedAgentConfig {
4401            risk_profile: "default".into(),
4402            ..AliasedAgentConfig::default()
4403        };
4404        test_agent.workspace.unrestricted_filesystem = true;
4405        cfg.agents.insert("test_agent".into(), test_agent);
4406
4407        let policy = SecurityPolicy::for_agent(&cfg, "test_agent").unwrap();
4408
4409        assert!(
4410            !policy.workspace_only,
4411            "unrestricted_filesystem=true must flip workspace_only off at the policy level"
4412        );
4413    }
4414
4415    // ── Edge cases: from_config preserves tracker ────────────
4416
4417    #[test]
4418    fn from_config_creates_fresh_tracker() {
4419        let risk = crate::schema::RiskProfileConfig {
4420            level: AutonomyLevel::Full,
4421            workspace_only: false,
4422            allowed_commands: vec![],
4423            forbidden_paths: vec![],
4424            require_approval_for_medium_risk: true,
4425            block_high_risk_commands: true,
4426            ..crate::schema::RiskProfileConfig::default()
4427        };
4428        let runtime = crate::schema::RuntimeProfileConfig {
4429            max_actions_per_hour: 10,
4430            max_cost_per_day_cents: 100,
4431            ..crate::schema::RuntimeProfileConfig::default()
4432        };
4433        let workspace = PathBuf::from("/tmp/test");
4434        let policy = SecurityPolicy::from_profiles(&risk, Some(&runtime), &workspace);
4435        assert!(!policy.is_rate_limited());
4436    }
4437
4438    // ══════════════════════════════════════════════════════════
4439    // SECURITY CHECKLIST TESTS
4440    // Checklist: gateway not public, pairing required,
4441    //            filesystem scoped (no /), access via tunnel
4442    // ══════════════════════════════════════════════════════════
4443
4444    // ── Checklist #3: Filesystem scoped (no /) ──────────────
4445
4446    #[test]
4447    fn checklist_root_path_blocked() {
4448        let p = default_policy();
4449        assert!(!p.is_path_allowed(tp_sys()));
4450        assert!(!p.is_path_allowed(&tp_sys_sub("anything")));
4451    }
4452
4453    #[test]
4454    fn checklist_all_system_dirs_blocked() {
4455        let p = SecurityPolicy {
4456            workspace_only: false,
4457            ..SecurityPolicy::default()
4458        };
4459        #[cfg(not(target_os = "windows"))]
4460        {
4461            for dir in ["/etc", "/root", "/proc", "/sys", "/dev", "/var", "/tmp"] {
4462                assert!(
4463                    p.forbidden_paths.iter().any(|f| f == dir),
4464                    "Default forbidden_paths must include {dir} on Unix"
4465                );
4466                assert!(
4467                    !p.is_path_allowed(dir),
4468                    "System dir should be blocked: {dir}"
4469                );
4470            }
4471        }
4472        #[cfg(target_os = "windows")]
4473        {
4474            for dir in [
4475                "C:\\Windows",
4476                "C:\\Windows\\System32",
4477                "C:\\Program Files",
4478                "C:\\ProgramData",
4479            ] {
4480                assert!(
4481                    p.forbidden_paths.iter().any(|f| f == dir),
4482                    "Default forbidden_paths must include {dir} on Windows"
4483                );
4484                assert!(
4485                    !p.is_path_allowed(dir),
4486                    "System dir should be blocked: {dir}"
4487                );
4488            }
4489        }
4490        for dot in &["~/.ssh", "~/.gnupg", "~/.aws"] {
4491            assert!(
4492                p.forbidden_paths.iter().any(|f| f == dot),
4493                "Default forbidden_paths must include {dot}"
4494            );
4495            assert!(
4496                !p.is_path_allowed(dot),
4497                "Sensitive dotfile dir should be blocked: {dot}"
4498            );
4499        }
4500    }
4501
4502    #[test]
4503    fn checklist_sensitive_dotfiles_blocked() {
4504        let p = SecurityPolicy {
4505            workspace_only: false,
4506            ..SecurityPolicy::default()
4507        };
4508        for path in [
4509            "~/.ssh/id_rsa",
4510            "~/.gnupg/secring.gpg",
4511            "~/.aws/credentials",
4512            "~/.config/secrets",
4513        ] {
4514            assert!(
4515                !p.is_path_allowed(path),
4516                "Sensitive dotfile should be blocked: {path}"
4517            );
4518        }
4519    }
4520
4521    #[test]
4522    fn checklist_null_byte_injection_blocked() {
4523        let p = default_policy();
4524        assert!(!p.is_path_allowed("safe\0/../../../etc/passwd"));
4525        assert!(!p.is_path_allowed("\0"));
4526        assert!(!p.is_path_allowed("file\0"));
4527    }
4528
4529    #[test]
4530    fn checklist_workspace_only_blocks_absolute_outside_workspace() {
4531        let p = SecurityPolicy {
4532            workspace_only: true,
4533            ..SecurityPolicy::default()
4534        };
4535        assert!(!p.is_path_allowed(&tp_sys_sub("any/absolute/path")));
4536        assert!(p.is_path_allowed("relative/path.txt"));
4537    }
4538
4539    #[test]
4540    fn checklist_resolved_path_must_be_in_workspace() {
4541        let p = SecurityPolicy {
4542            workspace_dir: PathBuf::from("/home/user/project"),
4543            ..SecurityPolicy::default()
4544        };
4545        // Inside workspace — allowed
4546        assert!(p.is_resolved_path_allowed(Path::new("/home/user/project/src/main.rs")));
4547        // Outside workspace — blocked (symlink escape)
4548        assert!(!p.is_resolved_path_allowed(Path::new("/etc/passwd")));
4549        assert!(!p.is_resolved_path_allowed(Path::new("/home/user/other_project/file")));
4550        // Root — blocked
4551        assert!(!p.is_resolved_path_allowed(Path::new("/")));
4552    }
4553
4554    #[test]
4555    fn checklist_default_policy_is_workspace_only() {
4556        let p = SecurityPolicy::default();
4557        assert!(
4558            p.workspace_only,
4559            "Default policy must be workspace_only=true"
4560        );
4561    }
4562
4563    #[test]
4564    fn checklist_default_forbidden_paths_comprehensive() {
4565        let p = SecurityPolicy::default();
4566        #[cfg(not(target_os = "windows"))]
4567        {
4568            for dir in ["/etc", "/root", "/proc", "/sys", "/dev", "/var", "/tmp"] {
4569                assert!(
4570                    p.forbidden_paths.iter().any(|f| f == dir),
4571                    "Default forbidden_paths must include {dir} on Unix"
4572                );
4573            }
4574        }
4575        #[cfg(target_os = "windows")]
4576        {
4577            for dir in [
4578                "C:\\Windows",
4579                "C:\\Windows\\System32",
4580                "C:\\Program Files",
4581                "C:\\ProgramData",
4582            ] {
4583                assert!(
4584                    p.forbidden_paths.iter().any(|f| f == dir),
4585                    "Default forbidden_paths must include {dir} on Windows"
4586                );
4587            }
4588        }
4589        for dot in &["~/.ssh", "~/.gnupg", "~/.aws", "~/.config"] {
4590            assert!(
4591                p.forbidden_paths.iter().any(|f| f == dot),
4592                "Default forbidden_paths must include {dot}"
4593            );
4594        }
4595    }
4596
4597    // ── §1.2 Path resolution / symlink bypass tests ──────────
4598
4599    #[test]
4600    fn resolved_path_blocks_outside_workspace() {
4601        let workspace = std::env::temp_dir().join("zeroclaw_test_resolved_path");
4602        let _ = std::fs::create_dir_all(&workspace);
4603
4604        // Use the canonicalized workspace so starts_with checks match
4605        let canonical_workspace = workspace
4606            .canonicalize()
4607            .unwrap_or_else(|_| workspace.clone());
4608
4609        let policy = SecurityPolicy {
4610            workspace_dir: canonical_workspace.clone(),
4611            ..SecurityPolicy::default()
4612        };
4613
4614        // A resolved path inside the workspace should be allowed
4615        let inside = canonical_workspace.join("subdir").join("file.txt");
4616        assert!(
4617            policy.is_resolved_path_allowed(&inside),
4618            "path inside workspace should be allowed"
4619        );
4620
4621        // A resolved path outside the workspace should be blocked
4622        let canonical_temp = std::env::temp_dir()
4623            .canonicalize()
4624            .unwrap_or_else(|_| std::env::temp_dir());
4625        let outside = canonical_temp.join("outside_workspace_zeroclaw");
4626        assert!(
4627            !policy.is_resolved_path_allowed(&outside),
4628            "path outside workspace must be blocked"
4629        );
4630
4631        let _ = std::fs::remove_dir_all(&workspace);
4632    }
4633
4634    #[test]
4635    fn resolved_path_blocks_root_escape() {
4636        let policy = SecurityPolicy {
4637            workspace_dir: PathBuf::from("/home/zeroclaw_user/project"),
4638            ..SecurityPolicy::default()
4639        };
4640
4641        assert!(
4642            !policy.is_resolved_path_allowed(Path::new("/etc/passwd")),
4643            "resolved path to /etc/passwd must be blocked"
4644        );
4645        assert!(
4646            !policy.is_resolved_path_allowed(Path::new("/root/.bashrc")),
4647            "resolved path to /root/.bashrc must be blocked"
4648        );
4649    }
4650
4651    #[cfg(unix)]
4652    #[test]
4653    fn resolved_path_blocks_symlink_escape() {
4654        use std::os::unix::fs::symlink;
4655
4656        let root = std::env::temp_dir().join("zeroclaw_test_symlink_escape");
4657        let workspace = root.join("workspace");
4658        let outside = root.join("outside_target");
4659
4660        let _ = std::fs::remove_dir_all(&root);
4661        std::fs::create_dir_all(&workspace).unwrap();
4662        std::fs::create_dir_all(&outside).unwrap();
4663
4664        // Create a symlink inside workspace pointing outside
4665        let link_path = workspace.join("escape_link");
4666        symlink(&outside, &link_path).unwrap();
4667
4668        let policy = SecurityPolicy {
4669            workspace_dir: workspace.clone(),
4670            ..SecurityPolicy::default()
4671        };
4672
4673        // The resolved symlink target should be outside workspace
4674        let resolved = link_path.canonicalize().unwrap();
4675        assert!(
4676            !policy.is_resolved_path_allowed(&resolved),
4677            "symlink-resolved path outside workspace must be blocked"
4678        );
4679
4680        let _ = std::fs::remove_dir_all(&root);
4681    }
4682
4683    #[cfg(unix)]
4684    #[test]
4685    fn allowed_roots_permits_paths_outside_workspace() {
4686        use std::os::unix::fs::symlink;
4687
4688        let root = std::env::temp_dir().join("zeroclaw_test_allowed_roots");
4689        let workspace = root.join("workspace");
4690        let extra = root.join("extra_root");
4691        let extra_file = extra.join("data.txt");
4692
4693        let _ = std::fs::remove_dir_all(&root);
4694        std::fs::create_dir_all(&workspace).unwrap();
4695        std::fs::create_dir_all(&extra).unwrap();
4696        std::fs::write(&extra_file, "test").unwrap();
4697
4698        // Symlink inside workspace pointing to extra root
4699        let link_path = workspace.join("link_to_extra");
4700        symlink(&extra, &link_path).unwrap();
4701
4702        let resolved = link_path.join("data.txt").canonicalize().unwrap();
4703
4704        // Without allowed_roots — blocked (symlink escape)
4705        let policy_without = SecurityPolicy {
4706            workspace_dir: workspace.clone(),
4707            allowed_roots: vec![],
4708            ..SecurityPolicy::default()
4709        };
4710        assert!(
4711            !policy_without.is_resolved_path_allowed(&resolved),
4712            "without allowed_roots, symlink target must be blocked"
4713        );
4714
4715        // With allowed_roots — permitted
4716        let policy_with = SecurityPolicy {
4717            workspace_dir: workspace.clone(),
4718            allowed_roots: vec![extra.clone()],
4719            ..SecurityPolicy::default()
4720        };
4721        assert!(
4722            policy_with.is_resolved_path_allowed(&resolved),
4723            "with allowed_roots containing the target, symlink must be allowed"
4724        );
4725
4726        // Unrelated path still blocked
4727        let unrelated = root.join("unrelated");
4728        std::fs::create_dir_all(&unrelated).unwrap();
4729        assert!(
4730            !policy_with.is_resolved_path_allowed(&unrelated.canonicalize().unwrap()),
4731            "paths outside workspace and allowed_roots must still be blocked"
4732        );
4733
4734        let _ = std::fs::remove_dir_all(&root);
4735    }
4736
4737    #[test]
4738    fn is_path_allowed_blocks_null_bytes() {
4739        let policy = default_policy();
4740        assert!(
4741            !policy.is_path_allowed("file\0.txt"),
4742            "paths with null bytes must be blocked"
4743        );
4744    }
4745
4746    #[test]
4747    fn is_path_allowed_blocks_url_encoded_traversal() {
4748        let policy = default_policy();
4749        assert!(
4750            !policy.is_path_allowed("..%2fetc%2fpasswd"),
4751            "URL-encoded path traversal must be blocked"
4752        );
4753        assert!(
4754            !policy.is_path_allowed("subdir%2f..%2f..%2fetc"),
4755            "URL-encoded parent dir traversal must be blocked"
4756        );
4757    }
4758
4759    #[test]
4760    fn resolve_tool_path_expands_tilde() {
4761        let p = SecurityPolicy {
4762            workspace_dir: PathBuf::from("/workspace"),
4763            ..SecurityPolicy::default()
4764        };
4765        let resolved = p.resolve_tool_path("~/Documents/file.txt");
4766        // Should expand ~ to home dir, not join with workspace
4767        assert!(resolved.is_absolute());
4768        assert!(!resolved.starts_with("/workspace"));
4769        assert!(resolved.to_string_lossy().ends_with("Documents/file.txt"));
4770    }
4771
4772    #[test]
4773    fn resolve_tool_path_keeps_absolute() {
4774        let p = SecurityPolicy {
4775            workspace_dir: PathBuf::from("/workspace"),
4776            ..SecurityPolicy::default()
4777        };
4778        let resolved = p.resolve_tool_path("/some/absolute/path");
4779        assert_eq!(resolved, PathBuf::from("/some/absolute/path"));
4780    }
4781
4782    #[test]
4783    fn resolve_tool_path_joins_relative() {
4784        let p = SecurityPolicy {
4785            workspace_dir: PathBuf::from("/workspace"),
4786            ..SecurityPolicy::default()
4787        };
4788        let resolved = p.resolve_tool_path("relative/path.txt");
4789        assert_eq!(resolved, PathBuf::from("/workspace/relative/path.txt"));
4790    }
4791
4792    #[test]
4793    fn resolve_tool_path_normalizes_workspace_prefixed_relative_paths() {
4794        let p = SecurityPolicy {
4795            workspace_dir: PathBuf::from("/zeroclaw-data/workspace"),
4796            ..SecurityPolicy::default()
4797        };
4798        let resolved = p.resolve_tool_path("zeroclaw-data/workspace/scripts/daily.py");
4799        assert_eq!(
4800            resolved,
4801            PathBuf::from("/zeroclaw-data/workspace/scripts/daily.py")
4802        );
4803    }
4804
4805    #[test]
4806    fn is_under_allowed_root_matches_allowed_roots() {
4807        let p = SecurityPolicy {
4808            workspace_dir: tp_ws(),
4809            workspace_only: true,
4810            allowed_roots: vec![tp_proj(), tp_data()],
4811            ..SecurityPolicy::default()
4812        };
4813        assert!(p.is_under_allowed_root(&format!("{}/myapp/src/main.rs", tp_proj().display())));
4814        assert!(p.is_under_allowed_root(&format!("{}/file.csv", tp_data().display())));
4815        assert!(!p.is_under_allowed_root(&tp_sys_sub("etc/passwd")));
4816        assert!(!p.is_under_allowed_root("relative/path"));
4817    }
4818
4819    #[test]
4820    fn is_under_allowed_root_returns_false_for_empty_roots() {
4821        let p = SecurityPolicy {
4822            workspace_dir: tp_ws(),
4823            workspace_only: true,
4824            allowed_roots: vec![],
4825            ..SecurityPolicy::default()
4826        };
4827        assert!(!p.is_under_allowed_root(&format!("{}/any/path", tp_proj().display())));
4828    }
4829
4830    // ── SecurityPolicy read/read-write split ────────────────────────
4831
4832    #[test]
4833    fn is_under_read_only_allowed_root_matches_only_read_only_list() {
4834        let p = SecurityPolicy {
4835            workspace_dir: tp_ws(),
4836            workspace_only: true,
4837            allowed_roots: vec![tp_rw()],
4838            allowed_roots_read_only: vec![tp_ro()],
4839            ..SecurityPolicy::default()
4840        };
4841        assert!(p.is_under_read_only_allowed_root(&format!("{}/notes.md", tp_ro().display())));
4842        assert!(!p.is_under_read_only_allowed_root(&format!("{}/file.csv", tp_rw().display())));
4843        assert!(!p.is_under_read_only_allowed_root(&tp_sys_sub("etc/passwd")));
4844        assert!(!p.is_under_read_only_allowed_root("relative"));
4845    }
4846
4847    #[test]
4848    fn is_under_any_allowed_root_unions_read_only_and_read_write() {
4849        let p = SecurityPolicy {
4850            workspace_dir: tp_ws(),
4851            workspace_only: true,
4852            allowed_roots: vec![tp_rw()],
4853            allowed_roots_read_only: vec![tp_ro()],
4854            ..SecurityPolicy::default()
4855        };
4856        assert!(p.is_under_any_allowed_root(&format!("{}/file.csv", tp_rw().display())));
4857        assert!(p.is_under_any_allowed_root(&format!("{}/notes.md", tp_ro().display())));
4858        assert!(!p.is_under_any_allowed_root(&tp_sys_sub("etc/passwd")));
4859    }
4860
4861    #[test]
4862    fn is_under_allowed_root_does_not_see_read_only_entries() {
4863        let p = SecurityPolicy {
4864            workspace_dir: tp_ws(),
4865            workspace_only: true,
4866            allowed_roots: vec![],
4867            allowed_roots_read_only: vec![tp_ro()],
4868            ..SecurityPolicy::default()
4869        };
4870        assert!(!p.is_under_allowed_root(&format!("{}/notes.md", tp_ro().display())));
4871        assert!(p.is_under_any_allowed_root(&format!("{}/notes.md", tp_ro().display())));
4872    }
4873
4874    // ── SubAgent escalation validator ──────────────────────────────
4875
4876    fn parent_policy_for_escalation_tests() -> SecurityPolicy {
4877        SecurityPolicy {
4878            workspace_dir: PathBuf::from("/workspace"),
4879            workspace_only: true,
4880            allowed_roots: vec![PathBuf::from("/projects"), PathBuf::from("/data")],
4881            allowed_roots_read_only: vec![PathBuf::from("/shared-docs")],
4882            allowed_commands: vec!["git".into(), "cargo".into(), "ls".into()],
4883            max_actions_per_hour: 100,
4884            max_cost_per_day_cents: 500,
4885            ..SecurityPolicy::default()
4886        }
4887    }
4888
4889    #[test]
4890    fn ensure_no_escalation_accepts_identical_policy() {
4891        let parent = parent_policy_for_escalation_tests();
4892        let child = parent.clone();
4893        assert!(child.ensure_no_escalation_beyond(&parent).is_ok());
4894    }
4895
4896    #[test]
4897    fn ensure_no_escalation_accepts_narrowed_child() {
4898        let parent = parent_policy_for_escalation_tests();
4899        let child = SecurityPolicy {
4900            allowed_roots: vec![PathBuf::from("/projects")],
4901            allowed_roots_read_only: vec![PathBuf::from("/shared-docs")],
4902            allowed_commands: vec!["git".into()],
4903            max_actions_per_hour: 50,
4904            max_cost_per_day_cents: 250,
4905            ..parent.clone()
4906        };
4907        assert!(child.ensure_no_escalation_beyond(&parent).is_ok());
4908    }
4909
4910    #[test]
4911    fn ensure_no_escalation_accepts_rw_root_downgraded_to_read_only_on_child() {
4912        // A SubAgent giving up its write privilege is a narrowing,
4913        // not an escalation.
4914        let parent = parent_policy_for_escalation_tests();
4915        let child = SecurityPolicy {
4916            allowed_roots: Vec::new(),
4917            allowed_roots_read_only: vec![PathBuf::from("/projects")],
4918            ..parent.clone()
4919        };
4920        assert!(child.ensure_no_escalation_beyond(&parent).is_ok());
4921    }
4922
4923    #[test]
4924    fn ensure_no_escalation_rejects_new_rw_root_not_in_parent() {
4925        let parent = parent_policy_for_escalation_tests();
4926        let child = SecurityPolicy {
4927            allowed_roots: vec![PathBuf::from("/projects"), PathBuf::from("/secrets")],
4928            ..parent.clone()
4929        };
4930        let err = child
4931            .ensure_no_escalation_beyond(&parent)
4932            .expect_err("new rw root must be rejected");
4933        assert!(matches!(
4934            err,
4935            EscalationViolation::ReadWriteRootNotInParent { ref path }
4936            if path == &PathBuf::from("/secrets")
4937        ));
4938    }
4939
4940    #[test]
4941    fn ensure_no_escalation_rejects_new_read_only_root_not_in_parent() {
4942        let parent = parent_policy_for_escalation_tests();
4943        let child = SecurityPolicy {
4944            allowed_roots_read_only: vec![PathBuf::from("/etc")],
4945            ..parent.clone()
4946        };
4947        let err = child
4948            .ensure_no_escalation_beyond(&parent)
4949            .expect_err("new read-only root must be rejected");
4950        assert!(matches!(
4951            err,
4952            EscalationViolation::ReadOnlyRootNotInParent { ref path }
4953            if path == &PathBuf::from("/etc")
4954        ));
4955    }
4956
4957    #[test]
4958    fn ensure_no_escalation_rejects_new_command_not_in_parent() {
4959        let parent = parent_policy_for_escalation_tests();
4960        let child = SecurityPolicy {
4961            allowed_commands: vec!["git".into(), "rm".into()],
4962            ..parent.clone()
4963        };
4964        let err = child
4965            .ensure_no_escalation_beyond(&parent)
4966            .expect_err("new command must be rejected");
4967        assert!(matches!(
4968            err,
4969            EscalationViolation::CommandNotInParent { ref command }
4970            if command == "rm"
4971        ));
4972    }
4973
4974    #[test]
4975    fn ensure_no_escalation_rejects_workspace_only_disabled_by_child() {
4976        let parent = parent_policy_for_escalation_tests();
4977        let child = SecurityPolicy {
4978            workspace_only: false,
4979            ..parent.clone()
4980        };
4981        let err = child
4982            .ensure_no_escalation_beyond(&parent)
4983            .expect_err("disabling workspace_only when parent enforces it must be rejected");
4984        assert_eq!(err, EscalationViolation::WorkspaceOnlyDisabledByChild);
4985    }
4986
4987    #[test]
4988    fn ensure_no_escalation_rejects_higher_max_actions() {
4989        let parent = parent_policy_for_escalation_tests();
4990        let child = SecurityPolicy {
4991            max_actions_per_hour: 200,
4992            ..parent.clone()
4993        };
4994        let err = child
4995            .ensure_no_escalation_beyond(&parent)
4996            .expect_err("higher max_actions_per_hour must be rejected");
4997        assert!(matches!(
4998            err,
4999            EscalationViolation::MaxActionsExceeded { child, parent } if child == 200 && parent == 100
5000        ));
5001    }
5002
5003    #[test]
5004    fn ensure_no_escalation_rejects_higher_max_cost() {
5005        let parent = parent_policy_for_escalation_tests();
5006        let child = SecurityPolicy {
5007            max_cost_per_day_cents: 1000,
5008            ..parent.clone()
5009        };
5010        let err = child
5011            .ensure_no_escalation_beyond(&parent)
5012            .expect_err("higher max_cost_per_day_cents must be rejected");
5013        assert!(matches!(
5014            err,
5015            EscalationViolation::MaxCostExceeded { child, parent } if child == 1000 && parent == 500
5016        ));
5017    }
5018
5019    #[test]
5020    fn ensure_no_escalation_rejects_higher_autonomy() {
5021        let parent = SecurityPolicy {
5022            autonomy: AutonomyLevel::Supervised,
5023            ..parent_policy_for_escalation_tests()
5024        };
5025        let child = SecurityPolicy {
5026            autonomy: AutonomyLevel::Full,
5027            ..parent.clone()
5028        };
5029        let err = child
5030            .ensure_no_escalation_beyond(&parent)
5031            .expect_err("Full child under Supervised parent must be rejected");
5032        assert!(matches!(
5033            err,
5034            EscalationViolation::AutonomyAboveParent { child, parent }
5035            if child == AutonomyLevel::Full && parent == AutonomyLevel::Supervised
5036        ));
5037    }
5038
5039    #[test]
5040    fn ensure_no_escalation_accepts_subpath_narrowing_inside_parent_root() {
5041        // Parent grants /projects rw; child narrows to /projects/repo —
5042        // a containment relation, not exact equality. Must accept.
5043        let parent = parent_policy_for_escalation_tests();
5044        let child = SecurityPolicy {
5045            allowed_roots: vec![PathBuf::from("/projects/repo")],
5046            allowed_roots_read_only: vec![],
5047            ..parent.clone()
5048        };
5049        assert!(child.ensure_no_escalation_beyond(&parent).is_ok());
5050    }
5051
5052    #[test]
5053    fn ensure_no_escalation_rejects_dropped_forbidden_path() {
5054        let parent = SecurityPolicy {
5055            forbidden_paths: vec!["/etc/secrets".into(), "/root".into()],
5056            ..parent_policy_for_escalation_tests()
5057        };
5058        let child = SecurityPolicy {
5059            forbidden_paths: vec!["/root".into()],
5060            ..parent.clone()
5061        };
5062        let err = child
5063            .ensure_no_escalation_beyond(&parent)
5064            .expect_err("child dropping a parent's forbidden_paths entry must be rejected");
5065        assert!(matches!(
5066            err,
5067            EscalationViolation::ForbiddenPathDroppedByChild { ref path }
5068            if path == "/etc/secrets"
5069        ));
5070    }
5071
5072    #[test]
5073    fn ensure_no_escalation_rejects_expanded_shell_env_passthrough() {
5074        let parent = SecurityPolicy {
5075            shell_env_passthrough: vec!["PATH".into()],
5076            ..parent_policy_for_escalation_tests()
5077        };
5078        let child = SecurityPolicy {
5079            shell_env_passthrough: vec!["PATH".into(), "AWS_SECRET_ACCESS_KEY".into()],
5080            ..parent.clone()
5081        };
5082        let err = child
5083            .ensure_no_escalation_beyond(&parent)
5084            .expect_err("child adding a shell_env_passthrough entry must be rejected");
5085        assert!(matches!(
5086            err,
5087            EscalationViolation::ShellEnvPassthroughExpanded { ref variable }
5088            if variable == "AWS_SECRET_ACCESS_KEY"
5089        ));
5090    }
5091
5092    #[test]
5093    fn ensure_no_escalation_rejects_higher_shell_timeout() {
5094        let parent = SecurityPolicy {
5095            shell_timeout_secs: 30,
5096            ..parent_policy_for_escalation_tests()
5097        };
5098        let child = SecurityPolicy {
5099            shell_timeout_secs: 600,
5100            ..parent.clone()
5101        };
5102        let err = child
5103            .ensure_no_escalation_beyond(&parent)
5104            .expect_err("higher shell_timeout_secs must be rejected");
5105        assert!(matches!(
5106            err,
5107            EscalationViolation::ShellTimeoutExceeded { child, parent }
5108            if child == 600 && parent == 30
5109        ));
5110    }
5111
5112    #[test]
5113    fn ensure_no_escalation_rejects_disabled_block_high_risk_commands() {
5114        let parent = SecurityPolicy {
5115            block_high_risk_commands: true,
5116            ..parent_policy_for_escalation_tests()
5117        };
5118        let child = SecurityPolicy {
5119            block_high_risk_commands: false,
5120            ..parent.clone()
5121        };
5122        let err = child
5123            .ensure_no_escalation_beyond(&parent)
5124            .expect_err("child flipping block_high_risk_commands off must be rejected");
5125        assert_eq!(
5126            err,
5127            EscalationViolation::BlockHighRiskCommandsDisabledByChild
5128        );
5129    }
5130
5131    #[test]
5132    fn ensure_no_escalation_rejects_disabled_require_approval() {
5133        let parent = SecurityPolicy {
5134            require_approval_for_medium_risk: true,
5135            ..parent_policy_for_escalation_tests()
5136        };
5137        let child = SecurityPolicy {
5138            require_approval_for_medium_risk: false,
5139            ..parent.clone()
5140        };
5141        let err = child
5142            .ensure_no_escalation_beyond(&parent)
5143            .expect_err("child flipping require_approval_for_medium_risk off must be rejected");
5144        assert_eq!(err, EscalationViolation::RequireApprovalDisabledByChild);
5145    }
5146
5147    #[test]
5148    fn from_risk_profile_leaves_allowed_roots_read_only_empty() {
5149        // RiskProfileConfig has no read-only-roots concept; it's
5150        // populated by the multi-agent runtime when it builds the
5151        // per-agent policy from workspace.access.
5152        let profile = crate::schema::RiskProfileConfig {
5153            allowed_roots: vec!["/projects".to_string()],
5154            ..crate::schema::RiskProfileConfig::default()
5155        };
5156        let policy = SecurityPolicy::from_risk_profile(&profile, Path::new("/workspace"));
5157        assert_eq!(policy.allowed_roots, vec![PathBuf::from("/projects")]);
5158        assert!(
5159            policy.allowed_roots_read_only.is_empty(),
5160            "read-only roots come from workspace.access, not RiskProfileConfig"
5161        );
5162    }
5163
5164    #[test]
5165    fn runtime_config_paths_are_protected() {
5166        let workspace = PathBuf::from("/tmp/zeroclaw-profile/workspace");
5167        let policy = SecurityPolicy {
5168            workspace_dir: workspace.clone(),
5169            ..SecurityPolicy::default()
5170        };
5171        let config_dir = workspace.parent().unwrap();
5172
5173        assert!(policy.is_runtime_config_path(&config_dir.join("config.toml")));
5174        assert!(policy.is_runtime_config_path(&config_dir.join("config.toml.bak")));
5175        assert!(policy.is_runtime_config_path(&config_dir.join(".config.toml.tmp-1234")));
5176        // The active_workspace.toml marker file was retired with the
5177        // [workspace] block; protection is no longer required and not
5178        // claimed.
5179        assert!(!policy.is_runtime_config_path(&config_dir.join("active_workspace.toml")));
5180    }
5181
5182    #[test]
5183    fn workspace_files_are_not_runtime_config_paths() {
5184        let workspace = PathBuf::from("/tmp/zeroclaw-profile/workspace");
5185        let policy = SecurityPolicy {
5186            workspace_dir: workspace.clone(),
5187            ..SecurityPolicy::default()
5188        };
5189        let nested_dir = workspace.join("notes");
5190
5191        assert!(!policy.is_runtime_config_path(&workspace.join("notes.txt")));
5192        assert!(!policy.is_runtime_config_path(&nested_dir.join("config.toml")));
5193    }
5194
5195    // ── prompt_summary ──────────────────────────────────────
5196
5197    #[test]
5198    fn prompt_summary_includes_autonomy_level() {
5199        let p = default_policy();
5200        let summary = p.prompt_summary();
5201        assert!(
5202            summary.contains("Supervised"),
5203            "should mention autonomy level"
5204        );
5205    }
5206
5207    #[test]
5208    fn prompt_summary_includes_workspace_boundary_when_workspace_only() {
5209        let p = SecurityPolicy {
5210            workspace_dir: PathBuf::from("/home/user/project"),
5211            workspace_only: true,
5212            ..SecurityPolicy::default()
5213        };
5214        let summary = p.prompt_summary();
5215        assert!(
5216            summary.contains("Workspace boundary"),
5217            "should mention workspace boundary"
5218        );
5219        assert!(
5220            summary.contains("/home/user/project"),
5221            "should mention workspace path"
5222        );
5223    }
5224
5225    #[test]
5226    fn prompt_summary_omits_workspace_boundary_when_not_workspace_only() {
5227        let p = SecurityPolicy {
5228            workspace_only: false,
5229            ..SecurityPolicy::default()
5230        };
5231        let summary = p.prompt_summary();
5232        assert!(
5233            !summary.contains("Workspace boundary"),
5234            "should not mention workspace boundary"
5235        );
5236    }
5237
5238    #[test]
5239    fn prompt_summary_includes_allowed_commands() {
5240        let p = SecurityPolicy {
5241            allowed_commands: vec!["git".into(), "ls".into()],
5242            ..SecurityPolicy::default()
5243        };
5244        let summary = p.prompt_summary();
5245        assert!(summary.contains("`git`"), "should list allowed commands");
5246        assert!(summary.contains("`ls`"), "should list allowed commands");
5247        assert!(
5248            summary.contains("You may execute these commands freely"),
5249            "should mention allowed commands positively"
5250        );
5251    }
5252
5253    #[test]
5254    fn prompt_summary_includes_forbidden_paths() {
5255        let p = SecurityPolicy {
5256            workspace_only: false,
5257            forbidden_paths: vec!["/etc".into(), "~/.ssh".into()],
5258            ..SecurityPolicy::default()
5259        };
5260        let summary = p.prompt_summary();
5261        assert!(summary.contains("`/etc`"), "should list forbidden paths");
5262        assert!(summary.contains("`~/.ssh`"), "should list forbidden paths");
5263    }
5264
5265    #[test]
5266    fn prompt_summary_includes_rate_limit() {
5267        let p = SecurityPolicy {
5268            max_actions_per_hour: 42,
5269            ..SecurityPolicy::default()
5270        };
5271        let summary = p.prompt_summary();
5272        assert!(summary.contains("42"), "should mention rate limit");
5273        assert!(
5274            summary.contains("actions per hour"),
5275            "should explain rate limit"
5276        );
5277    }
5278
5279    #[test]
5280    fn prompt_summary_includes_risk_controls() {
5281        let p = SecurityPolicy {
5282            block_high_risk_commands: true,
5283            require_approval_for_medium_risk: true,
5284            ..SecurityPolicy::default()
5285        };
5286        let summary = p.prompt_summary();
5287        assert!(
5288            summary.contains("Exercise caution with destructive commands"),
5289            "should mention high-risk caution"
5290        );
5291        assert!(
5292            summary.contains("Medium-risk commands"),
5293            "should mention medium-risk approval"
5294        );
5295    }
5296
5297    #[test]
5298    fn prompt_summary_includes_allowed_roots() {
5299        let p = SecurityPolicy {
5300            allowed_roots: vec![PathBuf::from("/shared/data"), PathBuf::from("/opt/tools")],
5301            ..SecurityPolicy::default()
5302        };
5303        let summary = p.prompt_summary();
5304        assert!(
5305            summary.contains("`/shared/data`"),
5306            "should list allowed roots"
5307        );
5308        assert!(
5309            summary.contains("`/opt/tools`"),
5310            "should list allowed roots"
5311        );
5312    }
5313
5314    #[test]
5315    fn wildcard_with_block_high_risk_false_allows_everything() {
5316        let p = SecurityPolicy {
5317            allowed_commands: vec!["*".into()],
5318            block_high_risk_commands: false,
5319            workspace_only: false,
5320            ..SecurityPolicy::default()
5321        };
5322        assert!(
5323            p.validate_command_execution("rm -rf /tmp/test", true)
5324                .is_ok()
5325        );
5326        assert!(p.validate_command_execution("nohup firefox", true).is_ok());
5327        assert!(
5328            p.validate_command_execution("ls /usr/bin/firefox", true)
5329                .is_ok()
5330        );
5331    }
5332
5333    #[test]
5334    fn wildcard_with_block_high_risk_true_still_blocks() {
5335        // Ensure the existing safety net is preserved: wildcard + block_high_risk_commands=true
5336        // should still block high-risk commands.
5337        let p = SecurityPolicy {
5338            autonomy: AutonomyLevel::Supervised,
5339            allowed_commands: vec!["*".into()],
5340            block_high_risk_commands: true,
5341            ..SecurityPolicy::default()
5342        };
5343        let result = p.validate_command_execution("rm -rf /tmp/test", true);
5344        assert!(result.is_err());
5345        assert!(result.unwrap_err().contains("high-risk"));
5346    }
5347
5348    // ── Shell guard bypass with wildcard + unblocked ──────────
5349
5350    #[test]
5351    fn wildcard_unblocked_allows_backticks() {
5352        let p = SecurityPolicy {
5353            allowed_commands: vec!["*".into()],
5354            block_high_risk_commands: false,
5355            ..SecurityPolicy::default()
5356        };
5357        assert!(p.is_command_allowed("echo `whoami`"));
5358        assert!(p.is_command_allowed("ls `which git`"));
5359    }
5360
5361    #[test]
5362    fn wildcard_unblocked_allows_dollar_paren() {
5363        let p = SecurityPolicy {
5364            allowed_commands: vec!["*".into()],
5365            block_high_risk_commands: false,
5366            ..SecurityPolicy::default()
5367        };
5368        assert!(p.is_command_allowed("echo $(cat /etc/hostname)"));
5369        assert!(p.is_command_allowed("echo $(rm -rf /)"));
5370    }
5371
5372    #[test]
5373    fn wildcard_unblocked_allows_dollar_brace() {
5374        let p = SecurityPolicy {
5375            allowed_commands: vec!["*".into()],
5376            block_high_risk_commands: false,
5377            ..SecurityPolicy::default()
5378        };
5379        assert!(p.is_command_allowed("echo ${HOME}"));
5380        assert!(p.is_command_allowed("echo ${PATH}"));
5381    }
5382
5383    #[test]
5384    fn wildcard_unblocked_allows_process_substitution() {
5385        let p = SecurityPolicy {
5386            allowed_commands: vec!["*".into()],
5387            block_high_risk_commands: false,
5388            ..SecurityPolicy::default()
5389        };
5390        assert!(p.is_command_allowed("diff <(ls dir1) <(ls dir2)"));
5391        assert!(p.is_command_allowed("tee >(grep error > errors.log)"));
5392    }
5393
5394    #[test]
5395    fn wildcard_unblocked_allows_pipes_and_chains() {
5396        let p = SecurityPolicy {
5397            allowed_commands: vec!["*".into()],
5398            block_high_risk_commands: false,
5399            ..SecurityPolicy::default()
5400        };
5401        assert!(p.is_command_allowed("ps aux | grep python | wc -l"));
5402        assert!(p.is_command_allowed("echo hello && echo world"));
5403    }
5404
5405    #[test]
5406    fn wildcard_blocked_still_runs_shell_guard() {
5407        // allowed_commands=["*"] but block_high_risk_commands=true (default)
5408        // — the shell expansion guard must still fire.
5409        let p = SecurityPolicy {
5410            allowed_commands: vec!["*".into()],
5411            block_high_risk_commands: true,
5412            ..SecurityPolicy::default()
5413        };
5414        assert!(!p.is_command_allowed("echo `whoami`"));
5415        assert!(!p.is_command_allowed("echo $(cat /etc/passwd)"));
5416        assert!(!p.is_command_allowed("echo ${HOME}"));
5417        assert!(!p.is_command_allowed("diff <(ls dir1) <(ls dir2)"));
5418    }
5419
5420    #[test]
5421    fn specific_allowlist_still_runs_shell_guard() {
5422        // Non-wildcard allowlist — the guard must always run regardless
5423        // of block_high_risk_commands.
5424        let p = SecurityPolicy {
5425            allowed_commands: vec!["echo".into(), "ls".into(), "diff".into()],
5426            block_high_risk_commands: false,
5427            ..SecurityPolicy::default()
5428        };
5429        assert!(!p.is_command_allowed("echo `whoami`"));
5430        assert!(!p.is_command_allowed("echo $(cat /etc/passwd)"));
5431        assert!(!p.is_command_allowed("echo ${HOME}"));
5432        assert!(!p.is_command_allowed("diff <(ls dir1) <(ls dir2)"));
5433    }
5434
5435    #[test]
5436    fn specific_allowlist_with_block_true_still_runs_shell_guard() {
5437        let p = SecurityPolicy {
5438            allowed_commands: vec!["echo".into(), "ls".into()],
5439            block_high_risk_commands: true,
5440            ..SecurityPolicy::default()
5441        };
5442        assert!(!p.is_command_allowed("echo `whoami`"));
5443        assert!(!p.is_command_allowed("echo $(rm -rf /)"));
5444        assert!(!p.is_command_allowed("echo ${HOME}"));
5445    }
5446
5447    #[test]
5448    fn wildcard_unblocked_readonly_still_blocked() {
5449        // Even with wildcard + unblocked, ReadOnly trumps everything.
5450        let p = SecurityPolicy {
5451            autonomy: AutonomyLevel::ReadOnly,
5452            allowed_commands: vec!["*".into()],
5453            block_high_risk_commands: false,
5454            ..SecurityPolicy::default()
5455        };
5456        assert!(!p.is_command_allowed("ls"));
5457        assert!(!p.is_command_allowed("echo `whoami`"));
5458    }
5459
5460    #[test]
5461    fn per_sender_tracker_isolates_counts() {
5462        let t = PerSenderTracker::new();
5463        // sender A hits limit=2 on 3rd call
5464        assert!(t.record_within("chat_a", 2)); // count=1 ≤ 2 → ok
5465        assert!(t.record_within("chat_a", 2)); // count=2 ≤ 2 → ok
5466        assert!(!t.record_within("chat_a", 2)); // count=3 > 2 → blocked
5467        // sender B is unaffected — its bucket is empty
5468        assert!(t.record_within("chat_b", 2)); // count=1 ≤ 2 → ok
5469        assert!(t.record_within("chat_b", 2)); // count=2 ≤ 2 → ok
5470        assert!(!t.record_within("chat_b", 2)); // count=3 > 2 → blocked
5471    }
5472
5473    #[test]
5474    fn per_sender_tracker_global_key_fallback() {
5475        let t = PerSenderTracker::new();
5476        assert!(!t.is_exhausted(PerSenderTracker::GLOBAL_KEY, 1));
5477        t.record_within(PerSenderTracker::GLOBAL_KEY, u32::MAX);
5478        // after 1 action, count=1 ≥ 1 → exhausted at max=1
5479        assert!(t.is_exhausted(PerSenderTracker::GLOBAL_KEY, 1));
5480    }
5481
5482    #[test]
5483    fn per_sender_tracker_is_exhausted_reads_without_spurious_insert() {
5484        let t = PerSenderTracker::new();
5485        // Key "ghost" has never been recorded — should not be exhausted at max=1
5486        assert!(!t.is_exhausted("ghost", 1));
5487    }
5488}