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