Skip to main content

codewhale_execpolicy/
lib.rs

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