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