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| {
317                ruleset
318                    .ask_rules
319                    .iter()
320                    .map(move |rule| (ruleset.layer, rule))
321            })
322            .filter(|(_, rule)| rule.tool == tool)
323            .filter(|(_, rule)| match rule.command.as_deref() {
324                Some(command) => self.arity_dict.allow_rule_matches(command, ctx.command),
325                None => true,
326            })
327            .filter(|(_, rule)| match (rule.path.as_deref(), ctx.path) {
328                (Some(pattern), Some(path)) => {
329                    normalize_path_value(pattern) == normalize_path_value(path)
330                }
331                (Some(_), None) => false,
332                (None, _) => true,
333            })
334            .max_by_key(|(layer, rule)| (*layer, ask_rule_specificity(rule)))
335            .map(|(_, rule)| rule.clone())
336    }
337
338    /// Records an approval key for the current session so subsequent checks skip approval.
339    pub fn remember_session_approval(&mut self, approval_key: String) {
340        self.approved_for_session.insert(approval_key);
341    }
342
343    /// Returns whether the given approval key has been recorded for this session.
344    pub fn is_session_approved(&self, approval_key: &str) -> bool {
345        self.approved_for_session.contains(approval_key)
346    }
347
348    /// Evaluates a command against the policy and returns a decision.
349    ///
350    /// The evaluation order is: deny rules first (always win), then trusted prefix
351    /// matching (arity-aware), then typed ask rules, and finally the approval mode.
352    pub fn check(&self, ctx: ExecPolicyContext<'_>) -> Result<ExecPolicyDecision> {
353        let normalized = normalize_command(ctx.command);
354        let (trusted_prefixes, denied_prefixes) = self.resolve_prefixes();
355        // Deny rules use word-boundary prefix matching: the command must either
356        // equal the rule or start with the rule followed by a space, so "rm"
357        // blocks "rm -rf /" but NOT "rmdir" or "rmview".
358        if let Some(rule) = denied_prefixes.iter().find(|rule| {
359            let norm_rule = normalize_command(rule);
360            normalized == norm_rule
361                || (normalized.starts_with(&norm_rule)
362                    && normalized.as_bytes().get(norm_rule.len()) == Some(&b' '))
363        }) {
364            return Ok(ExecPolicyDecision {
365                allow: false,
366                requires_approval: false,
367                matched_rule: Some(rule.clone()),
368                requirement: ExecApprovalRequirement::Forbidden {
369                    reason: format!("Command blocked by denied prefix rule '{rule}'"),
370                },
371            });
372        }
373
374        // Allow (trusted) rules use arity-aware prefix matching so that
375        // `auto_allow = ["git status"]` matches `git status -s` but NOT
376        // `git push origin main`.
377        let trusted_rule = trusted_prefixes
378            .iter()
379            .find(|rule| self.arity_dict.allow_rule_matches(rule, ctx.command))
380            .cloned();
381        let is_trusted = trusted_rule.is_some();
382
383        let ask_rule = self.matching_ask_rule(&ctx);
384
385        let mut matched_ask_rule = None;
386        // Resolve a matching typed ask-rule first. Ask-rules take precedence over
387        // mode-based handling for everything except `Never` (which forbids,
388        // because no prompt can be shown) and `Reject { rules: true }` (which
389        // explicitly rejects rule-exceptions). This ordering is checked against
390        // the experimental `if let` match-guard the original PR used; it is
391        // reproduced here with plain control flow for edition-2024 stable.
392        let ask_rule_requirement = match &ctx.ask_for_approval {
393            AskForApproval::Never | AskForApproval::Reject { rules: true, .. } => None,
394            _ => ask_rule.as_ref().map(|rule| {
395                matched_ask_rule = Some(rule.label());
396                ExecApprovalRequirement::NeedsApproval {
397                    reason: format!("Typed ask rule '{}' requires approval.", rule.label()),
398                    proposed_execpolicy_amendment: None,
399                    // A typed ask-rule approval (exec/fn/MCP) must not touch
400                    // network policy. The original PR allow-listed `ctx.cwd` as a
401                    // network host here, which is incorrect and security-relevant:
402                    // approving e.g. an exec rule should never create a network
403                    // allow-entry. Emit no network amendments for ask-rule prompts.
404                    proposed_network_policy_amendments: Vec::new(),
405                }
406            }),
407        };
408
409        let requirement = if let Some(req) = ask_rule_requirement {
410            req
411        } else {
412            match &ctx.ask_for_approval {
413                AskForApproval::Never => {
414                    if let Some(rule) = &ask_rule {
415                        matched_ask_rule = Some(rule.label());
416                        ExecApprovalRequirement::Forbidden {
417                            reason: format!(
418                                "Typed ask rule '{}' requires approval, but approval policy is never.",
419                                rule.label()
420                            ),
421                        }
422                    } else {
423                        ExecApprovalRequirement::Skip {
424                            bypass_sandbox: false,
425                            proposed_execpolicy_amendment: None,
426                        }
427                    }
428                }
429                AskForApproval::Reject { rules, .. } if *rules => {
430                    ExecApprovalRequirement::Forbidden {
431                        reason: "Policy is configured to reject rule-exceptions.".to_string(),
432                    }
433                }
434                AskForApproval::UnlessTrusted if is_trusted => ExecApprovalRequirement::Skip {
435                    bypass_sandbox: false,
436                    proposed_execpolicy_amendment: None,
437                },
438                AskForApproval::OnFailure => ExecApprovalRequirement::Skip {
439                    bypass_sandbox: false,
440                    proposed_execpolicy_amendment: None,
441                },
442                _ => ExecApprovalRequirement::NeedsApproval {
443                    reason: if is_trusted {
444                        "Approval requested by policy mode.".to_string()
445                    } else {
446                        "Unmatched command prefix requires approval.".to_string()
447                    },
448                    proposed_execpolicy_amendment: if is_trusted {
449                        None
450                    } else {
451                        Some(ExecPolicyAmendment {
452                            prefixes: vec![first_token(ctx.command)],
453                        })
454                    },
455                    proposed_network_policy_amendments: vec![NetworkPolicyAmendment {
456                        host: ctx.cwd.to_string(),
457                        action: NetworkPolicyRuleAction::Allow,
458                    }],
459                },
460            }
461        };
462
463        let (allow, requires_approval) = match requirement {
464            ExecApprovalRequirement::Skip { .. } => (true, false),
465            ExecApprovalRequirement::NeedsApproval { .. } => (true, true),
466            ExecApprovalRequirement::Forbidden { .. } => (false, false),
467        };
468
469        Ok(ExecPolicyDecision {
470            allow,
471            requires_approval,
472            matched_rule: matched_ask_rule.or(trusted_rule),
473            requirement,
474        })
475    }
476}
477
478fn normalize_command(value: &str) -> String {
479    // Normalize: lowercase, collapse internal whitespace to single spaces.
480    // This prevents bypass via "git  status" (double space) vs "git status".
481    value
482        .split_whitespace()
483        .collect::<Vec<_>>()
484        .join(" ")
485        .to_ascii_lowercase()
486}
487
488fn first_token(command: &str) -> String {
489    command
490        .split_whitespace()
491        .next()
492        .unwrap_or_default()
493        .to_string()
494}
495
496fn normalize_path_value(value: &str) -> String {
497    value
498        .replace('\\', "/")
499        .trim()
500        .trim_matches('/')
501        .to_ascii_lowercase()
502}
503
504fn ask_rule_specificity(rule: &ToolAskRule) -> usize {
505    rule.tool.len()
506        + rule
507            .command
508            .as_ref()
509            .map_or(0, |command| command.len() + 1000)
510        + rule.path.as_ref().map_or(0, |path| path.len() + 1000)
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516
517    fn ctx(command: &str, ask_for_approval: AskForApproval) -> ExecPolicyContext<'_> {
518        ExecPolicyContext {
519            command,
520            cwd: "/workspace",
521            tool: Some("exec_shell"),
522            path: None,
523            ask_for_approval,
524            sandbox_mode: Some("workspace-write"),
525        }
526    }
527
528    #[test]
529    fn trusted_prefix_skips_approval_when_policy_is_unless_trusted() {
530        let engine = ExecPolicyEngine::new(vec!["git status".to_string()], vec![]);
531
532        let decision = engine
533            .check(ctx("git status --porcelain", AskForApproval::UnlessTrusted))
534            .unwrap();
535
536        assert!(decision.allow);
537        assert!(!decision.requires_approval);
538        assert_eq!(decision.matched_rule.as_deref(), Some("git status"));
539        assert!(matches!(
540            decision.requirement,
541            ExecApprovalRequirement::Skip {
542                bypass_sandbox: false,
543                proposed_execpolicy_amendment: None,
544            }
545        ));
546    }
547
548    #[test]
549    fn denied_prefix_blocks_even_when_command_is_also_trusted() {
550        let engine = ExecPolicyEngine::new(
551            vec!["git status".to_string()],
552            vec!["git status".to_string()],
553        );
554
555        let decision = engine
556            .check(ctx("git status --porcelain", AskForApproval::UnlessTrusted))
557            .unwrap();
558
559        assert!(!decision.allow);
560        assert!(!decision.requires_approval);
561        assert_eq!(decision.matched_rule.as_deref(), Some("git status"));
562        assert!(matches!(
563            decision.requirement,
564            ExecApprovalRequirement::Forbidden { .. }
565        ));
566        assert_eq!(
567            decision.reason(),
568            "Command blocked by denied prefix rule 'git status'"
569        );
570    }
571
572    #[test]
573    fn unmatched_command_requires_approval_and_proposes_first_token_rule() {
574        let engine = ExecPolicyEngine::new(vec![], vec![]);
575
576        let decision = engine
577            .check(ctx("cargo test --workspace", AskForApproval::UnlessTrusted))
578            .unwrap();
579
580        assert!(decision.allow);
581        assert!(decision.requires_approval);
582        assert_eq!(decision.matched_rule, None);
583        match decision.requirement {
584            ExecApprovalRequirement::NeedsApproval {
585                proposed_execpolicy_amendment: Some(amendment),
586                proposed_network_policy_amendments,
587                ..
588            } => {
589                assert_eq!(amendment.prefixes, vec!["cargo"]);
590                assert_eq!(
591                    proposed_network_policy_amendments,
592                    vec![NetworkPolicyAmendment {
593                        host: "/workspace".to_string(),
594                        action: NetworkPolicyRuleAction::Allow,
595                    }]
596                );
597            }
598            other => panic!("expected approval with proposed amendment, got {other:?}"),
599        }
600    }
601
602    #[test]
603    fn trusted_command_in_on_request_mode_still_requires_approval_without_new_rule() {
604        let engine = ExecPolicyEngine::new(vec!["cargo test".to_string()], vec![]);
605
606        let decision = engine
607            .check(ctx("cargo test --workspace", AskForApproval::OnRequest))
608            .unwrap();
609
610        assert!(decision.allow);
611        assert!(decision.requires_approval);
612        assert_eq!(decision.matched_rule.as_deref(), Some("cargo test"));
613        match decision.requirement {
614            ExecApprovalRequirement::NeedsApproval {
615                proposed_execpolicy_amendment,
616                ..
617            } => assert_eq!(proposed_execpolicy_amendment, None),
618            other => panic!("expected approval without amendment, got {other:?}"),
619        }
620    }
621
622    #[test]
623    fn reject_rules_mode_forbids_unmatched_command() {
624        let engine = ExecPolicyEngine::new(vec![], vec![]);
625
626        let decision = engine
627            .check(ctx(
628                "npm install",
629                AskForApproval::Reject {
630                    sandbox_approval: false,
631                    rules: true,
632                    mcp_elicitations: false,
633                },
634            ))
635            .unwrap();
636
637        assert!(!decision.allow);
638        assert!(!decision.requires_approval);
639        assert_eq!(decision.matched_rule, None);
640        assert_eq!(decision.requirement.phase(), "forbidden");
641        assert_eq!(
642            decision.reason(),
643            "Policy is configured to reject rule-exceptions."
644        );
645    }
646
647    #[test]
648    fn typed_ask_rule_forbids_matching_command_when_policy_is_never() {
649        let engine = ExecPolicyEngine::with_rulesets(vec![
650            Ruleset::user(vec![], vec![])
651                .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
652        ]);
653
654        let decision = engine
655            .check(ctx("cargo test --workspace", AskForApproval::Never))
656            .unwrap();
657
658        assert!(!decision.allow);
659        assert!(!decision.requires_approval);
660        assert_eq!(
661            decision.matched_rule.as_deref(),
662            Some("tool=exec_shell command=cargo test")
663        );
664        assert_eq!(decision.requirement.phase(), "forbidden");
665        assert_eq!(
666            decision.reason(),
667            "Typed ask rule 'tool=exec_shell command=cargo test' requires approval, but approval policy is never."
668        );
669    }
670
671    #[test]
672    fn typed_ask_rule_requires_approval_under_unless_trusted() {
673        let engine = ExecPolicyEngine::with_rulesets(vec![
674            Ruleset::user(vec![], vec![])
675                .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
676        ]);
677
678        let decision = engine
679            .check(ctx("cargo test --workspace", AskForApproval::UnlessTrusted))
680            .unwrap();
681
682        assert!(decision.allow);
683        assert!(decision.requires_approval);
684        assert_eq!(
685            decision.matched_rule.as_deref(),
686            Some("tool=exec_shell command=cargo test")
687        );
688        match decision.requirement {
689            ExecApprovalRequirement::NeedsApproval {
690                proposed_execpolicy_amendment,
691                proposed_network_policy_amendments,
692                ..
693            } => {
694                assert_eq!(proposed_execpolicy_amendment, None);
695                // A typed ask-rule approval must not allow-list the cwd (or
696                // anything else) as a network host. See the NeedsApproval arm.
697                assert!(
698                    proposed_network_policy_amendments.is_empty(),
699                    "ask-rule approval must not propose network amendments, got {proposed_network_policy_amendments:?}"
700                );
701            }
702            other => panic!("expected typed ask approval, got {other:?}"),
703        }
704    }
705
706    #[test]
707    fn typed_ask_rule_requires_approval_under_on_failure() {
708        let engine = ExecPolicyEngine::with_rulesets(vec![
709            Ruleset::user(vec![], vec![])
710                .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
711        ]);
712
713        let decision = engine
714            .check(ctx("cargo test --workspace", AskForApproval::OnFailure))
715            .unwrap();
716
717        assert!(decision.allow);
718        assert!(decision.requires_approval);
719        assert_eq!(
720            decision.reason(),
721            "Typed ask rule 'tool=exec_shell command=cargo test' requires approval."
722        );
723    }
724
725    #[test]
726    fn typed_ask_rule_overrides_trusted_but_not_deny() {
727        let engine = ExecPolicyEngine::with_rulesets(vec![
728            Ruleset::user(
729                vec!["cargo test".to_string()],
730                vec!["cargo test --danger".to_string()],
731            )
732            .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
733        ]);
734
735        let trusted = engine
736            .check(ctx("cargo test --workspace", AskForApproval::UnlessTrusted))
737            .unwrap();
738        assert!(trusted.allow);
739        assert!(trusted.requires_approval);
740        assert_eq!(
741            trusted.matched_rule.as_deref(),
742            Some("tool=exec_shell command=cargo test")
743        );
744
745        let denied = engine
746            .check(ctx("cargo test --danger", AskForApproval::Never))
747            .unwrap();
748        assert!(!denied.allow);
749        assert!(!denied.requires_approval);
750        assert_eq!(denied.matched_rule.as_deref(), Some("cargo test --danger"));
751        assert_eq!(
752            denied.reason(),
753            "Command blocked by denied prefix rule 'cargo test --danger'"
754        );
755    }
756
757    #[test]
758    fn typed_ask_rule_prefers_higher_layer_before_specificity() {
759        let engine = ExecPolicyEngine::with_rulesets(vec![
760            Ruleset::agent(vec![], vec![])
761                .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test --workspace")]),
762            Ruleset::user(vec![], vec![])
763                .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
764        ]);
765
766        let decision = engine
767            .check(ctx(
768                "cargo test --workspace --all-features",
769                AskForApproval::UnlessTrusted,
770            ))
771            .unwrap();
772
773        assert!(decision.requires_approval);
774        assert_eq!(
775            decision.matched_rule.as_deref(),
776            Some("tool=exec_shell command=cargo test")
777        );
778    }
779
780    #[test]
781    fn reject_rules_mode_still_forbids_matching_ask_rule() {
782        let engine = ExecPolicyEngine::with_rulesets(vec![
783            Ruleset::user(vec![], vec![])
784                .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
785        ]);
786
787        let decision = engine
788            .check(ctx(
789                "cargo test --workspace",
790                AskForApproval::Reject {
791                    sandbox_approval: false,
792                    rules: true,
793                    mcp_elicitations: false,
794                },
795            ))
796            .unwrap();
797
798        assert!(!decision.allow);
799        assert!(!decision.requires_approval);
800        assert_eq!(decision.matched_rule, None);
801        assert_eq!(
802            decision.reason(),
803            "Policy is configured to reject rule-exceptions."
804        );
805    }
806
807    #[test]
808    fn typed_ask_rule_label_wins_when_never_blocks_trusted_command() {
809        let engine = ExecPolicyEngine::with_rulesets(vec![
810            Ruleset::user(vec!["cargo test".to_string()], vec![])
811                .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
812        ]);
813
814        let decision = engine
815            .check(ctx("cargo test --workspace", AskForApproval::Never))
816            .unwrap();
817
818        assert!(!decision.allow);
819        assert_eq!(
820            decision.matched_rule.as_deref(),
821            Some("tool=exec_shell command=cargo test")
822        );
823        assert_eq!(
824            decision.reason(),
825            "Typed ask rule 'tool=exec_shell command=cargo test' requires approval, but approval policy is never."
826        );
827    }
828
829    #[test]
830    fn typed_ask_path_matching_trims_spaces_before_boundary_slashes() {
831        let engine = ExecPolicyEngine::with_rulesets(vec![
832            Ruleset::user(vec![], vec![])
833                .with_ask_rules(vec![ToolAskRule::file_path("edit_file", " /TMP/PROJECT/ ")]),
834        ]);
835
836        let decision = engine
837            .check(ExecPolicyContext {
838                command: "",
839                cwd: "/workspace",
840                tool: Some("edit_file"),
841                path: Some("tmp/project"),
842                ask_for_approval: AskForApproval::Never,
843                sandbox_mode: Some("workspace-write"),
844            })
845            .unwrap();
846
847        assert!(!decision.allow);
848        assert_eq!(
849            decision.matched_rule.as_deref(),
850            Some("tool=edit_file path= /TMP/PROJECT/ ")
851        );
852    }
853}