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        let normalized_path = ctx
314            .path
315            .and_then(|path| normalize_workspace_relative_path(path, ctx.cwd));
316
317        self.rulesets
318            .iter()
319            .flat_map(|ruleset| {
320                ruleset
321                    .ask_rules
322                    .iter()
323                    .map(move |rule| (ruleset.layer, rule))
324            })
325            .filter(|(_, rule)| rule.tool == tool)
326            .filter(|(_, rule)| match rule.command.as_deref() {
327                Some(command) => self.arity_dict.allow_rule_matches(command, ctx.command),
328                None => true,
329            })
330            .filter(|(_, rule)| match (rule.path.as_deref(), ctx.path) {
331                (Some(pattern), Some(_)) => match (
332                    normalize_workspace_relative_path(pattern, ctx.cwd),
333                    normalized_path.as_deref(),
334                ) {
335                    (Some(pattern), Some(path)) => pattern == path,
336                    _ => false,
337                },
338                (Some(_), None) => false,
339                (None, _) => true,
340            })
341            .max_by_key(|(layer, rule)| (*layer, ask_rule_specificity(rule)))
342            .map(|(_, rule)| rule.clone())
343    }
344
345    /// Records an approval key for the current session so subsequent checks skip approval.
346    pub fn remember_session_approval(&mut self, approval_key: String) {
347        self.approved_for_session.insert(approval_key);
348    }
349
350    /// Returns whether the given approval key has been recorded for this session.
351    pub fn is_session_approved(&self, approval_key: &str) -> bool {
352        self.approved_for_session.contains(approval_key)
353    }
354
355    /// Evaluates a command against the policy and returns a decision.
356    ///
357    /// The evaluation order is: deny rules first (always win), then trusted prefix
358    /// matching (arity-aware), then typed ask rules, and finally the approval mode.
359    pub fn check(&self, ctx: ExecPolicyContext<'_>) -> Result<ExecPolicyDecision> {
360        let normalized = normalize_command(ctx.command);
361        let (trusted_prefixes, denied_prefixes) = self.resolve_prefixes();
362        // Deny rules use word-boundary prefix matching: the command must either
363        // equal the rule or start with the rule followed by a space, so "rm"
364        // blocks "rm -rf /" but NOT "rmdir" or "rmview".
365        if let Some(rule) = denied_prefixes.iter().find(|rule| {
366            let norm_rule = normalize_command(rule);
367            normalized == norm_rule
368                || (normalized.starts_with(&norm_rule)
369                    && normalized.as_bytes().get(norm_rule.len()) == Some(&b' '))
370        }) {
371            return Ok(ExecPolicyDecision {
372                allow: false,
373                requires_approval: false,
374                matched_rule: Some(rule.clone()),
375                requirement: ExecApprovalRequirement::Forbidden {
376                    reason: format!("Command blocked by denied prefix rule '{rule}'"),
377                },
378            });
379        }
380
381        // Allow (trusted) rules use arity-aware prefix matching so that
382        // `auto_allow = ["git status"]` matches `git status -s` but NOT
383        // `git push origin main`.
384        let trusted_rule = trusted_prefixes
385            .iter()
386            .find(|rule| self.arity_dict.allow_rule_matches(rule, ctx.command))
387            .cloned();
388        let is_trusted = trusted_rule.is_some();
389
390        let ask_rule = self.matching_ask_rule(&ctx);
391
392        let mut matched_ask_rule = None;
393        // Resolve a matching typed ask-rule first. Ask-rules take precedence over
394        // mode-based handling for everything except `Never` (which forbids,
395        // because no prompt can be shown) and `Reject { rules: true }` (which
396        // explicitly rejects rule-exceptions). This ordering is checked against
397        // the experimental `if let` match-guard the original PR used; it is
398        // reproduced here with plain control flow for edition-2024 stable.
399        let ask_rule_requirement = match &ctx.ask_for_approval {
400            AskForApproval::Never | AskForApproval::Reject { rules: true, .. } => None,
401            _ => ask_rule.as_ref().map(|rule| {
402                matched_ask_rule = Some(rule.label());
403                ExecApprovalRequirement::NeedsApproval {
404                    reason: format!("Typed ask rule '{}' requires approval.", rule.label()),
405                    proposed_execpolicy_amendment: None,
406                    // A typed ask-rule approval (exec/fn/MCP) must not touch
407                    // network policy. The original PR allow-listed `ctx.cwd` as a
408                    // network host here, which is incorrect and security-relevant:
409                    // approving e.g. an exec rule should never create a network
410                    // allow-entry. Emit no network amendments for ask-rule prompts.
411                    proposed_network_policy_amendments: Vec::new(),
412                }
413            }),
414        };
415
416        let requirement = if let Some(req) = ask_rule_requirement {
417            req
418        } else {
419            match &ctx.ask_for_approval {
420                AskForApproval::Never => {
421                    if let Some(rule) = &ask_rule {
422                        matched_ask_rule = Some(rule.label());
423                        ExecApprovalRequirement::Forbidden {
424                            reason: format!(
425                                "Typed ask rule '{}' requires approval, but approval policy is never.",
426                                rule.label()
427                            ),
428                        }
429                    } else {
430                        ExecApprovalRequirement::Skip {
431                            bypass_sandbox: false,
432                            proposed_execpolicy_amendment: None,
433                        }
434                    }
435                }
436                AskForApproval::Reject { rules, .. } if *rules => {
437                    ExecApprovalRequirement::Forbidden {
438                        reason: "Policy is configured to reject rule-exceptions.".to_string(),
439                    }
440                }
441                AskForApproval::UnlessTrusted if is_trusted => ExecApprovalRequirement::Skip {
442                    bypass_sandbox: false,
443                    proposed_execpolicy_amendment: None,
444                },
445                AskForApproval::OnFailure => ExecApprovalRequirement::Skip {
446                    bypass_sandbox: false,
447                    proposed_execpolicy_amendment: None,
448                },
449                _ => ExecApprovalRequirement::NeedsApproval {
450                    reason: if is_trusted {
451                        "Approval requested by policy mode.".to_string()
452                    } else {
453                        "Unmatched command prefix requires approval.".to_string()
454                    },
455                    proposed_execpolicy_amendment: if is_trusted {
456                        None
457                    } else {
458                        Some(ExecPolicyAmendment {
459                            prefixes: vec![first_token(ctx.command)],
460                        })
461                    },
462                    proposed_network_policy_amendments: vec![NetworkPolicyAmendment {
463                        host: ctx.cwd.to_string(),
464                        action: NetworkPolicyRuleAction::Allow,
465                    }],
466                },
467            }
468        };
469
470        let (allow, requires_approval) = match requirement {
471            ExecApprovalRequirement::Skip { .. } => (true, false),
472            ExecApprovalRequirement::NeedsApproval { .. } => (true, true),
473            ExecApprovalRequirement::Forbidden { .. } => (false, false),
474        };
475
476        Ok(ExecPolicyDecision {
477            allow,
478            requires_approval,
479            matched_rule: matched_ask_rule.or(trusted_rule),
480            requirement,
481        })
482    }
483}
484
485fn normalize_command(value: &str) -> String {
486    // Normalize: lowercase, collapse internal whitespace to single spaces.
487    // This prevents bypass via "git  status" (double space) vs "git status".
488    value
489        .split_whitespace()
490        .collect::<Vec<_>>()
491        .join(" ")
492        .to_ascii_lowercase()
493}
494
495fn first_token(command: &str) -> String {
496    command
497        .split_whitespace()
498        .next()
499        .unwrap_or_default()
500        .to_string()
501}
502
503/// Returns a slash-separated path relative to `workspace_root` when `value` is
504/// a safe path within that workspace.
505///
506/// Paths are normalized lexically so matching does not depend on the host OS
507/// or require the path to exist. A `..` segment is rejected rather than
508/// collapsed, preventing traversal from becoming matchable. Absolute paths
509/// must have the workspace as a whole-component prefix; relative paths are
510/// interpreted as workspace-relative. Backslashes are accepted so persisted
511/// rules and tool inputs behave consistently on Windows.
512///
513/// This is the canonical normalization shared by ask-rule matching and rule
514/// persistence: callers that save a file ask rule should store the value this
515/// returns so the saved path matches the same invocation later. `None` means
516/// the path is empty, traversing, drive-relative, or outside the workspace and
517/// must not be turned into a rule.
518pub fn normalize_workspace_relative_path(value: &str, workspace_root: &str) -> Option<String> {
519    let path = parse_path_for_matching(value)?;
520    let workspace = parse_path_for_matching(workspace_root)?;
521    let workspace_root = workspace.root.as_ref()?;
522
523    let relative_components = match path.root.as_ref() {
524        Some(path_root) => {
525            if path_root != workspace_root {
526                return None;
527            }
528            path.components.strip_prefix(&workspace.components[..])?
529        }
530        None => path.components.as_slice(),
531    };
532
533    Some(relative_components.join("/"))
534}
535
536#[derive(Debug)]
537struct PathForMatching {
538    root: Option<String>,
539    components: Vec<String>,
540}
541
542fn parse_path_for_matching(value: &str) -> Option<PathForMatching> {
543    let value = value.trim().replace('\\', "/").to_ascii_lowercase();
544    if value.is_empty() {
545        return None;
546    }
547
548    let (root, components) = if let Some(path) = value.strip_prefix('/') {
549        (Some("/".to_string()), path)
550    } else if is_windows_absolute_path(&value) {
551        (Some(value[..2].to_string()), &value[3..])
552    } else if has_windows_drive_prefix(&value) {
553        // `C:foo` is drive-relative on Windows. Treating it as a
554        // workspace-relative path could match outside the workspace.
555        return None;
556    } else {
557        (None, value.as_str())
558    };
559
560    let mut normalized_components = Vec::new();
561    for component in components.split('/') {
562        match component {
563            "" | "." => {}
564            ".." => return None,
565            component => normalized_components.push(component.to_string()),
566        }
567    }
568
569    Some(PathForMatching {
570        root,
571        components: normalized_components,
572    })
573}
574
575fn is_windows_absolute_path(value: &str) -> bool {
576    let bytes = value.as_bytes();
577    bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/'
578}
579
580fn has_windows_drive_prefix(value: &str) -> bool {
581    let bytes = value.as_bytes();
582    bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
583}
584
585fn ask_rule_specificity(rule: &ToolAskRule) -> usize {
586    rule.tool.len()
587        + rule
588            .command
589            .as_ref()
590            .map_or(0, |command| command.len() + 1000)
591        + rule.path.as_ref().map_or(0, |path| path.len() + 1000)
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597
598    fn ctx(command: &str, ask_for_approval: AskForApproval) -> ExecPolicyContext<'_> {
599        ExecPolicyContext {
600            command,
601            cwd: "/workspace",
602            tool: Some("exec_shell"),
603            path: None,
604            ask_for_approval,
605            sandbox_mode: Some("workspace-write"),
606        }
607    }
608
609    #[test]
610    fn trusted_prefix_skips_approval_when_policy_is_unless_trusted() {
611        let engine = ExecPolicyEngine::new(vec!["git status".to_string()], vec![]);
612
613        let decision = engine
614            .check(ctx("git status --porcelain", AskForApproval::UnlessTrusted))
615            .unwrap();
616
617        assert!(decision.allow);
618        assert!(!decision.requires_approval);
619        assert_eq!(decision.matched_rule.as_deref(), Some("git status"));
620        assert!(matches!(
621            decision.requirement,
622            ExecApprovalRequirement::Skip {
623                bypass_sandbox: false,
624                proposed_execpolicy_amendment: None,
625            }
626        ));
627    }
628
629    #[test]
630    fn denied_prefix_blocks_even_when_command_is_also_trusted() {
631        let engine = ExecPolicyEngine::new(
632            vec!["git status".to_string()],
633            vec!["git status".to_string()],
634        );
635
636        let decision = engine
637            .check(ctx("git status --porcelain", AskForApproval::UnlessTrusted))
638            .unwrap();
639
640        assert!(!decision.allow);
641        assert!(!decision.requires_approval);
642        assert_eq!(decision.matched_rule.as_deref(), Some("git status"));
643        assert!(matches!(
644            decision.requirement,
645            ExecApprovalRequirement::Forbidden { .. }
646        ));
647        assert_eq!(
648            decision.reason(),
649            "Command blocked by denied prefix rule 'git status'"
650        );
651    }
652
653    #[test]
654    fn unmatched_command_requires_approval_and_proposes_first_token_rule() {
655        let engine = ExecPolicyEngine::new(vec![], vec![]);
656
657        let decision = engine
658            .check(ctx("cargo test --workspace", AskForApproval::UnlessTrusted))
659            .unwrap();
660
661        assert!(decision.allow);
662        assert!(decision.requires_approval);
663        assert_eq!(decision.matched_rule, None);
664        match decision.requirement {
665            ExecApprovalRequirement::NeedsApproval {
666                proposed_execpolicy_amendment: Some(amendment),
667                proposed_network_policy_amendments,
668                ..
669            } => {
670                assert_eq!(amendment.prefixes, vec!["cargo"]);
671                assert_eq!(
672                    proposed_network_policy_amendments,
673                    vec![NetworkPolicyAmendment {
674                        host: "/workspace".to_string(),
675                        action: NetworkPolicyRuleAction::Allow,
676                    }]
677                );
678            }
679            other => panic!("expected approval with proposed amendment, got {other:?}"),
680        }
681    }
682
683    #[test]
684    fn trusted_command_in_on_request_mode_still_requires_approval_without_new_rule() {
685        let engine = ExecPolicyEngine::new(vec!["cargo test".to_string()], vec![]);
686
687        let decision = engine
688            .check(ctx("cargo test --workspace", AskForApproval::OnRequest))
689            .unwrap();
690
691        assert!(decision.allow);
692        assert!(decision.requires_approval);
693        assert_eq!(decision.matched_rule.as_deref(), Some("cargo test"));
694        match decision.requirement {
695            ExecApprovalRequirement::NeedsApproval {
696                proposed_execpolicy_amendment,
697                ..
698            } => assert_eq!(proposed_execpolicy_amendment, None),
699            other => panic!("expected approval without amendment, got {other:?}"),
700        }
701    }
702
703    #[test]
704    fn reject_rules_mode_forbids_unmatched_command() {
705        let engine = ExecPolicyEngine::new(vec![], vec![]);
706
707        let decision = engine
708            .check(ctx(
709                "npm install",
710                AskForApproval::Reject {
711                    sandbox_approval: false,
712                    rules: true,
713                    mcp_elicitations: false,
714                },
715            ))
716            .unwrap();
717
718        assert!(!decision.allow);
719        assert!(!decision.requires_approval);
720        assert_eq!(decision.matched_rule, None);
721        assert_eq!(decision.requirement.phase(), "forbidden");
722        assert_eq!(
723            decision.reason(),
724            "Policy is configured to reject rule-exceptions."
725        );
726    }
727
728    #[test]
729    fn typed_ask_rule_forbids_matching_command_when_policy_is_never() {
730        let engine = ExecPolicyEngine::with_rulesets(vec![
731            Ruleset::user(vec![], vec![])
732                .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
733        ]);
734
735        let decision = engine
736            .check(ctx("cargo test --workspace", AskForApproval::Never))
737            .unwrap();
738
739        assert!(!decision.allow);
740        assert!(!decision.requires_approval);
741        assert_eq!(
742            decision.matched_rule.as_deref(),
743            Some("tool=exec_shell command=cargo test")
744        );
745        assert_eq!(decision.requirement.phase(), "forbidden");
746        assert_eq!(
747            decision.reason(),
748            "Typed ask rule 'tool=exec_shell command=cargo test' requires approval, but approval policy is never."
749        );
750    }
751
752    #[test]
753    fn typed_ask_rule_requires_approval_under_unless_trusted() {
754        let engine = ExecPolicyEngine::with_rulesets(vec![
755            Ruleset::user(vec![], vec![])
756                .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
757        ]);
758
759        let decision = engine
760            .check(ctx("cargo test --workspace", AskForApproval::UnlessTrusted))
761            .unwrap();
762
763        assert!(decision.allow);
764        assert!(decision.requires_approval);
765        assert_eq!(
766            decision.matched_rule.as_deref(),
767            Some("tool=exec_shell command=cargo test")
768        );
769        match decision.requirement {
770            ExecApprovalRequirement::NeedsApproval {
771                proposed_execpolicy_amendment,
772                proposed_network_policy_amendments,
773                ..
774            } => {
775                assert_eq!(proposed_execpolicy_amendment, None);
776                // A typed ask-rule approval must not allow-list the cwd (or
777                // anything else) as a network host. See the NeedsApproval arm.
778                assert!(
779                    proposed_network_policy_amendments.is_empty(),
780                    "ask-rule approval must not propose network amendments, got {proposed_network_policy_amendments:?}"
781                );
782            }
783            other => panic!("expected typed ask approval, got {other:?}"),
784        }
785    }
786
787    #[test]
788    fn typed_ask_rule_requires_approval_under_on_failure() {
789        let engine = ExecPolicyEngine::with_rulesets(vec![
790            Ruleset::user(vec![], vec![])
791                .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
792        ]);
793
794        let decision = engine
795            .check(ctx("cargo test --workspace", AskForApproval::OnFailure))
796            .unwrap();
797
798        assert!(decision.allow);
799        assert!(decision.requires_approval);
800        assert_eq!(
801            decision.reason(),
802            "Typed ask rule 'tool=exec_shell command=cargo test' requires approval."
803        );
804    }
805
806    #[test]
807    fn typed_ask_rule_overrides_trusted_but_not_deny() {
808        let engine = ExecPolicyEngine::with_rulesets(vec![
809            Ruleset::user(
810                vec!["cargo test".to_string()],
811                vec!["cargo test --danger".to_string()],
812            )
813            .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
814        ]);
815
816        let trusted = engine
817            .check(ctx("cargo test --workspace", AskForApproval::UnlessTrusted))
818            .unwrap();
819        assert!(trusted.allow);
820        assert!(trusted.requires_approval);
821        assert_eq!(
822            trusted.matched_rule.as_deref(),
823            Some("tool=exec_shell command=cargo test")
824        );
825
826        let denied = engine
827            .check(ctx("cargo test --danger", AskForApproval::Never))
828            .unwrap();
829        assert!(!denied.allow);
830        assert!(!denied.requires_approval);
831        assert_eq!(denied.matched_rule.as_deref(), Some("cargo test --danger"));
832        assert_eq!(
833            denied.reason(),
834            "Command blocked by denied prefix rule 'cargo test --danger'"
835        );
836    }
837
838    #[test]
839    fn typed_ask_rule_prefers_higher_layer_before_specificity() {
840        let engine = ExecPolicyEngine::with_rulesets(vec![
841            Ruleset::agent(vec![], vec![])
842                .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test --workspace")]),
843            Ruleset::user(vec![], vec![])
844                .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
845        ]);
846
847        let decision = engine
848            .check(ctx(
849                "cargo test --workspace --all-features",
850                AskForApproval::UnlessTrusted,
851            ))
852            .unwrap();
853
854        assert!(decision.requires_approval);
855        assert_eq!(
856            decision.matched_rule.as_deref(),
857            Some("tool=exec_shell command=cargo test")
858        );
859    }
860
861    #[test]
862    fn reject_rules_mode_still_forbids_matching_ask_rule() {
863        let engine = ExecPolicyEngine::with_rulesets(vec![
864            Ruleset::user(vec![], vec![])
865                .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
866        ]);
867
868        let decision = engine
869            .check(ctx(
870                "cargo test --workspace",
871                AskForApproval::Reject {
872                    sandbox_approval: false,
873                    rules: true,
874                    mcp_elicitations: false,
875                },
876            ))
877            .unwrap();
878
879        assert!(!decision.allow);
880        assert!(!decision.requires_approval);
881        assert_eq!(decision.matched_rule, None);
882        assert_eq!(
883            decision.reason(),
884            "Policy is configured to reject rule-exceptions."
885        );
886    }
887
888    #[test]
889    fn typed_ask_rule_label_wins_when_never_blocks_trusted_command() {
890        let engine = ExecPolicyEngine::with_rulesets(vec![
891            Ruleset::user(vec!["cargo test".to_string()], vec![])
892                .with_ask_rules(vec![ToolAskRule::exec_shell("cargo test")]),
893        ]);
894
895        let decision = engine
896            .check(ctx("cargo test --workspace", AskForApproval::Never))
897            .unwrap();
898
899        assert!(!decision.allow);
900        assert_eq!(
901            decision.matched_rule.as_deref(),
902            Some("tool=exec_shell command=cargo test")
903        );
904        assert_eq!(
905            decision.reason(),
906            "Typed ask rule 'tool=exec_shell command=cargo test' requires approval, but approval policy is never."
907        );
908    }
909
910    #[test]
911    fn typed_ask_path_matching_trims_spaces_before_workspace_normalization() {
912        let engine =
913            ExecPolicyEngine::with_rulesets(vec![Ruleset::user(vec![], vec![]).with_ask_rules(
914                vec![ToolAskRule::file_path(
915                    "edit_file",
916                    " /workspace/tmp/project/ ",
917                )],
918            )]);
919
920        let decision = engine
921            .check(ExecPolicyContext {
922                command: "",
923                cwd: "/workspace",
924                tool: Some("edit_file"),
925                path: Some("tmp/project"),
926                ask_for_approval: AskForApproval::Never,
927                sandbox_mode: Some("workspace-write"),
928            })
929            .unwrap();
930
931        assert!(!decision.allow);
932        assert_eq!(
933            decision.matched_rule.as_deref(),
934            Some("tool=edit_file path= /workspace/tmp/project/ ")
935        );
936    }
937
938    #[test]
939    fn typed_ask_path_matching_normalizes_relative_and_absolute_workspace_paths() {
940        let relative_rule = ExecPolicyEngine::with_rulesets(vec![
941            Ruleset::user(vec![], vec![])
942                .with_ask_rules(vec![ToolAskRule::file_path("edit_file", "src/a.rs")]),
943        ]);
944        let absolute_path = relative_rule
945            .check(ExecPolicyContext {
946                command: "",
947                cwd: "/workspace",
948                tool: Some("edit_file"),
949                path: Some("/workspace/src/a.rs"),
950                ask_for_approval: AskForApproval::OnFailure,
951                sandbox_mode: Some("workspace-write"),
952            })
953            .unwrap();
954        assert!(absolute_path.requires_approval);
955
956        let absolute_rule =
957            ExecPolicyEngine::with_rulesets(vec![Ruleset::user(vec![], vec![]).with_ask_rules(
958                vec![ToolAskRule::file_path("edit_file", "/workspace/src/a.rs")],
959            )]);
960        let relative_path = absolute_rule
961            .check(ExecPolicyContext {
962                command: "",
963                cwd: "/workspace",
964                tool: Some("edit_file"),
965                path: Some("src/a.rs"),
966                ask_for_approval: AskForApproval::OnFailure,
967                sandbox_mode: Some("workspace-write"),
968            })
969            .unwrap();
970        assert!(relative_path.requires_approval);
971    }
972
973    #[test]
974    fn typed_ask_path_matching_rejects_traversal_and_external_paths() {
975        for (rule_path, path) in [
976            ("src/a.rs", "../src/a.rs"),
977            ("src/a.rs", "/workspace/src/../src/a.rs"),
978            ("src/a.rs", "/src/a.rs"),
979            ("../src/a.rs", "src/a.rs"),
980            ("/src/a.rs", "src/a.rs"),
981        ] {
982            let engine = ExecPolicyEngine::with_rulesets(vec![
983                Ruleset::user(vec![], vec![])
984                    .with_ask_rules(vec![ToolAskRule::file_path("edit_file", rule_path)]),
985            ]);
986            let decision = engine
987                .check(ExecPolicyContext {
988                    command: "",
989                    cwd: "/workspace",
990                    tool: Some("edit_file"),
991                    path: Some(path),
992                    ask_for_approval: AskForApproval::OnFailure,
993                    sandbox_mode: Some("workspace-write"),
994                })
995                .unwrap();
996            assert_eq!(
997                decision.matched_rule, None,
998                "rule {rule_path:?} and path {path:?} must not match"
999            );
1000        }
1001    }
1002
1003    #[test]
1004    fn typed_ask_path_matching_accepts_windows_separators() {
1005        let engine = ExecPolicyEngine::with_rulesets(vec![
1006            Ruleset::user(vec![], vec![])
1007                .with_ask_rules(vec![ToolAskRule::file_path("edit_file", r"src\a.rs")]),
1008        ]);
1009
1010        let decision = engine
1011            .check(ExecPolicyContext {
1012                command: "",
1013                cwd: r"C:\workspace",
1014                tool: Some("edit_file"),
1015                path: Some(r"C:\workspace\src\a.rs"),
1016                ask_for_approval: AskForApproval::OnFailure,
1017                sandbox_mode: Some("workspace-write"),
1018            })
1019            .unwrap();
1020
1021        assert!(decision.requires_approval);
1022    }
1023}