Skip to main content

codewhale_execpolicy/
lib.rs

1pub mod bash_arity;
2
3use std::collections::HashSet;
4
5use anyhow::Result;
6use bash_arity::BashArityDict;
7use codewhale_protocol::{NetworkPolicyAmendment, NetworkPolicyRuleAction};
8use serde::{Deserialize, Serialize};
9
10/// Priority layer for a permission ruleset. Higher ordinal = higher priority.
11/// On conflict, the highest-priority layer's longest matching prefix wins.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum RulesetLayer {
15    BuiltinDefault = 0,
16    Agent = 1,
17    User = 2,
18}
19
20/// A named set of allow/deny prefix rules at a given priority layer.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct Ruleset {
23    /// Priority layer this ruleset belongs to.
24    pub layer: RulesetLayer,
25    /// Command prefixes that are allowed without requiring approval.
26    pub trusted_prefixes: Vec<String>,
27    /// Command prefixes that are always blocked, regardless of trust rules.
28    pub denied_prefixes: Vec<String>,
29    /// Typed rules that mark specific tool invocations as requiring approval.
30    #[serde(default, skip_serializing_if = "Vec::is_empty")]
31    pub ask_rules: Vec<ToolAskRule>,
32}
33
34impl Ruleset {
35    /// Creates an empty ruleset at the builtin default priority layer.
36    pub fn builtin_default() -> Self {
37        Self {
38            layer: RulesetLayer::BuiltinDefault,
39            trusted_prefixes: vec![],
40            denied_prefixes: vec![],
41            ask_rules: vec![],
42        }
43    }
44
45    /// Creates an agent-layer ruleset with the given trusted and denied prefixes.
46    pub fn agent(trusted: Vec<String>, denied: Vec<String>) -> Self {
47        Self {
48            layer: RulesetLayer::Agent,
49            trusted_prefixes: trusted,
50            denied_prefixes: denied,
51            ask_rules: vec![],
52        }
53    }
54
55    /// Creates a user-layer ruleset with the given trusted and denied prefixes.
56    pub fn user(trusted: Vec<String>, denied: Vec<String>) -> Self {
57        Self {
58            layer: RulesetLayer::User,
59            trusted_prefixes: trusted,
60            denied_prefixes: denied,
61            ask_rules: vec![],
62        }
63    }
64
65    /// Attaches typed ask rules to this ruleset and returns it.
66    pub fn with_ask_rules(mut self, ask_rules: Vec<ToolAskRule>) -> Self {
67        self.ask_rules = ask_rules;
68        self
69    }
70}
71
72/// Permission action for a tool invocation rule.
73#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
74#[serde(rename_all = "snake_case")]
75pub enum PermissionAction {
76    /// Allow the invocation without asking.
77    Allow,
78    /// Ask the user before allowing — the approval prompt is forced.
79    Ask,
80    /// Deny the invocation — the tool call is blocked.
81    Deny,
82}
83
84fn default_rule_action() -> PermissionAction {
85    PermissionAction::Ask
86}
87
88/// Typed rule that controls whether a tool invocation is denied, allowed, or requires approval.
89///
90/// The `action` field governs what happens when this rule matches:
91/// - `"deny"` — the tool call is blocked outright (highest priority).
92/// - `"ask"` — the approval prompt is forced (default, backward compatible).
93/// - `"allow"` — the tool call proceeds without asking.
94///
95/// Deny always wins over ask, which wins over allow.  Command-prefix-based
96/// deny and allow rules are promoted into the execution-policy engine's
97/// `denied_prefixes` / `trusted_prefixes` for arity-aware matching;
98/// path-only rules are evaluated separately.
99#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
100#[serde(deny_unknown_fields)]
101pub struct ToolAskRule {
102    /// Name of the tool this rule applies to (e.g. `"exec_shell"`, `"edit_file"`).
103    pub tool: String,
104    /// Optional command prefix to match against (uses arity-aware matching).
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub command: Option<String>,
107    /// Optional file path pattern to match against.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub path: Option<String>,
110    /// Action when this rule matches. Default: `"ask"` (backward compatible).
111    #[serde(default = "default_rule_action")]
112    pub action: PermissionAction,
113}
114
115impl ToolAskRule {
116    /// Creates a new ask rule matching any invocation of the given tool.
117    pub fn new(tool: impl Into<String>) -> Self {
118        Self {
119            tool: tool.into(),
120            command: None,
121            path: None,
122            action: PermissionAction::Ask,
123        }
124    }
125
126    /// Creates an ask rule for `exec_shell` matching a specific command prefix.
127    pub fn exec_shell(command: impl Into<String>) -> Self {
128        Self {
129            tool: "exec_shell".to_string(),
130            command: Some(command.into()),
131            path: None,
132            action: PermissionAction::Ask,
133        }
134    }
135
136    /// Creates an ask rule for a file-tool matching a specific path pattern.
137    pub fn file_path(tool: impl Into<String>, path: impl Into<String>) -> Self {
138        Self {
139            tool: tool.into(),
140            command: None,
141            path: Some(path.into()),
142            action: PermissionAction::Ask,
143        }
144    }
145
146    fn label(&self) -> String {
147        let mut parts = vec![format!("tool={}", self.tool)];
148        if let Some(command) = &self.command {
149            parts.push(format!("command={command}"));
150        }
151        if let Some(path) = &self.path {
152            parts.push(format!("path={path}"));
153        }
154        parts.join(" ")
155    }
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
159#[serde(rename_all = "snake_case")]
160/// Policy mode controlling when tool invocations require human approval.
161pub enum AskForApproval {
162    /// Skip approval if the command matches a trusted prefix; otherwise require it.
163    UnlessTrusted,
164    /// Allow execution and only request approval after a failure occurs.
165    OnFailure,
166    /// Always require approval before execution.
167    OnRequest,
168    /// Reject invocations outright based on specific criteria.
169    Reject {
170        /// Whether sandbox approval requests are rejected.
171        sandbox_approval: bool,
172        /// Whether rule-exception requests are rejected.
173        rules: bool,
174        /// Whether MCP elicitation requests are rejected.
175        mcp_elicitations: bool,
176    },
177    /// Never require approval; forbid commands that would need it.
178    Never,
179}
180
181/// A proposed amendment to the execution policy, suggesting new trusted prefixes.
182#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
183pub struct ExecPolicyAmendment {
184    /// Command prefixes to add to the trusted list.
185    pub prefixes: Vec<String>,
186}
187
188/// The approval requirement determined by the execution policy engine.
189#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
190pub enum ExecApprovalRequirement {
191    /// Execution is allowed without approval.
192    Skip {
193        /// Whether the sandbox should be bypassed for this execution.
194        bypass_sandbox: bool,
195        /// Optional proposed policy amendment (e.g., to persist the allowed prefix).
196        proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
197    },
198    /// Execution is allowed but requires human approval first.
199    NeedsApproval {
200        /// Human-readable reason explaining why approval is needed.
201        reason: String,
202        /// Optional proposed policy amendment that would be applied on approval.
203        proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
204        /// Proposed network policy amendments that would be applied on approval.
205        proposed_network_policy_amendments: Vec<NetworkPolicyAmendment>,
206    },
207    /// Execution is forbidden by policy.
208    Forbidden {
209        /// Human-readable reason explaining why execution is forbidden.
210        reason: String,
211    },
212}
213
214impl ExecApprovalRequirement {
215    /// Returns the human-readable reason for this approval requirement.
216    pub fn reason(&self) -> &str {
217        match self {
218            ExecApprovalRequirement::Skip { .. } => "Execution allowed by policy.",
219            ExecApprovalRequirement::NeedsApproval { reason, .. } => reason,
220            ExecApprovalRequirement::Forbidden { reason } => reason,
221        }
222    }
223
224    /// Returns a short phase label: `"allowed"`, `"needs_approval"`, or `"forbidden"`.
225    pub fn phase(&self) -> &'static str {
226        match self {
227            ExecApprovalRequirement::Skip { .. } => "allowed",
228            ExecApprovalRequirement::NeedsApproval { .. } => "needs_approval",
229            ExecApprovalRequirement::Forbidden { .. } => "forbidden",
230        }
231    }
232}
233
234/// The result of evaluating a command against the execution policy.
235#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
236pub struct ExecPolicyDecision {
237    /// Whether the command is allowed to execute.
238    pub allow: bool,
239    /// Whether human approval is required before execution.
240    pub requires_approval: bool,
241    /// The detailed approval requirement, including any proposed amendments.
242    pub requirement: ExecApprovalRequirement,
243    /// The rule that matched, if any (e.g. a trusted prefix or ask rule label).
244    pub matched_rule: Option<String>,
245    /// The action of the matched ask-rule, if the match came from a
246    /// `ToolAskRule` rather than a prefix.  `None` for prefix matches.
247    pub matched_action: Option<PermissionAction>,
248}
249
250impl ExecPolicyDecision {
251    /// Returns the human-readable reason for this decision.
252    pub fn reason(&self) -> &str {
253        self.requirement.reason()
254    }
255}
256
257/// Input context provided to the execution policy engine for a single check.
258#[derive(Debug, Clone)]
259pub struct ExecPolicyContext<'a> {
260    /// The shell command string being evaluated.
261    pub command: &'a str,
262    /// The current working directory at invocation time.
263    pub cwd: &'a str,
264    /// The tool name (e.g. `"exec_shell"`, `"edit_file"`). Defaults to `"exec_shell"` when `None`.
265    pub tool: Option<&'a str>,
266    /// An optional file path relevant to the invocation (used for path-based ask rules).
267    pub path: Option<&'a str>,
268    /// The current approval policy mode.
269    pub ask_for_approval: AskForApproval,
270    /// The sandbox mode in effect, if any (e.g. `"workspace-write"`).
271    pub sandbox_mode: Option<&'a str>,
272}
273
274#[derive(Debug, Clone, Default)]
275pub struct ExecPolicyEngine {
276    /// Layered rulesets (builtin → agent → user). When non-empty, takes precedence
277    /// over the legacy flat lists below.
278    rulesets: Vec<Ruleset>,
279    /// Legacy flat lists kept for backward compatibility with `new()`.
280    trusted_prefixes: Vec<String>,
281    denied_prefixes: Vec<String>,
282    approved_for_session: HashSet<String>,
283    /// Arity dictionary for command-prefix allow-rule matching.
284    arity_dict: BashArityDict,
285}
286
287impl ExecPolicyEngine {
288    /// Legacy constructor: wraps the two vecs into a User-layer ruleset.
289    pub fn new(trusted_prefixes: Vec<String>, denied_prefixes: Vec<String>) -> Self {
290        Self {
291            rulesets: vec![],
292            trusted_prefixes,
293            denied_prefixes,
294            approved_for_session: HashSet::new(),
295            arity_dict: BashArityDict::new(),
296        }
297    }
298
299    /// Build an engine from explicit layered rulesets.
300    /// Rulesets are sorted by layer priority on construction.
301    pub fn with_rulesets(mut rulesets: Vec<Ruleset>) -> Self {
302        rulesets.sort_by_key(|r| r.layer);
303        Self {
304            rulesets,
305            trusted_prefixes: vec![],
306            denied_prefixes: vec![],
307            approved_for_session: HashSet::new(),
308            arity_dict: BashArityDict::new(),
309        }
310    }
311
312    /// Add a ruleset layer (re-sorts internally).
313    pub fn add_ruleset(&mut self, ruleset: Ruleset) {
314        self.rulesets.push(ruleset);
315        self.rulesets.sort_by_key(|r| r.layer);
316    }
317
318    /// Resolve the effective trusted/denied prefix sets by merging all rulesets.
319    ///
320    /// Collects all prefixes from every layer (builtin → agent → user) into flat
321    /// trusted/denied lists. The `check()` method then applies deny-always-wins
322    /// semantics: any matching deny prefix blocks the command regardless of layer.
323    /// Trusted rules are only consulted after deny checks pass.
324    fn resolve_prefixes(&self) -> (Vec<String>, Vec<String>) {
325        if self.rulesets.is_empty() {
326            return (self.trusted_prefixes.clone(), self.denied_prefixes.clone());
327        }
328        // Collect all trusted/denied across all layers, highest-priority last so they
329        // shadow lower-priority entries with the same prefix.
330        let mut trusted: Vec<String> = vec![];
331        let mut denied: Vec<String> = vec![];
332        for rs in &self.rulesets {
333            trusted.extend(rs.trusted_prefixes.iter().cloned());
334            denied.extend(rs.denied_prefixes.iter().cloned());
335        }
336        // Also merge legacy flat lists as user-layer.
337        trusted.extend(self.trusted_prefixes.iter().cloned());
338        denied.extend(self.denied_prefixes.iter().cloned());
339        (trusted, denied)
340    }
341
342    fn matching_ask_rule(&self, ctx: &ExecPolicyContext<'_>) -> Option<ToolAskRule> {
343        let tool = ctx.tool.unwrap_or("exec_shell");
344        let normalized_path = ctx
345            .path
346            .and_then(|path| normalize_workspace_relative_path(path, ctx.cwd));
347
348        self.rulesets
349            .iter()
350            .flat_map(|ruleset| {
351                ruleset
352                    .ask_rules
353                    .iter()
354                    .map(move |rule| (ruleset.layer, rule))
355            })
356            .filter(|(_, rule)| rule.tool == tool)
357            .filter(|(_, rule)| match rule.command.as_deref() {
358                Some(command) => self.arity_dict.allow_rule_matches(command, ctx.command),
359                None => true,
360            })
361            .filter(|(_, rule)| match (rule.path.as_deref(), ctx.path) {
362                (Some(pattern), Some(_)) => match (
363                    normalize_workspace_relative_path(pattern, ctx.cwd),
364                    normalized_path.as_deref(),
365                ) {
366                    (Some(pattern), Some(path)) => pattern == path,
367                    _ => false,
368                },
369                (Some(_), None) => false,
370                (None, _) => true,
371            })
372            .max_by_key(|(layer, rule)| (rule.action, *layer, ask_rule_specificity(rule)))
373            .map(|(_, rule)| rule.clone())
374    }
375
376    /// Records an approval key for the current session so subsequent checks skip approval.
377    pub fn remember_session_approval(&mut self, approval_key: String) {
378        self.approved_for_session.insert(approval_key);
379    }
380
381    /// Returns whether the given approval key has been recorded for this session.
382    pub fn is_session_approved(&self, approval_key: &str) -> bool {
383        self.approved_for_session.contains(approval_key)
384    }
385
386    /// Evaluates a command against the policy and returns a decision.
387    ///
388    /// The evaluation order is: deny rules first (always win), then trusted prefix
389    /// matching (arity-aware), then typed ask rules, and finally the approval mode.
390    pub fn check(&self, ctx: ExecPolicyContext<'_>) -> Result<ExecPolicyDecision> {
391        let normalized = normalize_command(ctx.command);
392        let (trusted_prefixes, denied_prefixes) = self.resolve_prefixes();
393        // Deny rules use word-boundary prefix matching: the command must either
394        // equal the rule or start with the rule followed by a space, so "rm"
395        // blocks "rm -rf /" but NOT "rmdir" or "rmview".
396        if let Some(rule) = denied_prefixes.iter().find(|rule| {
397            let norm_rule = normalize_command(rule);
398            normalized == norm_rule
399                || (normalized.starts_with(&norm_rule)
400                    && normalized.as_bytes().get(norm_rule.len()) == Some(&b' '))
401        }) {
402            return Ok(ExecPolicyDecision {
403                allow: false,
404                requires_approval: false,
405                matched_rule: Some(rule.clone()),
406                matched_action: None,
407                requirement: ExecApprovalRequirement::Forbidden {
408                    reason: format!("Command blocked by denied prefix rule '{rule}'"),
409                },
410            });
411        }
412
413        // Allow (trusted) rules use arity-aware prefix matching so that
414        // `auto_allow = ["git status"]` matches `git status -s` but NOT
415        // `git push origin main`.
416        let trusted_rule = trusted_prefixes
417            .iter()
418            .find(|rule| self.arity_dict.allow_rule_matches(rule, ctx.command))
419            .cloned();
420        let is_trusted = trusted_rule.is_some();
421
422        let ask_rule = self.matching_ask_rule(&ctx);
423
424        // Handle explicit deny/allow actions before mode-based resolution.
425        // Deny wins over everything; allow skips approval regardless of mode.
426        if let Some(rule) = &ask_rule {
427            match rule.action {
428                PermissionAction::Deny => {
429                    return Ok(ExecPolicyDecision {
430                        allow: false,
431                        requires_approval: false,
432                        matched_rule: Some(rule.label()),
433                        matched_action: Some(PermissionAction::Deny),
434                        requirement: ExecApprovalRequirement::Forbidden {
435                            reason: format!(
436                                "Permission rule '{}' explicitly denies this invocation.",
437                                rule.label()
438                            ),
439                        },
440                    });
441                }
442                PermissionAction::Allow => {
443                    return Ok(ExecPolicyDecision {
444                        allow: true,
445                        requires_approval: false,
446                        matched_rule: Some(rule.label()),
447                        matched_action: Some(PermissionAction::Allow),
448                        requirement: ExecApprovalRequirement::Skip {
449                            bypass_sandbox: false,
450                            proposed_execpolicy_amendment: None,
451                        },
452                    });
453                }
454                PermissionAction::Ask => {
455                    // Fall through to existing mode-based logic below.
456                }
457            }
458        }
459
460        let mut matched_ask_rule = None;
461        // Resolve a matching typed ask-rule first. Ask-rules take precedence over
462        // mode-based handling for everything except `Never` (which forbids,
463        // because no prompt can be shown) and `Reject { rules: true }` (which
464        // explicitly rejects rule-exceptions). This ordering is checked against
465        // the experimental `if let` match-guard the original PR used; it is
466        // reproduced here with plain control flow for edition-2024 stable.
467        let ask_rule_requirement = match &ctx.ask_for_approval {
468            AskForApproval::Never | AskForApproval::Reject { rules: true, .. } => None,
469            _ => ask_rule.as_ref().map(|rule| {
470                matched_ask_rule = Some(rule.label());
471                ExecApprovalRequirement::NeedsApproval {
472                    reason: format!("Typed ask rule '{}' requires approval.", rule.label()),
473                    proposed_execpolicy_amendment: None,
474                    // A typed ask-rule approval (exec/fn/MCP) must not touch
475                    // network policy. The original PR allow-listed `ctx.cwd` as a
476                    // network host here, which is incorrect and security-relevant:
477                    // approving e.g. an exec rule should never create a network
478                    // allow-entry. Emit no network amendments for ask-rule prompts.
479                    proposed_network_policy_amendments: Vec::new(),
480                }
481            }),
482        };
483
484        let requirement = if let Some(req) = ask_rule_requirement {
485            req
486        } else {
487            match &ctx.ask_for_approval {
488                AskForApproval::Never => {
489                    if let Some(rule) = &ask_rule {
490                        matched_ask_rule = Some(rule.label());
491                        ExecApprovalRequirement::Forbidden {
492                            reason: format!(
493                                "Typed ask rule '{}' requires approval, but approval policy is never.",
494                                rule.label()
495                            ),
496                        }
497                    } else {
498                        ExecApprovalRequirement::Skip {
499                            bypass_sandbox: false,
500                            proposed_execpolicy_amendment: None,
501                        }
502                    }
503                }
504                AskForApproval::Reject { rules, .. } if *rules => {
505                    ExecApprovalRequirement::Forbidden {
506                        reason: "Policy is configured to reject rule-exceptions.".to_string(),
507                    }
508                }
509                AskForApproval::UnlessTrusted if is_trusted => ExecApprovalRequirement::Skip {
510                    bypass_sandbox: false,
511                    proposed_execpolicy_amendment: None,
512                },
513                AskForApproval::OnFailure => ExecApprovalRequirement::Skip {
514                    bypass_sandbox: false,
515                    proposed_execpolicy_amendment: None,
516                },
517                _ => ExecApprovalRequirement::NeedsApproval {
518                    reason: if is_trusted {
519                        "Approval requested by policy mode.".to_string()
520                    } else {
521                        "Unmatched command prefix requires approval.".to_string()
522                    },
523                    proposed_execpolicy_amendment: if is_trusted {
524                        None
525                    } else {
526                        Some(ExecPolicyAmendment {
527                            prefixes: vec![first_token(ctx.command)],
528                        })
529                    },
530                    proposed_network_policy_amendments: vec![NetworkPolicyAmendment {
531                        host: ctx.cwd.to_string(),
532                        action: NetworkPolicyRuleAction::Allow,
533                    }],
534                },
535            }
536        };
537
538        let (allow, requires_approval) = match requirement {
539            ExecApprovalRequirement::Skip { .. } => (true, false),
540            ExecApprovalRequirement::NeedsApproval { .. } => (true, true),
541            ExecApprovalRequirement::Forbidden { .. } => (false, false),
542        };
543
544        Ok(ExecPolicyDecision {
545            allow,
546            requires_approval,
547            matched_rule: matched_ask_rule.or(trusted_rule),
548            matched_action: ask_rule.as_ref().map(|r| r.action),
549            requirement,
550        })
551    }
552}
553
554fn normalize_command(value: &str) -> String {
555    // Normalize: lowercase, collapse internal whitespace to single spaces.
556    // This prevents bypass via "git  status" (double space) vs "git status".
557    value
558        .split_whitespace()
559        .collect::<Vec<_>>()
560        .join(" ")
561        .to_ascii_lowercase()
562}
563
564fn first_token(command: &str) -> String {
565    command
566        .split_whitespace()
567        .next()
568        .unwrap_or_default()
569        .to_string()
570}
571
572/// Returns a slash-separated path relative to `workspace_root` when `value` is
573/// a safe path within that workspace.
574///
575/// Paths are normalized lexically so matching does not depend on the host OS
576/// or require the path to exist. A `..` segment is rejected rather than
577/// collapsed, preventing traversal from becoming matchable. Absolute paths
578/// must have the workspace as a whole-component prefix; relative paths are
579/// interpreted as workspace-relative. Backslashes are accepted so persisted
580/// rules and tool inputs behave consistently on Windows.
581///
582/// This is the canonical normalization shared by ask-rule matching and rule
583/// persistence: callers that save a file ask rule should store the value this
584/// returns so the saved path matches the same invocation later. `None` means
585/// the path is empty, traversing, drive-relative, or outside the workspace and
586/// must not be turned into a rule.
587pub fn normalize_workspace_relative_path(value: &str, workspace_root: &str) -> Option<String> {
588    let path = parse_path_for_matching(value)?;
589    let workspace = parse_path_for_matching(workspace_root)?;
590    let workspace_root = workspace.root.as_ref()?;
591
592    let relative_components = match path.root.as_ref() {
593        Some(path_root) => {
594            if path_root != workspace_root {
595                return None;
596            }
597            path.components.strip_prefix(&workspace.components[..])?
598        }
599        None => path.components.as_slice(),
600    };
601
602    Some(relative_components.join("/"))
603}
604
605#[derive(Debug)]
606struct PathForMatching {
607    root: Option<String>,
608    components: Vec<String>,
609}
610
611fn parse_path_for_matching(value: &str) -> Option<PathForMatching> {
612    let value = value.trim().replace('\\', "/").to_ascii_lowercase();
613    if value.is_empty() {
614        return None;
615    }
616
617    let (root, components) = if let Some(path) = value.strip_prefix('/') {
618        (Some("/".to_string()), path)
619    } else if is_windows_absolute_path(&value) {
620        (Some(value[..2].to_string()), &value[3..])
621    } else if has_windows_drive_prefix(&value) {
622        // `C:foo` is drive-relative on Windows. Treating it as a
623        // workspace-relative path could match outside the workspace.
624        return None;
625    } else {
626        (None, value.as_str())
627    };
628
629    let mut normalized_components = Vec::new();
630    for component in components.split('/') {
631        match component {
632            "" | "." => {}
633            ".." => return None,
634            component => normalized_components.push(component.to_string()),
635        }
636    }
637
638    Some(PathForMatching {
639        root,
640        components: normalized_components,
641    })
642}
643
644fn is_windows_absolute_path(value: &str) -> bool {
645    let bytes = value.as_bytes();
646    bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/'
647}
648
649fn has_windows_drive_prefix(value: &str) -> bool {
650    let bytes = value.as_bytes();
651    bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
652}
653
654fn ask_rule_specificity(rule: &ToolAskRule) -> usize {
655    rule.tool.len()
656        + rule
657            .command
658            .as_ref()
659            .map_or(0, |command| command.len() + 1000)
660        + rule.path.as_ref().map_or(0, |path| path.len() + 1000)
661}
662
663#[cfg(test)]
664mod tests {
665    use super::*;
666    use AskForApproval::*;
667
668    fn ctx(command: &str, ask_for_approval: AskForApproval) -> ExecPolicyContext<'_> {
669        ExecPolicyContext {
670            command,
671            cwd: "/workspace",
672            tool: Some("exec_shell"),
673            path: None,
674            ask_for_approval,
675            sandbox_mode: Some("workspace-write"),
676        }
677    }
678
679    #[test]
680    fn trusted_prefix_skips_approval_when_policy_is_unless_trusted() {
681        let engine = ExecPolicyEngine::new(vec!["git status".to_string()], vec![]);
682
683        let decision = engine
684            .check(ctx("git status --porcelain", AskForApproval::UnlessTrusted))
685            .unwrap();
686
687        assert!(decision.allow);
688        assert!(!decision.requires_approval);
689        assert_eq!(decision.matched_rule.as_deref(), Some("git status"));
690        assert!(matches!(
691            decision.requirement,
692            ExecApprovalRequirement::Skip {
693                bypass_sandbox: false,
694                proposed_execpolicy_amendment: None,
695            }
696        ));
697    }
698
699    #[test]
700    fn denied_prefix_blocks_even_when_command_is_also_trusted() {
701        let engine = ExecPolicyEngine::new(
702            vec!["git status".to_string()],
703            vec!["git status".to_string()],
704        );
705
706        let decision = engine
707            .check(ctx("git status --porcelain", AskForApproval::UnlessTrusted))
708            .unwrap();
709
710        assert!(!decision.allow);
711        assert!(!decision.requires_approval);
712        assert_eq!(decision.matched_rule.as_deref(), Some("git status"));
713        assert!(matches!(
714            decision.requirement,
715            ExecApprovalRequirement::Forbidden { .. }
716        ));
717        assert_eq!(
718            decision.reason(),
719            "Command blocked by denied prefix rule 'git status'"
720        );
721    }
722
723    #[test]
724    fn unmatched_command_requires_approval_and_proposes_first_token_rule() {
725        let engine = ExecPolicyEngine::new(vec![], vec![]);
726
727        let decision = engine
728            .check(ctx("cargo test --workspace", AskForApproval::UnlessTrusted))
729            .unwrap();
730
731        assert!(decision.allow);
732        assert!(decision.requires_approval);
733        assert_eq!(decision.matched_rule, None);
734        match decision.requirement {
735            ExecApprovalRequirement::NeedsApproval {
736                proposed_execpolicy_amendment: Some(amendment),
737                proposed_network_policy_amendments,
738                ..
739            } => {
740                assert_eq!(amendment.prefixes, vec!["cargo"]);
741                assert_eq!(
742                    proposed_network_policy_amendments,
743                    vec![NetworkPolicyAmendment {
744                        host: "/workspace".to_string(),
745                        action: NetworkPolicyRuleAction::Allow,
746                    }]
747                );
748            }
749            other => panic!("expected approval with proposed amendment, got {other:?}"),
750        }
751    }
752
753    #[test]
754    fn trusted_command_in_on_request_mode_still_requires_approval_without_new_rule() {
755        let engine = ExecPolicyEngine::new(vec!["cargo test".to_string()], vec![]);
756
757        let decision = engine
758            .check(ctx("cargo test --workspace", AskForApproval::OnRequest))
759            .unwrap();
760
761        assert!(decision.allow);
762        assert!(decision.requires_approval);
763        assert_eq!(decision.matched_rule.as_deref(), Some("cargo test"));
764        match decision.requirement {
765            ExecApprovalRequirement::NeedsApproval {
766                proposed_execpolicy_amendment,
767                ..
768            } => assert_eq!(proposed_execpolicy_amendment, None),
769            other => panic!("expected approval without amendment, got {other:?}"),
770        }
771    }
772
773    #[test]
774    fn reject_rules_mode_forbids_unmatched_command() {
775        let engine = ExecPolicyEngine::new(vec![], vec![]);
776
777        let decision = engine
778            .check(ctx(
779                "npm install",
780                AskForApproval::Reject {
781                    sandbox_approval: false,
782                    rules: true,
783                    mcp_elicitations: false,
784                },
785            ))
786            .unwrap();
787
788        assert!(!decision.allow);
789        assert!(!decision.requires_approval);
790        assert_eq!(decision.matched_rule, None);
791        assert_eq!(decision.requirement.phase(), "forbidden");
792        assert_eq!(
793            decision.reason(),
794            "Policy is configured to reject rule-exceptions."
795        );
796    }
797
798    #[test]
799    fn typed_ask_rule_forbids_matching_command_when_policy_is_never() {
800        let engine = ExecPolicyEngine::with_rulesets(vec![
801            Ruleset::user(vec![], vec![])
802                .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
803        ]);
804
805        let decision = engine
806            .check(ctx("cargo test --workspace", AskForApproval::Never))
807            .unwrap();
808
809        assert!(!decision.allow);
810        assert!(!decision.requires_approval);
811        assert_eq!(
812            decision.matched_rule.as_deref(),
813            Some("tool=exec_shell command=cargo test")
814        );
815        assert_eq!(decision.requirement.phase(), "forbidden");
816        assert_eq!(
817            decision.reason(),
818            "Typed ask rule 'tool=exec_shell command=cargo test' requires approval, but approval policy is never."
819        );
820    }
821
822    #[test]
823    fn typed_ask_rule_requires_approval_under_unless_trusted() {
824        let engine = ExecPolicyEngine::with_rulesets(vec![
825            Ruleset::user(vec![], vec![])
826                .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
827        ]);
828
829        let decision = engine
830            .check(ctx("cargo test --workspace", AskForApproval::UnlessTrusted))
831            .unwrap();
832
833        assert!(decision.allow);
834        assert!(decision.requires_approval);
835        assert_eq!(
836            decision.matched_rule.as_deref(),
837            Some("tool=exec_shell command=cargo test")
838        );
839        match decision.requirement {
840            ExecApprovalRequirement::NeedsApproval {
841                proposed_execpolicy_amendment,
842                proposed_network_policy_amendments,
843                ..
844            } => {
845                assert_eq!(proposed_execpolicy_amendment, None);
846                // A typed ask-rule approval must not allow-list the cwd (or
847                // anything else) as a network host. See the NeedsApproval arm.
848                assert!(
849                    proposed_network_policy_amendments.is_empty(),
850                    "ask-rule approval must not propose network amendments, got {proposed_network_policy_amendments:?}"
851                );
852            }
853            other => panic!("expected typed ask approval, got {other:?}"),
854        }
855    }
856
857    #[test]
858    fn typed_ask_rule_requires_approval_under_on_failure() {
859        let engine = ExecPolicyEngine::with_rulesets(vec![
860            Ruleset::user(vec![], vec![])
861                .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
862        ]);
863
864        let decision = engine
865            .check(ctx("cargo test --workspace", AskForApproval::OnFailure))
866            .unwrap();
867
868        assert!(decision.allow);
869        assert!(decision.requires_approval);
870        assert_eq!(
871            decision.reason(),
872            "Typed ask rule 'tool=exec_shell command=cargo test' requires approval."
873        );
874    }
875
876    #[test]
877    fn typed_ask_rule_overrides_trusted_but_not_deny() {
878        let engine = ExecPolicyEngine::with_rulesets(vec![
879            Ruleset::user(
880                vec!["cargo test".to_string()],
881                vec!["cargo test --danger".to_string()],
882            )
883            .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
884        ]);
885
886        let trusted = engine
887            .check(ctx("cargo test --workspace", AskForApproval::UnlessTrusted))
888            .unwrap();
889        assert!(trusted.allow);
890        assert!(trusted.requires_approval);
891        assert_eq!(
892            trusted.matched_rule.as_deref(),
893            Some("tool=exec_shell command=cargo test")
894        );
895
896        let denied = engine
897            .check(ctx("cargo test --danger", AskForApproval::Never))
898            .unwrap();
899        assert!(!denied.allow);
900        assert!(!denied.requires_approval);
901        assert_eq!(denied.matched_rule.as_deref(), Some("cargo test --danger"));
902        assert_eq!(
903            denied.reason(),
904            "Command blocked by denied prefix rule 'cargo test --danger'"
905        );
906    }
907
908    #[test]
909    fn typed_ask_rule_prefers_higher_layer_before_specificity() {
910        let engine = ExecPolicyEngine::with_rulesets(vec![
911            Ruleset::agent(vec![], vec![])
912                .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test --workspace")]),
913            Ruleset::user(vec![], vec![])
914                .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
915        ]);
916
917        let decision = engine
918            .check(ctx(
919                "cargo test --workspace --all-features",
920                AskForApproval::UnlessTrusted,
921            ))
922            .unwrap();
923
924        assert!(decision.requires_approval);
925        assert_eq!(
926            decision.matched_rule.as_deref(),
927            Some("tool=exec_shell command=cargo test")
928        );
929    }
930
931    #[test]
932    fn reject_rules_mode_still_forbids_matching_ask_rule() {
933        let engine = ExecPolicyEngine::with_rulesets(vec![
934            Ruleset::user(vec![], vec![])
935                .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
936        ]);
937
938        let decision = engine
939            .check(ctx(
940                "cargo test --workspace",
941                AskForApproval::Reject {
942                    sandbox_approval: false,
943                    rules: true,
944                    mcp_elicitations: false,
945                },
946            ))
947            .unwrap();
948
949        assert!(!decision.allow);
950        assert!(!decision.requires_approval);
951        assert_eq!(decision.matched_rule, None);
952        assert_eq!(
953            decision.reason(),
954            "Policy is configured to reject rule-exceptions."
955        );
956    }
957
958    #[test]
959    fn typed_ask_rule_label_wins_when_never_blocks_trusted_command() {
960        let engine = ExecPolicyEngine::with_rulesets(vec![
961            Ruleset::user(vec!["cargo test".to_string()], vec![])
962                .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
963        ]);
964
965        let decision = engine
966            .check(ctx("cargo test --workspace", AskForApproval::Never))
967            .unwrap();
968
969        assert!(!decision.allow);
970        assert_eq!(
971            decision.matched_rule.as_deref(),
972            Some("tool=exec_shell command=cargo test")
973        );
974        assert_eq!(
975            decision.reason(),
976            "Typed ask rule 'tool=exec_shell command=cargo test' requires approval, but approval policy is never."
977        );
978    }
979
980    #[test]
981    fn typed_ask_path_matching_trims_spaces_before_workspace_normalization() {
982        let engine =
983            ExecPolicyEngine::with_rulesets(vec![Ruleset::user(vec![], vec![]).with_ask_rules(
984                vec![ToolAskRule::file_path(
985                    "edit_file",
986                    " /workspace/tmp/project/ ",
987                )],
988            )]);
989
990        let decision = engine
991            .check(ExecPolicyContext {
992                command: "",
993                cwd: "/workspace",
994                tool: Some("edit_file"),
995                path: Some("tmp/project"),
996                ask_for_approval: AskForApproval::Never,
997                sandbox_mode: Some("workspace-write"),
998            })
999            .unwrap();
1000
1001        assert!(!decision.allow);
1002        assert_eq!(
1003            decision.matched_rule.as_deref(),
1004            Some("tool=edit_file path= /workspace/tmp/project/ ")
1005        );
1006    }
1007
1008    #[test]
1009    fn typed_ask_path_matching_normalizes_relative_and_absolute_workspace_paths() {
1010        let relative_rule = ExecPolicyEngine::with_rulesets(vec![
1011            Ruleset::user(vec![], vec![])
1012                .with_ask_rules(vec![ToolAskRule::file_path("edit_file", "src/a.rs")]),
1013        ]);
1014        let absolute_path = relative_rule
1015            .check(ExecPolicyContext {
1016                command: "",
1017                cwd: "/workspace",
1018                tool: Some("edit_file"),
1019                path: Some("/workspace/src/a.rs"),
1020                ask_for_approval: AskForApproval::OnFailure,
1021                sandbox_mode: Some("workspace-write"),
1022            })
1023            .unwrap();
1024        assert!(absolute_path.requires_approval);
1025
1026        let absolute_rule =
1027            ExecPolicyEngine::with_rulesets(vec![Ruleset::user(vec![], vec![]).with_ask_rules(
1028                vec![ToolAskRule::file_path("edit_file", "/workspace/src/a.rs")],
1029            )]);
1030        let relative_path = absolute_rule
1031            .check(ExecPolicyContext {
1032                command: "",
1033                cwd: "/workspace",
1034                tool: Some("edit_file"),
1035                path: Some("src/a.rs"),
1036                ask_for_approval: AskForApproval::OnFailure,
1037                sandbox_mode: Some("workspace-write"),
1038            })
1039            .unwrap();
1040        assert!(relative_path.requires_approval);
1041    }
1042
1043    #[test]
1044    fn typed_ask_path_matching_rejects_traversal_and_external_paths() {
1045        for (rule_path, path) in [
1046            ("src/a.rs", "../src/a.rs"),
1047            ("src/a.rs", "/workspace/src/../src/a.rs"),
1048            ("src/a.rs", "/src/a.rs"),
1049            ("../src/a.rs", "src/a.rs"),
1050            ("/src/a.rs", "src/a.rs"),
1051        ] {
1052            let engine = ExecPolicyEngine::with_rulesets(vec![
1053                Ruleset::user(vec![], vec![])
1054                    .with_ask_rules(vec![ToolAskRule::file_path("edit_file", rule_path)]),
1055            ]);
1056            let decision = engine
1057                .check(ExecPolicyContext {
1058                    command: "",
1059                    cwd: "/workspace",
1060                    tool: Some("edit_file"),
1061                    path: Some(path),
1062                    ask_for_approval: AskForApproval::OnFailure,
1063                    sandbox_mode: Some("workspace-write"),
1064                })
1065                .unwrap();
1066            assert_eq!(
1067                decision.matched_rule, None,
1068                "rule {rule_path:?} and path {path:?} must not match"
1069            );
1070        }
1071    }
1072
1073    #[test]
1074    fn typed_ask_path_matching_accepts_windows_separators() {
1075        let engine = ExecPolicyEngine::with_rulesets(vec![
1076            Ruleset::user(vec![], vec![])
1077                .with_ask_rules(vec![ToolAskRule::file_path("edit_file", r"src\a.rs")]),
1078        ]);
1079
1080        let decision = engine
1081            .check(ExecPolicyContext {
1082                command: "",
1083                cwd: r"C:\workspace",
1084                tool: Some("edit_file"),
1085                path: Some(r"C:\workspace\src\a.rs"),
1086                ask_for_approval: AskForApproval::OnFailure,
1087                sandbox_mode: Some("workspace-write"),
1088            })
1089            .unwrap();
1090
1091        assert!(decision.requires_approval);
1092    }
1093
1094    // ── deny / allow action tests ──────────────────────────────────────────
1095
1096    #[test]
1097    fn deny_action_blocks_regardless_of_mode() {
1098        let engine =
1099            ExecPolicyEngine::with_rulesets(vec![Ruleset::user(vec![], vec![]).with_ask_rules(
1100                vec![ToolAskRule {
1101                    tool: "exec_shell".into(),
1102                    command: Some("sed".into()),
1103                    path: None,
1104                    action: PermissionAction::Deny,
1105                }],
1106            )]);
1107
1108        // sed should be blocked even under UnlessTrusted
1109        let decision = engine
1110            .check(ExecPolicyContext {
1111                command: "sed -i 's/foo/bar/' file.txt",
1112                cwd: "/tmp",
1113                tool: Some("exec_shell"),
1114                path: None,
1115                ask_for_approval: AskForApproval::UnlessTrusted,
1116                sandbox_mode: None,
1117            })
1118            .unwrap();
1119
1120        assert!(!decision.allow);
1121        assert!(!decision.requires_approval);
1122        assert_eq!(decision.matched_action, Some(PermissionAction::Deny));
1123        assert_eq!(decision.requirement.phase(), "forbidden");
1124        assert!(
1125            decision.reason().contains("explicitly denies"),
1126            "expected deny reason, got: {}",
1127            decision.reason()
1128        );
1129    }
1130
1131    #[test]
1132    fn allow_action_skips_approval_regardless_of_mode() {
1133        let engine =
1134            ExecPolicyEngine::with_rulesets(vec![Ruleset::user(vec![], vec![]).with_ask_rules(
1135                vec![ToolAskRule {
1136                    tool: "exec_shell".into(),
1137                    command: Some("git status".into()),
1138                    path: None,
1139                    action: PermissionAction::Allow,
1140                }],
1141            )]);
1142
1143        // git status should be allowed even under OnRequest
1144        let decision = engine
1145            .check(ExecPolicyContext {
1146                command: "git status",
1147                cwd: "/tmp",
1148                tool: Some("exec_shell"),
1149                path: None,
1150                ask_for_approval: AskForApproval::OnRequest,
1151                sandbox_mode: None,
1152            })
1153            .unwrap();
1154
1155        assert!(decision.allow);
1156        assert!(!decision.requires_approval);
1157        assert_eq!(decision.matched_action, Some(PermissionAction::Allow));
1158    }
1159
1160    #[test]
1161    fn deny_wins_over_allow_when_both_match() {
1162        // Deny "sed" rule at user layer, allow "sed" at agent layer.
1163        // Higher-layer (user) deny should win.
1164        let engine = ExecPolicyEngine::with_rulesets(vec![
1165            Ruleset::agent(vec!["sed".into()], vec![]).with_ask_rules(vec![]),
1166            Ruleset::user(vec![], vec!["sed".into()]).with_ask_rules(vec![]),
1167        ]);
1168
1169        let decision = engine
1170            .check(ExecPolicyContext {
1171                command: "sed -i 's/a/b/' x.txt",
1172                cwd: "/tmp",
1173                tool: Some("exec_shell"),
1174                path: None,
1175                ask_for_approval: AskForApproval::UnlessTrusted,
1176                sandbox_mode: None,
1177            })
1178            .unwrap();
1179
1180        assert!(!decision.allow);
1181        assert_eq!(decision.requirement.phase(), "forbidden");
1182    }
1183
1184    #[test]
1185    fn ask_action_default_backward_compatible() {
1186        // Without explicit action, rules default to Ask via serde default.
1187        let rule = ToolAskRule::exec_shell("cargo test");
1188        assert_eq!(rule.action, PermissionAction::Ask);
1189    }
1190
1191    #[test]
1192    fn deny_action_constructors_produce_ask_by_default() {
1193        assert_eq!(ToolAskRule::new("exec_shell").action, PermissionAction::Ask);
1194        assert_eq!(
1195            ToolAskRule::exec_shell("cargo test").action,
1196            PermissionAction::Ask
1197        );
1198        assert_eq!(
1199            ToolAskRule::file_path("read_file", "secrets.txt").action,
1200            PermissionAction::Ask
1201        );
1202    }
1203
1204    // ── deny: single-word commands ────────────────────────────────────────
1205
1206    #[test]
1207    fn deny_single_word_blocks_exact_and_subcommands() {
1208        let engine = engine_with_ask_rule(ToolAskRule {
1209            tool: "exec_shell".into(),
1210            command: Some("sed".into()),
1211            path: None,
1212            action: PermissionAction::Deny,
1213        });
1214
1215        // exact match
1216        let d = engine.check(ctx("sed", UnlessTrusted)).unwrap();
1217        assert!(!d.allow, "deny must block exact 'sed'");
1218
1219        // subcommand
1220        let d = engine
1221            .check(ctx("sed -i 's/a/b/' file.txt", UnlessTrusted))
1222            .unwrap();
1223        assert!(!d.allow, "deny must block 'sed -i …'");
1224    }
1225
1226    #[test]
1227    fn deny_single_word_does_not_block_unrelated() {
1228        let engine = engine_with_ask_rule(ToolAskRule {
1229            tool: "exec_shell".into(),
1230            command: Some("sed".into()),
1231            path: None,
1232            action: PermissionAction::Deny,
1233        });
1234
1235        // unrelated command passes through
1236        let d = engine
1237            .check(ctx("awk '{print $1}'", UnlessTrusted))
1238            .unwrap();
1239        assert!(d.allow, "deny 'sed' must not block 'awk'");
1240    }
1241
1242    #[test]
1243    fn deny_word_boundary_prevents_false_positives() {
1244        // "rm" must block "rm -rf /" but NOT "rmdir"
1245        let engine = engine_with_ask_rule(ToolAskRule {
1246            tool: "exec_shell".into(),
1247            command: Some("rm".into()),
1248            path: None,
1249            action: PermissionAction::Deny,
1250        });
1251
1252        assert!(!engine.check(ctx("rm -rf /", UnlessTrusted)).unwrap().allow);
1253        assert!(
1254            engine
1255                .check(ctx("rmdir empty-dir", UnlessTrusted))
1256                .unwrap()
1257                .allow
1258        );
1259    }
1260
1261    // ── deny: multi-word commands ─────────────────────────────────────────
1262
1263    #[test]
1264    fn deny_multi_word_blocks_subcommands() {
1265        let engine = engine_with_ask_rule(ToolAskRule {
1266            tool: "exec_shell".into(),
1267            command: Some("git push".into()),
1268            path: None,
1269            action: PermissionAction::Deny,
1270        });
1271
1272        assert!(!engine.check(ctx("git push", UnlessTrusted)).unwrap().allow);
1273        assert!(
1274            !engine
1275                .check(ctx("git push origin main", UnlessTrusted))
1276                .unwrap()
1277                .allow
1278        );
1279        assert!(
1280            !engine
1281                .check(ctx("git push --force", UnlessTrusted))
1282                .unwrap()
1283                .allow
1284        );
1285    }
1286
1287    #[test]
1288    fn deny_multi_word_distinguishes_from_sibling_subcommands() {
1289        // "git push" must NOT block "git pull"
1290        let engine = engine_with_ask_rule(ToolAskRule {
1291            tool: "exec_shell".into(),
1292            command: Some("git push".into()),
1293            path: None,
1294            action: PermissionAction::Deny,
1295        });
1296
1297        assert!(engine.check(ctx("git pull", UnlessTrusted)).unwrap().allow);
1298        assert!(
1299            engine
1300                .check(ctx("git pull origin main", UnlessTrusted))
1301                .unwrap()
1302                .allow
1303        );
1304        assert!(
1305            engine
1306                .check(ctx("git status", UnlessTrusted))
1307                .unwrap()
1308                .allow
1309        );
1310    }
1311
1312    #[test]
1313    fn deny_multi_word_via_denied_prefixes_path() {
1314        // When ruleset() promotes deny→denied_prefixes, the word-boundary
1315        // path in check() handles it identically.
1316        let engine = ExecPolicyEngine::new(vec![], vec!["git push".into()]);
1317
1318        assert!(
1319            !engine
1320                .check(ctx("git push --force", UnlessTrusted))
1321                .unwrap()
1322                .allow
1323        );
1324        assert!(engine.check(ctx("git pull", UnlessTrusted)).unwrap().allow);
1325    }
1326
1327    // ── deny: priority ────────────────────────────────────────────────────
1328
1329    #[test]
1330    fn deny_wins_over_allow_via_ask_rules() {
1331        let engine =
1332            ExecPolicyEngine::with_rulesets(vec![Ruleset::user(vec![], vec![]).with_ask_rules(
1333                vec![
1334                    ToolAskRule {
1335                        tool: "exec_shell".into(),
1336                        command: Some("sed".into()),
1337                        path: None,
1338                        action: PermissionAction::Allow,
1339                    },
1340                    ToolAskRule {
1341                        tool: "exec_shell".into(),
1342                        command: Some("sed".into()),
1343                        path: None,
1344                        action: PermissionAction::Deny,
1345                    },
1346                ],
1347            )]);
1348
1349        // Both match; deny should win (execpolicy early-return for deny
1350        // fires before allow).
1351        let d = engine
1352            .check(ctx("sed -i 's/a/b/' x.txt", UnlessTrusted))
1353            .unwrap();
1354        assert!(!d.allow, "deny must win over allow");
1355    }
1356
1357    #[test]
1358    fn deny_wins_over_allow_via_ask_rules_regardless_of_order() {
1359        let engine =
1360            ExecPolicyEngine::with_rulesets(vec![Ruleset::user(vec![], vec![]).with_ask_rules(
1361                vec![
1362                    ToolAskRule {
1363                        tool: "exec_shell".into(),
1364                        command: Some("sed".into()),
1365                        path: None,
1366                        action: PermissionAction::Deny,
1367                    },
1368                    ToolAskRule {
1369                        tool: "exec_shell".into(),
1370                        command: Some("sed".into()),
1371                        path: None,
1372                        action: PermissionAction::Allow,
1373                    },
1374                ],
1375            )]);
1376
1377        let d = engine
1378            .check(ctx("sed -i 's/a/b/' x.txt", UnlessTrusted))
1379            .unwrap();
1380        assert!(!d.allow, "deny must win even if allow appears later");
1381        assert_eq!(d.matched_action, Some(PermissionAction::Deny));
1382    }
1383
1384    #[test]
1385    fn path_deny_wins_over_path_allow_regardless_of_order() {
1386        let engine =
1387            ExecPolicyEngine::with_rulesets(vec![Ruleset::user(vec![], vec![]).with_ask_rules(
1388                vec![
1389                    ToolAskRule {
1390                        tool: "write_file".into(),
1391                        command: None,
1392                        path: Some("src/secrets.rs".into()),
1393                        action: PermissionAction::Deny,
1394                    },
1395                    ToolAskRule {
1396                        tool: "write_file".into(),
1397                        command: None,
1398                        path: Some("src/secrets.rs".into()),
1399                        action: PermissionAction::Allow,
1400                    },
1401                ],
1402            )]);
1403
1404        let d = engine
1405            .check(ExecPolicyContext {
1406                command: "",
1407                cwd: "/workspace",
1408                tool: Some("write_file"),
1409                path: Some("/workspace/src/secrets.rs"),
1410                ask_for_approval: UnlessTrusted,
1411                sandbox_mode: None,
1412            })
1413            .unwrap();
1414
1415        assert!(!d.allow, "path deny must win even if allow appears later");
1416        assert_eq!(d.matched_action, Some(PermissionAction::Deny));
1417    }
1418
1419    #[test]
1420    fn deny_via_prefixes_wins_over_allow_via_prefixes() {
1421        // denied_prefixes checked first, before trusted_prefixes.
1422        let engine = ExecPolicyEngine::new(vec!["sed".into()], vec!["sed".into()]);
1423
1424        let d = engine
1425            .check(ctx("sed -i 's/a/b/' x.txt", UnlessTrusted))
1426            .unwrap();
1427        assert!(!d.allow, "denied prefix must win over trusted prefix");
1428    }
1429
1430    #[test]
1431    fn deny_tool_only_without_command_blocks_every_invocation() {
1432        let engine = engine_with_ask_rule(ToolAskRule {
1433            tool: "exec_shell".into(),
1434            command: None,
1435            path: None,
1436            action: PermissionAction::Deny,
1437        });
1438
1439        // any exec_shell command should be blocked
1440        assert!(
1441            !engine
1442                .check(ctx("git status", UnlessTrusted))
1443                .unwrap()
1444                .allow
1445        );
1446        assert!(
1447            !engine
1448                .check(ctx("cargo build", UnlessTrusted))
1449                .unwrap()
1450                .allow
1451        );
1452        assert!(
1453            !engine
1454                .check(ctx("echo hello", UnlessTrusted))
1455                .unwrap()
1456                .allow
1457        );
1458    }
1459
1460    // ── allow: single / multi-word ────────────────────────────────────────
1461
1462    #[test]
1463    fn allow_single_word_skips_approval() {
1464        let engine = engine_with_ask_rule(ToolAskRule {
1465            tool: "exec_shell".into(),
1466            command: Some("cargo".into()),
1467            path: None,
1468            action: PermissionAction::Allow,
1469        });
1470
1471        let d = engine
1472            .check(ctx("cargo build --release", OnRequest))
1473            .unwrap();
1474        assert!(d.allow);
1475        assert!(!d.requires_approval);
1476        assert_eq!(d.matched_action, Some(PermissionAction::Allow));
1477    }
1478
1479    #[test]
1480    fn allow_multi_word_skips_approval() {
1481        let engine = engine_with_ask_rule(ToolAskRule {
1482            tool: "exec_shell".into(),
1483            command: Some("git status".into()),
1484            path: None,
1485            action: PermissionAction::Allow,
1486        });
1487
1488        let d = engine.check(ctx("git status --short", OnRequest)).unwrap();
1489        assert!(d.allow);
1490        assert!(!d.requires_approval);
1491    }
1492
1493    #[test]
1494    fn allow_does_not_leak_to_unmatched_commands() {
1495        let engine = engine_with_ask_rule(ToolAskRule {
1496            tool: "exec_shell".into(),
1497            command: Some("git status".into()),
1498            path: None,
1499            action: PermissionAction::Allow,
1500        });
1501
1502        // Unrelated command: normal approval flow applies.
1503        let d = engine
1504            .check(ctx("git push origin main", UnlessTrusted))
1505            .unwrap();
1506        // UnlessTrusted without a trusted prefix: requires approval
1507        assert!(d.requires_approval);
1508    }
1509
1510    #[test]
1511    fn allow_under_never_mode_still_allows() {
1512        // allow action must bypass even strict Never mode.
1513        let engine = engine_with_ask_rule(ToolAskRule {
1514            tool: "exec_shell".into(),
1515            command: Some("cargo".into()),
1516            path: None,
1517            action: PermissionAction::Allow,
1518        });
1519
1520        let d = engine.check(ctx("cargo check", Never)).unwrap();
1521        assert!(d.allow);
1522        assert!(!d.requires_approval);
1523    }
1524
1525    // ── ask: default / backward compat ────────────────────────────────────
1526
1527    #[test]
1528    fn ask_action_behaves_like_before_action_field_existed() {
1529        let engine = engine_with_ask_rule(ToolAskRule {
1530            tool: "exec_shell".into(),
1531            command: Some("cargo test".into()),
1532            path: None,
1533            action: PermissionAction::Ask,
1534        });
1535
1536        // Under UnlessTrusted: ask rule forces approval
1537        let d = engine
1538            .check(ctx("cargo test --workspace", UnlessTrusted))
1539            .unwrap();
1540        assert!(d.allow);
1541        assert!(d.requires_approval);
1542
1543        // Under Never: ask rule is forbidden
1544        let d = engine.check(ctx("cargo test --workspace", Never)).unwrap();
1545        assert!(!d.allow);
1546        assert_eq!(d.requirement.phase(), "forbidden");
1547    }
1548
1549    #[test]
1550    fn ask_is_default_when_action_omitted() {
1551        let rule = ToolAskRule::exec_shell("cargo test");
1552        assert_eq!(rule.action, PermissionAction::Ask);
1553    }
1554
1555    // ── cross-cutting ─────────────────────────────────────────────────────
1556
1557    #[test]
1558    fn deny_blocks_tool_only_even_for_different_tool() {
1559        // deny on "exec_shell" must not affect "write_file"
1560        let engine = engine_with_ask_rule(ToolAskRule {
1561            tool: "exec_shell".into(),
1562            command: Some("sed".into()),
1563            path: None,
1564            action: PermissionAction::Deny,
1565        });
1566
1567        let d = engine
1568            .check(ExecPolicyContext {
1569                command: "",
1570                cwd: "/workspace",
1571                tool: Some("write_file"),
1572                path: Some("/workspace/src/main.rs"),
1573                ask_for_approval: UnlessTrusted,
1574                sandbox_mode: None,
1575            })
1576            .unwrap();
1577        // write_file should not be affected by exec_shell deny
1578        assert!(d.allow);
1579    }
1580
1581    #[test]
1582    fn normalize_handles_extra_whitespace_in_command() {
1583        // "git  status" (double space) normalizes to "git status"
1584        let engine = ExecPolicyEngine::new(vec![], vec!["git push".into()]);
1585
1586        let d = engine
1587            .check(ctx("git   push   --force", UnlessTrusted))
1588            .unwrap();
1589        assert!(!d.allow, "extra whitespace must not bypass deny");
1590    }
1591
1592    #[test]
1593    fn normalize_handles_case_insensitivity() {
1594        // normalize_command lowercases — "SED" matches "sed"
1595        let engine = ExecPolicyEngine::new(vec![], vec!["sed".into()]);
1596
1597        let d = engine
1598            .check(ctx("SED -i 's/a/b/' file.txt", UnlessTrusted))
1599            .unwrap();
1600        assert!(!d.allow, "case must not bypass deny");
1601    }
1602
1603    #[test]
1604    fn allow_falls_back_to_mode_when_no_rule_matches() {
1605        let engine = ExecPolicyEngine::new(vec![], vec![]); // no rules
1606
1607        let d = engine.check(ctx("cargo build", UnlessTrusted)).unwrap();
1608        assert!(d.allow);
1609        assert!(d.requires_approval, "untrusted cmd needs approval");
1610    }
1611
1612    // ── helpers ───────────────────────────────────────────────────────────
1613
1614    fn engine_with_ask_rule(rule: ToolAskRule) -> ExecPolicyEngine {
1615        ExecPolicyEngine::with_rulesets(vec![
1616            Ruleset::user(vec![], vec![]).with_ask_rules(vec![rule]),
1617        ])
1618    }
1619}