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/// Typed rule that marks a tool invocation as requiring approval.
73///
74/// This foundation is intentionally ask-only. Existing trusted/denied command
75/// prefix behavior is preserved while typed ask records can make
76/// `AskForApproval::Never` reject invocations that cannot be approved.
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
78pub struct ToolAskRule {
79    /// Name of the tool this rule applies to (e.g. `"exec_shell"`, `"edit_file"`).
80    pub tool: String,
81    /// Optional command prefix to match against (uses arity-aware matching).
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub command: Option<String>,
84    /// Optional file path pattern to match against.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub path: Option<String>,
87}
88
89impl ToolAskRule {
90    /// Creates a new ask rule matching any invocation of the given tool.
91    pub fn new(tool: impl Into<String>) -> Self {
92        Self {
93            tool: tool.into(),
94            command: None,
95            path: None,
96        }
97    }
98
99    /// Creates an ask rule for `exec_shell` matching a specific command prefix.
100    pub fn exec_shell(command: impl Into<String>) -> Self {
101        Self {
102            tool: "exec_shell".to_string(),
103            command: Some(command.into()),
104            path: None,
105        }
106    }
107
108    /// Creates an ask rule for a file-tool matching a specific path pattern.
109    pub fn file_path(tool: impl Into<String>, path: impl Into<String>) -> Self {
110        Self {
111            tool: tool.into(),
112            command: None,
113            path: Some(path.into()),
114        }
115    }
116
117    fn label(&self) -> String {
118        let mut parts = vec![format!("tool={}", self.tool)];
119        if let Some(command) = &self.command {
120            parts.push(format!("command={command}"));
121        }
122        if let Some(path) = &self.path {
123            parts.push(format!("path={path}"));
124        }
125        parts.join(" ")
126    }
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
130#[serde(rename_all = "snake_case")]
131/// Policy mode controlling when tool invocations require human approval.
132pub enum AskForApproval {
133    /// Skip approval if the command matches a trusted prefix; otherwise require it.
134    UnlessTrusted,
135    /// Allow execution and only request approval after a failure occurs.
136    OnFailure,
137    /// Always require approval before execution.
138    OnRequest,
139    /// Reject invocations outright based on specific criteria.
140    Reject {
141        /// Whether sandbox approval requests are rejected.
142        sandbox_approval: bool,
143        /// Whether rule-exception requests are rejected.
144        rules: bool,
145        /// Whether MCP elicitation requests are rejected.
146        mcp_elicitations: bool,
147    },
148    /// Never require approval; forbid commands that would need it.
149    Never,
150}
151
152/// A proposed amendment to the execution policy, suggesting new trusted prefixes.
153#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
154pub struct ExecPolicyAmendment {
155    /// Command prefixes to add to the trusted list.
156    pub prefixes: Vec<String>,
157}
158
159/// The approval requirement determined by the execution policy engine.
160#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
161pub enum ExecApprovalRequirement {
162    /// Execution is allowed without approval.
163    Skip {
164        /// Whether the sandbox should be bypassed for this execution.
165        bypass_sandbox: bool,
166        /// Optional proposed policy amendment (e.g., to persist the allowed prefix).
167        proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
168    },
169    /// Execution is allowed but requires human approval first.
170    NeedsApproval {
171        /// Human-readable reason explaining why approval is needed.
172        reason: String,
173        /// Optional proposed policy amendment that would be applied on approval.
174        proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
175        /// Proposed network policy amendments that would be applied on approval.
176        proposed_network_policy_amendments: Vec<NetworkPolicyAmendment>,
177    },
178    /// Execution is forbidden by policy.
179    Forbidden {
180        /// Human-readable reason explaining why execution is forbidden.
181        reason: String,
182    },
183}
184
185impl ExecApprovalRequirement {
186    /// Returns the human-readable reason for this approval requirement.
187    pub fn reason(&self) -> &str {
188        match self {
189            ExecApprovalRequirement::Skip { .. } => "Execution allowed by policy.",
190            ExecApprovalRequirement::NeedsApproval { reason, .. } => reason,
191            ExecApprovalRequirement::Forbidden { reason } => reason,
192        }
193    }
194
195    /// Returns a short phase label: `"allowed"`, `"needs_approval"`, or `"forbidden"`.
196    pub fn phase(&self) -> &'static str {
197        match self {
198            ExecApprovalRequirement::Skip { .. } => "allowed",
199            ExecApprovalRequirement::NeedsApproval { .. } => "needs_approval",
200            ExecApprovalRequirement::Forbidden { .. } => "forbidden",
201        }
202    }
203}
204
205/// The result of evaluating a command against the execution policy.
206#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
207pub struct ExecPolicyDecision {
208    /// Whether the command is allowed to execute.
209    pub allow: bool,
210    /// Whether human approval is required before execution.
211    pub requires_approval: bool,
212    /// The detailed approval requirement, including any proposed amendments.
213    pub requirement: ExecApprovalRequirement,
214    /// The rule that matched, if any (e.g. a trusted prefix or ask rule label).
215    pub matched_rule: Option<String>,
216}
217
218impl ExecPolicyDecision {
219    /// Returns the human-readable reason for this decision.
220    pub fn reason(&self) -> &str {
221        self.requirement.reason()
222    }
223}
224
225/// Input context provided to the execution policy engine for a single check.
226#[derive(Debug, Clone)]
227pub struct ExecPolicyContext<'a> {
228    /// The shell command string being evaluated.
229    pub command: &'a str,
230    /// The current working directory at invocation time.
231    pub cwd: &'a str,
232    /// The tool name (e.g. `"exec_shell"`, `"edit_file"`). Defaults to `"exec_shell"` when `None`.
233    pub tool: Option<&'a str>,
234    /// An optional file path relevant to the invocation (used for path-based ask rules).
235    pub path: Option<&'a str>,
236    /// The current approval policy mode.
237    pub ask_for_approval: AskForApproval,
238    /// The sandbox mode in effect, if any (e.g. `"workspace-write"`).
239    pub sandbox_mode: Option<&'a str>,
240}
241
242#[derive(Debug, Clone, Default)]
243pub struct ExecPolicyEngine {
244    /// Layered rulesets (builtin → agent → user). When non-empty, takes precedence
245    /// over the legacy flat lists below.
246    rulesets: Vec<Ruleset>,
247    /// Legacy flat lists kept for backward compatibility with `new()`.
248    trusted_prefixes: Vec<String>,
249    denied_prefixes: Vec<String>,
250    approved_for_session: HashSet<String>,
251    /// Arity dictionary for command-prefix allow-rule matching.
252    arity_dict: BashArityDict,
253}
254
255impl ExecPolicyEngine {
256    /// Legacy constructor: wraps the two vecs into a User-layer ruleset.
257    pub fn new(trusted_prefixes: Vec<String>, denied_prefixes: Vec<String>) -> Self {
258        Self {
259            rulesets: vec![],
260            trusted_prefixes,
261            denied_prefixes,
262            approved_for_session: HashSet::new(),
263            arity_dict: BashArityDict::new(),
264        }
265    }
266
267    /// Build an engine from explicit layered rulesets.
268    /// Rulesets are sorted by layer priority on construction.
269    pub fn with_rulesets(mut rulesets: Vec<Ruleset>) -> Self {
270        rulesets.sort_by_key(|r| r.layer);
271        Self {
272            rulesets,
273            trusted_prefixes: vec![],
274            denied_prefixes: vec![],
275            approved_for_session: HashSet::new(),
276            arity_dict: BashArityDict::new(),
277        }
278    }
279
280    /// Add a ruleset layer (re-sorts internally).
281    pub fn add_ruleset(&mut self, ruleset: Ruleset) {
282        self.rulesets.push(ruleset);
283        self.rulesets.sort_by_key(|r| r.layer);
284    }
285
286    /// Resolve the effective trusted/denied prefix sets by merging all rulesets.
287    ///
288    /// Collects all prefixes from every layer (builtin → agent → user) into flat
289    /// trusted/denied lists. The `check()` method then applies deny-always-wins
290    /// semantics: any matching deny prefix blocks the command regardless of layer.
291    /// Trusted rules are only consulted after deny checks pass.
292    fn resolve_prefixes(&self) -> (Vec<String>, Vec<String>) {
293        if self.rulesets.is_empty() {
294            return (self.trusted_prefixes.clone(), self.denied_prefixes.clone());
295        }
296        // Collect all trusted/denied across all layers, highest-priority last so they
297        // shadow lower-priority entries with the same prefix.
298        let mut trusted: Vec<String> = vec![];
299        let mut denied: Vec<String> = vec![];
300        for rs in &self.rulesets {
301            trusted.extend(rs.trusted_prefixes.iter().cloned());
302            denied.extend(rs.denied_prefixes.iter().cloned());
303        }
304        // Also merge legacy flat lists as user-layer.
305        trusted.extend(self.trusted_prefixes.iter().cloned());
306        denied.extend(self.denied_prefixes.iter().cloned());
307        (trusted, denied)
308    }
309
310    fn matching_ask_rule(&self, ctx: &ExecPolicyContext<'_>) -> Option<ToolAskRule> {
311        let tool = ctx.tool.unwrap_or("exec_shell");
312
313        self.rulesets
314            .iter()
315            .flat_map(|ruleset| ruleset.ask_rules.iter())
316            .filter(|rule| rule.tool == tool)
317            .filter(|rule| match rule.command.as_deref() {
318                Some(command) => self.arity_dict.allow_rule_matches(command, ctx.command),
319                None => true,
320            })
321            .filter(|rule| match (rule.path.as_deref(), ctx.path) {
322                (Some(pattern), Some(path)) => {
323                    normalize_path_value(pattern) == normalize_path_value(path)
324                }
325                (Some(_), None) => false,
326                (None, _) => true,
327            })
328            .max_by_key(|rule| ask_rule_specificity(rule))
329            .cloned()
330    }
331
332    /// Records an approval key for the current session so subsequent checks skip approval.
333    pub fn remember_session_approval(&mut self, approval_key: String) {
334        self.approved_for_session.insert(approval_key);
335    }
336
337    /// Returns whether the given approval key has been recorded for this session.
338    pub fn is_session_approved(&self, approval_key: &str) -> bool {
339        self.approved_for_session.contains(approval_key)
340    }
341
342    /// Evaluates a command against the policy and returns a decision.
343    ///
344    /// The evaluation order is: deny rules first (always win), then trusted prefix
345    /// matching (arity-aware), then typed ask rules, and finally the approval mode.
346    pub fn check(&self, ctx: ExecPolicyContext<'_>) -> Result<ExecPolicyDecision> {
347        let normalized = normalize_command(ctx.command);
348        let (trusted_prefixes, denied_prefixes) = self.resolve_prefixes();
349        // Deny rules use simple prefix matching (no arity semantics needed).
350        if let Some(rule) = denied_prefixes
351            .iter()
352            .find(|rule| normalized.starts_with(&normalize_command(rule)))
353        {
354            return Ok(ExecPolicyDecision {
355                allow: false,
356                requires_approval: false,
357                matched_rule: Some(rule.clone()),
358                requirement: ExecApprovalRequirement::Forbidden {
359                    reason: format!("Command blocked by denied prefix rule '{rule}'"),
360                },
361            });
362        }
363
364        // Allow (trusted) rules use arity-aware prefix matching so that
365        // `auto_allow = ["git status"]` matches `git status -s` but NOT
366        // `git push origin main`.
367        let trusted_rule = trusted_prefixes
368            .iter()
369            .find(|rule| self.arity_dict.allow_rule_matches(rule, ctx.command))
370            .cloned();
371        let is_trusted = trusted_rule.is_some();
372
373        let ask_rule = self.matching_ask_rule(&ctx);
374
375        let requirement = match &ctx.ask_for_approval {
376            AskForApproval::Never => {
377                if let Some(rule) = &ask_rule {
378                    ExecApprovalRequirement::Forbidden {
379                        reason: format!(
380                            "Typed ask rule '{}' requires approval, but approval policy is never.",
381                            rule.label()
382                        ),
383                    }
384                } else {
385                    ExecApprovalRequirement::Skip {
386                        bypass_sandbox: false,
387                        proposed_execpolicy_amendment: None,
388                    }
389                }
390            }
391            AskForApproval::UnlessTrusted if is_trusted => ExecApprovalRequirement::Skip {
392                bypass_sandbox: false,
393                proposed_execpolicy_amendment: None,
394            },
395            AskForApproval::OnFailure => ExecApprovalRequirement::Skip {
396                bypass_sandbox: false,
397                proposed_execpolicy_amendment: None,
398            },
399            AskForApproval::Reject { rules, .. } if *rules => ExecApprovalRequirement::Forbidden {
400                reason: "Policy is configured to reject rule-exceptions.".to_string(),
401            },
402            _ => ExecApprovalRequirement::NeedsApproval {
403                reason: if is_trusted {
404                    "Approval requested by policy mode.".to_string()
405                } else {
406                    "Unmatched command prefix requires approval.".to_string()
407                },
408                proposed_execpolicy_amendment: if is_trusted {
409                    None
410                } else {
411                    Some(ExecPolicyAmendment {
412                        prefixes: vec![first_token(ctx.command)],
413                    })
414                },
415                proposed_network_policy_amendments: vec![NetworkPolicyAmendment {
416                    host: ctx.cwd.to_string(),
417                    action: NetworkPolicyRuleAction::Allow,
418                }],
419            },
420        };
421
422        let (allow, requires_approval) = match requirement {
423            ExecApprovalRequirement::Skip { .. } => (true, false),
424            ExecApprovalRequirement::NeedsApproval { .. } => (true, true),
425            ExecApprovalRequirement::Forbidden { .. } => (false, false),
426        };
427
428        let matched_ask_rule = if matches!(&ctx.ask_for_approval, AskForApproval::Never) {
429            ask_rule.map(|rule| rule.label())
430        } else {
431            None
432        };
433
434        Ok(ExecPolicyDecision {
435            allow,
436            requires_approval,
437            matched_rule: matched_ask_rule.or(trusted_rule),
438            requirement,
439        })
440    }
441}
442
443fn normalize_command(value: &str) -> String {
444    value.trim().to_ascii_lowercase()
445}
446
447fn first_token(command: &str) -> String {
448    command
449        .split_whitespace()
450        .next()
451        .unwrap_or_default()
452        .to_string()
453}
454
455fn normalize_path_value(value: &str) -> String {
456    value
457        .replace('\\', "/")
458        .trim()
459        .trim_matches('/')
460        .to_ascii_lowercase()
461}
462
463fn ask_rule_specificity(rule: &ToolAskRule) -> usize {
464    rule.tool.len()
465        + rule
466            .command
467            .as_ref()
468            .map_or(0, |command| command.len() + 1000)
469        + rule.path.as_ref().map_or(0, |path| path.len() + 1000)
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    fn ctx(command: &str, ask_for_approval: AskForApproval) -> ExecPolicyContext<'_> {
477        ExecPolicyContext {
478            command,
479            cwd: "/workspace",
480            tool: Some("exec_shell"),
481            path: None,
482            ask_for_approval,
483            sandbox_mode: Some("workspace-write"),
484        }
485    }
486
487    #[test]
488    fn trusted_prefix_skips_approval_when_policy_is_unless_trusted() {
489        let engine = ExecPolicyEngine::new(vec!["git status".to_string()], vec![]);
490
491        let decision = engine
492            .check(ctx("git status --porcelain", AskForApproval::UnlessTrusted))
493            .unwrap();
494
495        assert!(decision.allow);
496        assert!(!decision.requires_approval);
497        assert_eq!(decision.matched_rule.as_deref(), Some("git status"));
498        assert!(matches!(
499            decision.requirement,
500            ExecApprovalRequirement::Skip {
501                bypass_sandbox: false,
502                proposed_execpolicy_amendment: None,
503            }
504        ));
505    }
506
507    #[test]
508    fn denied_prefix_blocks_even_when_command_is_also_trusted() {
509        let engine = ExecPolicyEngine::new(
510            vec!["git status".to_string()],
511            vec!["git status".to_string()],
512        );
513
514        let decision = engine
515            .check(ctx("git status --porcelain", AskForApproval::UnlessTrusted))
516            .unwrap();
517
518        assert!(!decision.allow);
519        assert!(!decision.requires_approval);
520        assert_eq!(decision.matched_rule.as_deref(), Some("git status"));
521        assert!(matches!(
522            decision.requirement,
523            ExecApprovalRequirement::Forbidden { .. }
524        ));
525        assert_eq!(
526            decision.reason(),
527            "Command blocked by denied prefix rule 'git status'"
528        );
529    }
530
531    #[test]
532    fn unmatched_command_requires_approval_and_proposes_first_token_rule() {
533        let engine = ExecPolicyEngine::new(vec![], vec![]);
534
535        let decision = engine
536            .check(ctx("cargo test --workspace", AskForApproval::UnlessTrusted))
537            .unwrap();
538
539        assert!(decision.allow);
540        assert!(decision.requires_approval);
541        assert_eq!(decision.matched_rule, None);
542        match decision.requirement {
543            ExecApprovalRequirement::NeedsApproval {
544                proposed_execpolicy_amendment: Some(amendment),
545                proposed_network_policy_amendments,
546                ..
547            } => {
548                assert_eq!(amendment.prefixes, vec!["cargo"]);
549                assert_eq!(
550                    proposed_network_policy_amendments,
551                    vec![NetworkPolicyAmendment {
552                        host: "/workspace".to_string(),
553                        action: NetworkPolicyRuleAction::Allow,
554                    }]
555                );
556            }
557            other => panic!("expected approval with proposed amendment, got {other:?}"),
558        }
559    }
560
561    #[test]
562    fn trusted_command_in_on_request_mode_still_requires_approval_without_new_rule() {
563        let engine = ExecPolicyEngine::new(vec!["cargo test".to_string()], vec![]);
564
565        let decision = engine
566            .check(ctx("cargo test --workspace", AskForApproval::OnRequest))
567            .unwrap();
568
569        assert!(decision.allow);
570        assert!(decision.requires_approval);
571        assert_eq!(decision.matched_rule.as_deref(), Some("cargo test"));
572        match decision.requirement {
573            ExecApprovalRequirement::NeedsApproval {
574                proposed_execpolicy_amendment,
575                ..
576            } => assert_eq!(proposed_execpolicy_amendment, None),
577            other => panic!("expected approval without amendment, got {other:?}"),
578        }
579    }
580
581    #[test]
582    fn reject_rules_mode_forbids_unmatched_command() {
583        let engine = ExecPolicyEngine::new(vec![], vec![]);
584
585        let decision = engine
586            .check(ctx(
587                "npm install",
588                AskForApproval::Reject {
589                    sandbox_approval: false,
590                    rules: true,
591                    mcp_elicitations: false,
592                },
593            ))
594            .unwrap();
595
596        assert!(!decision.allow);
597        assert!(!decision.requires_approval);
598        assert_eq!(decision.matched_rule, None);
599        assert_eq!(decision.requirement.phase(), "forbidden");
600        assert_eq!(
601            decision.reason(),
602            "Policy is configured to reject rule-exceptions."
603        );
604    }
605
606    #[test]
607    fn typed_ask_rule_forbids_matching_command_when_policy_is_never() {
608        let engine = ExecPolicyEngine::with_rulesets(vec![
609            Ruleset::user(vec![], vec![])
610                .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
611        ]);
612
613        let decision = engine
614            .check(ctx("cargo test --workspace", AskForApproval::Never))
615            .unwrap();
616
617        assert!(!decision.allow);
618        assert!(!decision.requires_approval);
619        assert_eq!(
620            decision.matched_rule.as_deref(),
621            Some("tool=exec_shell command=cargo test")
622        );
623        assert_eq!(decision.requirement.phase(), "forbidden");
624        assert_eq!(
625            decision.reason(),
626            "Typed ask rule 'tool=exec_shell command=cargo test' requires approval, but approval policy is never."
627        );
628    }
629
630    #[test]
631    fn typed_ask_rule_is_ignored_outside_never_mode_for_now() {
632        let engine = ExecPolicyEngine::with_rulesets(vec![
633            Ruleset::user(vec![], vec![])
634                .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
635        ]);
636
637        let decision = engine
638            .check(ctx("cargo test --workspace", AskForApproval::UnlessTrusted))
639            .unwrap();
640
641        assert!(decision.allow);
642        assert!(decision.requires_approval);
643        assert_eq!(decision.matched_rule, None);
644        match decision.requirement {
645            ExecApprovalRequirement::NeedsApproval {
646                proposed_execpolicy_amendment: Some(amendment),
647                ..
648            } => assert_eq!(amendment.prefixes, vec!["cargo"]),
649            other => panic!("expected unchanged approval behavior, got {other:?}"),
650        }
651    }
652
653    #[test]
654    fn typed_ask_rule_does_not_change_allow_deny_precedence() {
655        let engine = ExecPolicyEngine::with_rulesets(vec![
656            Ruleset::user(
657                vec!["cargo test".to_string()],
658                vec!["cargo test --danger".to_string()],
659            )
660            .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
661        ]);
662
663        let trusted = engine
664            .check(ctx("cargo test --workspace", AskForApproval::UnlessTrusted))
665            .unwrap();
666        assert!(trusted.allow);
667        assert!(!trusted.requires_approval);
668        assert_eq!(trusted.matched_rule.as_deref(), Some("cargo test"));
669
670        let denied = engine
671            .check(ctx("cargo test --danger", AskForApproval::Never))
672            .unwrap();
673        assert!(!denied.allow);
674        assert!(!denied.requires_approval);
675        assert_eq!(denied.matched_rule.as_deref(), Some("cargo test --danger"));
676        assert_eq!(
677            denied.reason(),
678            "Command blocked by denied prefix rule 'cargo test --danger'"
679        );
680    }
681
682    #[test]
683    fn typed_ask_rule_label_wins_when_never_blocks_trusted_command() {
684        let engine = ExecPolicyEngine::with_rulesets(vec![
685            Ruleset::user(vec!["cargo test".to_string()], vec![])
686                .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
687        ]);
688
689        let decision = engine
690            .check(ctx("cargo test --workspace", AskForApproval::Never))
691            .unwrap();
692
693        assert!(!decision.allow);
694        assert_eq!(
695            decision.matched_rule.as_deref(),
696            Some("tool=exec_shell command=cargo test")
697        );
698        assert_eq!(
699            decision.reason(),
700            "Typed ask rule 'tool=exec_shell command=cargo test' requires approval, but approval policy is never."
701        );
702    }
703
704    #[test]
705    fn typed_ask_path_matching_trims_spaces_before_boundary_slashes() {
706        let engine = ExecPolicyEngine::with_rulesets(vec![
707            Ruleset::user(vec![], vec![])
708                .with_ask_rules(vec![ToolAskRule::file_path("edit_file", " /TMP/PROJECT/ ")]),
709        ]);
710
711        let decision = engine
712            .check(ExecPolicyContext {
713                command: "",
714                cwd: "/workspace",
715                tool: Some("edit_file"),
716                path: Some("tmp/project"),
717                ask_for_approval: AskForApproval::Never,
718                sandbox_mode: Some("workspace-write"),
719            })
720            .unwrap();
721
722        assert!(!decision.allow);
723        assert_eq!(
724            decision.matched_rule.as_deref(),
725            Some("tool=edit_file path= /TMP/PROJECT/ ")
726        );
727    }
728}