Skip to main content

clash_policy/
match_tree.rs

1//! Match tree IR — a uniform trie for policy evaluation.
2//!
3//! Replaces the multi-node-type tree with a single `Condition` node type.
4//! Capability domains (exec/fs/net) become Starlark compile-time sugar,
5//! not IR concepts. Evaluation is a single DFS pass.
6
7use std::collections::HashMap;
8use std::sync::Arc;
9
10use crate::Effect;
11use crate::ir::{DecisionTrace, PolicyDecision, RuleMatch, RuleSkip};
12use crate::sandbox_types::{SandboxPolicy, ViolationAction};
13use regex::Regex;
14use serde::{Deserialize, Serialize};
15
16// ---------------------------------------------------------------------------
17// Tool alias table — inlined from clash::agents to avoid a circular dep.
18// The canonical → internal mapping must stay in sync with clash::agents::TOOL_ALIASES.
19// ---------------------------------------------------------------------------
20
21struct ToolAlias {
22    canonical: &'static str,
23    internal: &'static str,
24}
25
26const TOOL_ALIASES: &[ToolAlias] = &[
27    ToolAlias {
28        canonical: "shell",
29        internal: "Bash",
30    },
31    ToolAlias {
32        canonical: "read",
33        internal: "Read",
34    },
35    ToolAlias {
36        canonical: "write",
37        internal: "Write",
38    },
39    ToolAlias {
40        canonical: "edit",
41        internal: "Edit",
42    },
43    ToolAlias {
44        canonical: "glob",
45        internal: "Glob",
46    },
47    ToolAlias {
48        canonical: "grep",
49        internal: "Grep",
50    },
51    ToolAlias {
52        canonical: "web_fetch",
53        internal: "WebFetch",
54    },
55    ToolAlias {
56        canonical: "web_search",
57        internal: "WebSearch",
58    },
59];
60
61/// Given a Clash canonical name, return the internal name.
62fn canonical_to_internal(clash_name: &str) -> Option<&'static str> {
63    let lower = clash_name.to_lowercase();
64    TOOL_ALIASES
65        .iter()
66        .find(|a| a.canonical.to_lowercase() == lower)
67        .map(|a| a.internal)
68}
69
70/// Given an internal name, return the Clash canonical name.
71fn internal_to_canonical(internal_name: &str) -> Option<&'static str> {
72    let lower = internal_name.to_lowercase();
73    TOOL_ALIASES
74        .iter()
75        .find(|a| a.internal.to_lowercase() == lower)
76        .map(|a| a.canonical)
77}
78
79// ---------------------------------------------------------------------------
80// Value types
81// ---------------------------------------------------------------------------
82
83/// A value that can be resolved at eval time.
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case")]
86pub enum Value {
87    /// Resolve an environment variable.
88    Env(String),
89    /// A literal string.
90    Literal(String),
91    /// Join segments with `/`.
92    Path(Vec<Value>),
93}
94
95impl std::fmt::Display for Value {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        match self {
98            Value::Env(env) => write!(f, "${env}"),
99            Value::Literal(lit) => write!(f, "'{lit}'"),
100            Value::Path(values) => write!(f, "{values:#?}"),
101        }
102    }
103}
104
105impl Value {
106    /// Resolve this value to a string.
107    pub fn resolve(&self) -> String {
108        match self {
109            Value::Env(var) => std::env::var(var).unwrap_or_default(),
110            Value::Literal(s) => s.clone(),
111            Value::Path(parts) => parts
112                .iter()
113                .map(|p| p.resolve())
114                .collect::<Vec<_>>()
115                .join("/"),
116        }
117    }
118}
119
120// ---------------------------------------------------------------------------
121// Pattern types
122// ---------------------------------------------------------------------------
123
124/// A pattern for matching against observable values.
125#[derive(Debug, Clone, Serialize, Deserialize)]
126#[serde(rename_all = "snake_case")]
127pub enum Pattern {
128    /// Matches anything.
129    Wildcard,
130    /// Matches the resolved value of a `Value`.
131    Literal(Value),
132    /// Matches against a compiled regex.
133    Regex(
134        #[serde(
135            serialize_with = "serialize_regex",
136            deserialize_with = "deserialize_regex"
137        )]
138        Arc<Regex>,
139    ),
140    /// Matches if any sub-pattern matches.
141    AnyOf(Vec<Pattern>),
142    /// Matches if the sub-pattern does NOT match.
143    Not(Box<Pattern>),
144    /// Matches if the string starts with the resolved value (subpath matching).
145    /// Matches both exact (path == prefix) and children (path starts with prefix + "/").
146    Prefix(Value),
147    /// Matches direct children of the resolved path (one level deep).
148    /// Matches paths like `prefix/name` but not `prefix/name/sub`.
149    ChildOf(Value),
150}
151
152fn serialize_regex<S: serde::Serializer>(re: &Arc<Regex>, s: S) -> Result<S::Ok, S::Error> {
153    s.serialize_str(re.as_str())
154}
155
156fn deserialize_regex<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Arc<Regex>, D::Error> {
157    let s = String::deserialize(d)?;
158    Regex::new(&s)
159        .map(Arc::new)
160        .map_err(serde::de::Error::custom)
161}
162
163impl Pattern {
164    /// Test whether this pattern matches a string value.
165    pub fn matches(&self, value: &str) -> bool {
166        match self {
167            Pattern::Wildcard => true,
168            Pattern::Literal(v) => v.resolve() == value,
169            Pattern::Regex(re) => re.is_match(value),
170            Pattern::AnyOf(pats) => pats.iter().any(|p| p.matches(value)),
171            Pattern::Not(p) => !p.matches(value),
172            Pattern::Prefix(v) => {
173                let prefix = v.resolve();
174                // An empty prefix (e.g. unset env var) matches nothing — not everything.
175                !prefix.is_empty() && (value == prefix || value.starts_with(&format!("{prefix}/")))
176            }
177            Pattern::ChildOf(v) => {
178                let parent = v.resolve();
179                value
180                    .strip_prefix(&format!("{parent}/"))
181                    .is_some_and(|rest| !rest.contains('/'))
182            }
183        }
184    }
185
186    /// Specificity score for sorting. Higher = more specific.
187    pub fn specificity(&self) -> u8 {
188        match self {
189            Pattern::Wildcard => 0,
190            Pattern::Not(_) => 1,
191            Pattern::AnyOf(_) => 1,
192            Pattern::Regex(_) => 2,
193            Pattern::Prefix(_) => 3,
194            Pattern::ChildOf(_) => 3,
195            Pattern::Literal(_) => 4,
196        }
197    }
198}
199
200impl PartialEq for Pattern {
201    fn eq(&self, other: &Self) -> bool {
202        match (self, other) {
203            (Pattern::Wildcard, Pattern::Wildcard) => true,
204            (Pattern::Literal(a), Pattern::Literal(b)) => a == b,
205            (Pattern::Regex(a), Pattern::Regex(b)) => a.as_str() == b.as_str(),
206            (Pattern::AnyOf(a), Pattern::AnyOf(b)) => a == b,
207            (Pattern::Not(a), Pattern::Not(b)) => a == b,
208            (Pattern::Prefix(a), Pattern::Prefix(b)) => a == b,
209            (Pattern::ChildOf(a), Pattern::ChildOf(b)) => a == b,
210            _ => false,
211        }
212    }
213}
214
215impl Eq for Pattern {}
216
217// ---------------------------------------------------------------------------
218// Observable
219// ---------------------------------------------------------------------------
220
221/// What to observe from the query context.
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
223#[serde(rename_all = "snake_case")]
224pub enum Observable {
225    /// The tool name (e.g. "Bash", "Read", "Write").
226    ToolName,
227    /// The hook type.
228    HookType,
229    /// The agent name.
230    AgentName,
231    /// A positional argument (0-indexed).
232    PositionalArg(i32),
233    /// Scan all args — true if any matches.
234    HasArg,
235    /// A named argument by key.
236    NamedArg(String),
237    /// A path into structured tool_input JSON.
238    NestedField(Vec<String>),
239    /// Capability: filesystem operation ("read" or "write").
240    /// Mapped from tool name: Read/Glob/Grep → "read", Write/Edit → "write".
241    FsOp,
242    /// Capability: resolved filesystem path.
243    /// Extracted from tool_input: file_path, path, or pattern field.
244    FsPath,
245    /// Capability: network domain.
246    /// Extracted from WebFetch URL or "*" for WebSearch.
247    NetDomain,
248    /// The permission mode (e.g. "default", "plan").
249    /// From Claude Code's permission_mode hook field.
250    Mode,
251}
252
253impl Observable {
254    /// Specificity score for sorting. Higher = more specific.
255    pub fn specificity(&self) -> u8 {
256        match self {
257            Observable::ToolName => 1,
258            Observable::HookType => 1,
259            Observable::AgentName => 1,
260            Observable::PositionalArg(_) => 2,
261            Observable::HasArg => 1,
262            Observable::NamedArg(_) => 2,
263            Observable::NestedField(path) => 2 + path.len().min(3) as u8,
264            Observable::FsOp => 1,
265            Observable::FsPath => 2,
266            Observable::NetDomain => 2,
267            Observable::Mode => 0,
268        }
269    }
270}
271
272// ---------------------------------------------------------------------------
273// Sandbox reference
274// ---------------------------------------------------------------------------
275
276/// Reference to a named sandbox definition.
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
278pub struct SandboxRef(pub String);
279
280// ---------------------------------------------------------------------------
281// Decision
282// ---------------------------------------------------------------------------
283
284/// A leaf decision in the match tree.
285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
286#[serde(rename_all = "snake_case")]
287pub enum Decision {
288    /// Allow, optionally with a sandbox.
289    Allow(Option<SandboxRef>),
290    /// Deny.
291    Deny,
292    /// Ask the user, optionally with a sandbox.
293    Ask(Option<SandboxRef>),
294}
295
296impl Decision {
297    pub fn effect(&self) -> Effect {
298        match self {
299            Decision::Allow(_) => Effect::Allow,
300            Decision::Deny => Effect::Deny,
301            Decision::Ask(_) => Effect::Ask,
302        }
303    }
304
305    pub fn sandbox_ref(&self) -> Option<&SandboxRef> {
306        match self {
307            Decision::Allow(sb) | Decision::Ask(sb) => sb.as_ref(),
308            Decision::Deny => None,
309        }
310    }
311}
312
313// ---------------------------------------------------------------------------
314// Node
315// ---------------------------------------------------------------------------
316
317/// A node in the match tree.
318#[derive(Debug, Clone, Serialize, Deserialize)]
319#[serde(rename_all = "snake_case")]
320pub enum Node {
321    /// A condition node: observe a value and test it against a pattern.
322    Condition {
323        observe: Observable,
324        pattern: Pattern,
325        /// Children sorted by specificity (most specific first).
326        children: Vec<Node>,
327        /// Optional docstring describing this rule's purpose.
328        #[serde(default, skip_serializing_if = "Option::is_none")]
329        doc: Option<String>,
330        /// Optional source provenance (e.g. policy level name).
331        #[serde(default, skip_serializing_if = "Option::is_none")]
332        source: Option<String>,
333        /// When true, asserts that no more positional args exist after this one.
334        /// Analogous to `$` in regex — the match is exhaustive.
335        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
336        terminal: bool,
337    },
338    /// A leaf decision.
339    Decision(Decision),
340}
341
342impl Node {
343    /// Stamp source provenance on a root-level Condition node if it has none.
344    ///
345    /// Leaves (`Decision`) and already-stamped nodes are left unchanged.
346    pub fn stamp_source(&mut self, source: &str) {
347        if let Node::Condition {
348            source: slot @ None,
349            ..
350        } = self
351        {
352            *slot = Some(source.to_string());
353        }
354    }
355}
356
357// ---------------------------------------------------------------------------
358// CompiledPolicy
359// ---------------------------------------------------------------------------
360
361/// A fully compiled match-tree policy, ready for evaluation.
362#[derive(Debug, Clone, Serialize, Deserialize)]
363pub struct CompiledPolicy {
364    /// Named sandbox definitions.
365    pub sandboxes: HashMap<String, SandboxPolicy>,
366    /// Root-level children of the tree.
367    pub tree: Vec<Node>,
368    /// Default effect when no rule matches.
369    #[serde(default = "default_effect")]
370    pub default_effect: Effect,
371    /// Name of the default sandbox (used by `clash shell` when no rule-specific sandbox matches).
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub default_sandbox: Option<String>,
374    /// What the model should do when a sandbox blocks an operation.
375    #[serde(default, skip_serializing_if = "ViolationAction::is_default")]
376    pub on_sandbox_violation: ViolationAction,
377    /// When explicitly set to `false`, harness default rules are not injected.
378    /// `None` means enabled (default behavior).
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub harness_defaults: Option<bool>,
381}
382
383fn default_effect() -> Effect {
384    Effect::Ask
385}
386
387// ---------------------------------------------------------------------------
388// PolicyManifest — JSON shape produced by evaluating a policy
389// ---------------------------------------------------------------------------
390
391/// JSON shape produced by evaluating a `.star` policy (and by the legacy
392/// `policy.json` migrate path). Parsed at the loader level; `includes` are
393/// resolved and merged before the inner `CompiledPolicy` is used.
394#[derive(Debug, Clone, Serialize, Deserialize)]
395pub struct PolicyManifest {
396    /// Starlark files to include (evaluated and merged at load time).
397    #[serde(default, skip_serializing_if = "Vec::is_empty")]
398    pub includes: Vec<IncludeEntry>,
399    /// The inline policy (tree, sandboxes, default_effect, etc.).
400    #[serde(flatten)]
401    pub policy: CompiledPolicy,
402}
403
404/// A single include directive in a [`PolicyManifest`].
405#[derive(Debug, Clone, Serialize, Deserialize)]
406pub struct IncludeEntry {
407    /// Path to a `.star` file. `@clash//` prefix resolves to the embedded stdlib;
408    /// other paths are relative to the directory containing the policy file.
409    pub path: String,
410}
411
412impl CompiledPolicy {
413    /// Return the number of root-level rule branches.
414    pub fn rule_count(&self) -> usize {
415        self.tree.len()
416    }
417
418    /// Format rules as human-readable lines for display (flat, denormalized).
419    pub fn format_rules(&self) -> Vec<String> {
420        super::format::format_rules(self)
421    }
422
423    /// Format rules as a tree with box-drawing characters.
424    pub fn format_tree(&self) -> Vec<String> {
425        super::format::format_tree(self)
426    }
427
428    /// Count root-level nodes stamped with source "harness".
429    pub fn harness_node_count(&self) -> usize {
430        self.tree
431            .iter()
432            .filter(|n| match n {
433                Node::Condition { source, .. } => source.as_deref() == Some("harness"),
434                _ => false,
435            })
436            .count()
437    }
438
439    /// Return a view of the tree with harness nodes filtered out.
440    pub fn tree_without_harness(&self) -> Vec<&Node> {
441        self.tree
442            .iter()
443            .filter(|n| match n {
444                Node::Condition { source, .. } => source.as_deref() != Some("harness"),
445                _ => true,
446            })
447            .collect()
448    }
449
450    /// Format rules as a tree, optionally excluding harness nodes.
451    pub fn format_tree_filtered(&self, include_harness: bool) -> Vec<String> {
452        if include_harness {
453            super::format::format_tree(self)
454        } else {
455            let nodes = self.tree_without_harness();
456            super::format::format_tree_nodes(&nodes)
457        }
458    }
459}
460
461// ---------------------------------------------------------------------------
462// Query context for evaluation
463// ---------------------------------------------------------------------------
464
465/// The context passed to the evaluator, extracted from a tool invocation.
466#[derive(Debug)]
467pub struct QueryContext {
468    /// The tool name (e.g. "Bash", "Read").
469    pub tool_name: String,
470    /// Positional args (for Bash: parsed command parts).
471    pub args: Vec<String>,
472    /// The full tool_input JSON.
473    pub tool_input: serde_json::Value,
474    /// Hook type, if this is a hook invocation.
475    pub hook_type: Option<String>,
476    /// Agent name, if this is an agent invocation.
477    pub agent_name: Option<String>,
478    /// Capability: filesystem operation ("read" or "write"), if applicable.
479    pub fs_op: Option<String>,
480    /// Capability: resolved filesystem path, if applicable.
481    pub fs_path: Option<String>,
482    /// Capability: network domain, if applicable.
483    pub net_domain: Option<String>,
484    /// Permission mode from Claude Code (e.g. "default", "plan").
485    pub mode: Option<String>,
486}
487
488impl QueryContext {
489    /// Build a QueryContext from a tool invocation.
490    pub fn from_tool(tool_name: &str, tool_input: &serde_json::Value) -> Self {
491        let args = match tool_name {
492            "Bash" => {
493                let command = tool_input
494                    .get("command")
495                    .and_then(|v| v.as_str())
496                    .unwrap_or("");
497                let parts: Vec<&str> = command.split_whitespace().collect();
498                let (bin, rest) = parse_bash_bin_args(&parts);
499                let mut args = vec![bin];
500                args.extend(rest);
501                args
502            }
503            "WebFetch" => tool_input
504                .get("url")
505                .and_then(|v| v.as_str())
506                .and_then(extract_domain)
507                .map(|d| vec![d])
508                .unwrap_or_default(),
509            _ => vec![],
510        };
511
512        // Extract capability-level fields from tool invocations.
513        let (fs_op, fs_path) = match tool_name {
514            "Read" => (
515                Some("read".to_string()),
516                tool_input
517                    .get("file_path")
518                    .and_then(|v| v.as_str())
519                    .map(resolve_relative_path),
520            ),
521            "Glob" | "Grep" => (
522                Some("read".to_string()),
523                tool_input
524                    .get("path")
525                    .or_else(|| tool_input.get("pattern"))
526                    .and_then(|v| v.as_str())
527                    .map(resolve_relative_path),
528            ),
529            "Write" | "Edit" => (
530                Some("write".to_string()),
531                tool_input
532                    .get("file_path")
533                    .and_then(|v| v.as_str())
534                    .map(resolve_relative_path),
535            ),
536            _ => (None, None),
537        };
538
539        let net_domain = match tool_name {
540            "WebFetch" => tool_input
541                .get("url")
542                .and_then(|v| v.as_str())
543                .and_then(extract_domain),
544            "WebSearch" => Some("*".to_string()),
545            _ => None,
546        };
547
548        QueryContext {
549            tool_name: tool_name.to_string(),
550            args,
551            tool_input: tool_input.clone(),
552            hook_type: None,
553            agent_name: None,
554            fs_op,
555            fs_path,
556            net_domain,
557            mode: None,
558        }
559    }
560
561    /// Extract the value of an observable from this context.
562    fn extract(&self, obs: &Observable) -> Option<Vec<String>> {
563        match obs {
564            Observable::ToolName => Some(vec![self.tool_name.clone()]),
565            Observable::HookType => self.hook_type.clone().map(|h| vec![h]),
566            Observable::AgentName => self.agent_name.clone().map(|a| vec![a]),
567            Observable::PositionalArg(i) => {
568                let idx = *i as usize;
569                self.args.get(idx).map(|a| vec![a.clone()])
570            }
571            Observable::HasArg => Some(self.args.clone()),
572            Observable::NamedArg(name) => self
573                .tool_input
574                .get(name)
575                .and_then(|v| v.as_str())
576                .map(|s| vec![s.to_string()]),
577            Observable::NestedField(path) => {
578                let mut current = &self.tool_input;
579                for segment in path {
580                    current = current.get(segment)?;
581                }
582                current.as_str().map(|s| vec![s.to_string()])
583            }
584            Observable::FsOp => self.fs_op.clone().map(|op| vec![op]),
585            Observable::FsPath => self.fs_path.clone().map(|p| vec![p]),
586            Observable::NetDomain => self.net_domain.clone().map(|d| vec![d]),
587            Observable::Mode => self.mode.clone().map(|m| vec![m]),
588        }
589    }
590}
591
592/// Resolve a potentially relative path against CWD.
593fn resolve_relative_path(path: &str) -> String {
594    super::path::PathResolver::from_env().resolve_relative(path)
595}
596
597/// Extract the domain from a URL string.
598fn extract_domain(url: &str) -> Option<String> {
599    // Simple extraction: skip scheme, take host
600    let without_scheme = url
601        .strip_prefix("https://")
602        .or_else(|| url.strip_prefix("http://"))
603        .unwrap_or(url);
604    let host = without_scheme.split('/').next()?;
605    // Strip port if present
606    let domain = host.split(':').next()?;
607    if domain.is_empty() {
608        None
609    } else {
610        Some(domain.to_string())
611    }
612}
613
614// ---------------------------------------------------------------------------
615// Bash command parsing utilities
616// ---------------------------------------------------------------------------
617
618/// Extract the binary name and arguments from whitespace-split Bash command tokens,
619/// skipping leading environment variable assignments, the `env` utility, and
620/// transparent prefix commands (`time`, `nice`, etc.).
621pub(crate) fn parse_bash_bin_args(parts: &[&str]) -> (String, Vec<String>) {
622    let mut i = 0;
623
624    loop {
625        while i < parts.len() && is_env_assignment(parts[i]) {
626            i += 1;
627        }
628
629        if i < parts.len() && parts[i] == "env" {
630            i += 1;
631            while i < parts.len() && is_env_assignment(parts[i]) {
632                i += 1;
633            }
634            continue;
635        }
636
637        if i < parts.len()
638            && let Some(skip) = transparent_prefix_skip(parts[i], parts.get(i + 1..).unwrap_or(&[]))
639        {
640            i += 1 + skip;
641            continue;
642        }
643
644        break;
645    }
646
647    match parts.get(i) {
648        Some(bin) => (
649            bin.to_string(),
650            parts[i + 1..].iter().map(|s| s.to_string()).collect(),
651        ),
652        None => (String::new(), vec![]),
653    }
654}
655
656fn is_env_assignment(token: &str) -> bool {
657    match token.find('=') {
658        Some(0) | None => false,
659        Some(pos) => {
660            let name = &token[..pos];
661            let mut chars = name.chars();
662            match chars.next() {
663                Some(c) if c.is_ascii_alphabetic() || c == '_' => {
664                    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
665                }
666                _ => false,
667            }
668        }
669    }
670}
671
672fn transparent_prefix_skip(cmd: &str, rest: &[&str]) -> Option<usize> {
673    match cmd {
674        "time" => Some(skip_flags(rest, &["-f", "-o"])),
675        "command" => {
676            if rest.first().is_some_and(|f| *f == "-v" || *f == "-V") {
677                None
678            } else {
679                Some(skip_flags(rest, &[]))
680            }
681        }
682        "nice" => Some(skip_flags(rest, &["-n"])),
683        "nohup" => Some(0),
684        "timeout" => {
685            let flags = skip_flags(rest, &["-s", "-k", "--signal", "--kill-after"]);
686            if flags < rest.len() {
687                Some(flags + 1)
688            } else {
689                Some(flags)
690            }
691        }
692        _ => None,
693    }
694}
695
696fn skip_flags(tokens: &[&str], value_flags: &[&str]) -> usize {
697    let mut i = 0;
698    while i < tokens.len() && tokens[i].starts_with('-') {
699        let flag = tokens[i];
700        i += 1;
701        if flag.contains('=') {
702            continue;
703        }
704        if value_flags.contains(&flag) && i < tokens.len() {
705            i += 1;
706        }
707    }
708    i
709}
710
711// ---------------------------------------------------------------------------
712// Evaluation trace
713// ---------------------------------------------------------------------------
714
715/// Trace of the DFS evaluation for debugging.
716#[derive(Debug, Clone, Default)]
717pub struct EvalTrace {
718    /// Branches entered that produced a decision.
719    pub matched: Vec<TraceEntry>,
720    /// Branches where pattern didn't match.
721    pub skipped: Vec<TraceEntry>,
722    /// Branches entered but no decision found (backtracked).
723    pub dead_ends: Vec<TraceEntry>,
724}
725
726/// A single trace entry recording a node visit.
727#[derive(Debug, Clone)]
728pub struct TraceEntry {
729    /// Path of observables traversed to reach this node.
730    pub path: Vec<String>,
731    /// The observable at this node.
732    pub observable: String,
733    /// The pattern tested.
734    pub pattern_desc: String,
735    /// The value tested against.
736    pub tested_value: Option<String>,
737}
738
739// ---------------------------------------------------------------------------
740// DFS Evaluator
741// ---------------------------------------------------------------------------
742
743/// Evaluate the match tree against a query context.
744///
745/// Returns the first decision found via DFS, or None if no branch matches.
746pub fn eval(nodes: &[Node], ctx: &QueryContext) -> Option<Decision> {
747    for node in nodes {
748        match node {
749            Node::Decision(d) => return Some(d.clone()),
750            Node::Condition {
751                observe,
752                pattern,
753                children,
754                terminal,
755                ..
756            } => {
757                if matches_observable(observe, pattern, *terminal, ctx)
758                    && let Some(d) = eval(children, ctx)
759                {
760                    return Some(d);
761                }
762            }
763        }
764    }
765    None
766}
767
768/// Evaluate with tracing, recording which branches were taken/skipped.
769pub fn eval_traced(
770    nodes: &[Node],
771    ctx: &QueryContext,
772    trace: &mut EvalTrace,
773    path: &mut Vec<String>,
774) -> Option<Decision> {
775    for node in nodes {
776        match node {
777            Node::Decision(d) => return Some(d.clone()),
778            Node::Condition {
779                observe,
780                pattern,
781                children,
782                terminal,
783                ..
784            } => {
785                let obs_name = format!("{observe:?}");
786                let pat_desc = format!("{pattern:?}");
787                let values = ctx.extract(observe);
788                let tested = values.as_ref().map(|vs| vs.join(", "));
789
790                if matches_observable(observe, pattern, *terminal, ctx) {
791                    path.push(obs_name.clone());
792                    if let Some(d) = eval_traced(children, ctx, trace, path) {
793                        trace.matched.push(TraceEntry {
794                            path: path.clone(),
795                            observable: obs_name,
796                            pattern_desc: pat_desc,
797                            tested_value: tested,
798                        });
799                        path.pop();
800                        return Some(d);
801                    }
802                    // Entered but no decision found — dead end.
803                    trace.dead_ends.push(TraceEntry {
804                        path: path.clone(),
805                        observable: obs_name,
806                        pattern_desc: pat_desc,
807                        tested_value: tested,
808                    });
809                    path.pop();
810                } else {
811                    trace.skipped.push(TraceEntry {
812                        path: path.clone(),
813                        observable: obs_name,
814                        pattern_desc: pat_desc,
815                        tested_value: tested,
816                    });
817                }
818            }
819        }
820    }
821    None
822}
823
824/// Walk the tree and return the index path to the first matching decision.
825fn find_match_path_dfs(nodes: &[Node], ctx: &QueryContext) -> Option<Vec<usize>> {
826    for (i, node) in nodes.iter().enumerate() {
827        match node {
828            Node::Decision(_) => return Some(vec![i]),
829            Node::Condition {
830                observe,
831                pattern,
832                children,
833                terminal,
834                ..
835            } => {
836                if matches_observable(observe, pattern, *terminal, ctx)
837                    && let Some(mut child_path) = find_match_path_dfs(children, ctx)
838                {
839                    child_path.insert(0, i);
840                    return Some(child_path);
841                }
842            }
843        }
844    }
845    None
846}
847
848/// Test whether an observable matches a pattern in the given context.
849///
850/// When `terminal` is true and the observable is a `PositionalArg(i)`,
851/// additionally asserts that no more positional args exist after index `i`.
852/// This is analogous to `$` in regex — the match is exhaustive.
853fn matches_observable(
854    obs: &Observable,
855    pattern: &Pattern,
856    terminal: bool,
857    ctx: &QueryContext,
858) -> bool {
859    match obs {
860        Observable::HasArg => {
861            // HasArg: true if ANY arg matches the pattern
862            ctx.args.iter().any(|arg| pattern.matches(arg))
863        }
864        Observable::PositionalArg(i) if terminal => {
865            let idx = *i as usize;
866            match ctx.args.get(idx) {
867                Some(val) if pattern.matches(val) => ctx.args.len() == idx + 1,
868                _ => false,
869            }
870        }
871        Observable::ToolName => {
872            // Tool name matching is case-insensitive and supports canonical aliases.
873            // All of these match tool_name="Bash": "Bash", "bash", "shell", "Shell".
874            let tool = &ctx.tool_name;
875            let tool_lower = tool.to_lowercase();
876            let canonical = internal_to_canonical(tool);
877
878            // Build the set of names this tool is known by.
879            let mut aliases = vec![tool.clone(), tool_lower.clone()];
880            if let Some(c) = canonical {
881                aliases.push(c.to_string());
882                let c_lower = c.to_lowercase();
883                if c_lower != c {
884                    aliases.push(c_lower);
885                }
886            }
887
888            match pattern {
889                Pattern::Wildcard => true,
890                Pattern::Literal(v) => {
891                    let pat = v.resolve();
892                    let pat_lower = pat.to_lowercase();
893                    // Direct or case-insensitive match against any alias
894                    aliases.iter().any(|a| a == &pat || a.to_lowercase() == pat_lower)
895                    // Or the pattern is a canonical name that resolves to this tool
896                    || canonical_to_internal(&pat)
897                        .is_some_and(|resolved| resolved.eq_ignore_ascii_case(tool))
898                }
899                _ => {
900                    // Regex, AnyOf, Not, Prefix — match against all aliases
901                    aliases.iter().any(|v| pattern.matches(v))
902                }
903            }
904        }
905        _ => {
906            // For all other observables, extract the value and match
907            if let Some(values) = ctx.extract(obs) {
908                values.iter().any(|v| pattern.matches(v))
909            } else {
910                // No value available — only Wildcard matches
911                matches!(pattern, Pattern::Wildcard)
912            }
913        }
914    }
915}
916
917// ---------------------------------------------------------------------------
918// CompiledPolicy evaluation
919// ---------------------------------------------------------------------------
920
921impl CompiledPolicy {
922    /// Evaluate this policy against a tool invocation.
923    pub fn evaluate(&self, tool_name: &str, tool_input: &serde_json::Value) -> PolicyDecision {
924        let ctx = QueryContext::from_tool(tool_name, tool_input);
925        self.evaluate_ctx(&ctx)
926    }
927
928    /// Evaluate this policy with mode and agent context.
929    pub fn evaluate_with_mode(
930        &self,
931        tool_name: &str,
932        tool_input: &serde_json::Value,
933        mode: Option<&str>,
934    ) -> PolicyDecision {
935        self.evaluate_with_context(tool_name, tool_input, mode, None)
936    }
937
938    /// Evaluate this policy with mode and agent context.
939    pub fn evaluate_with_context(
940        &self,
941        tool_name: &str,
942        tool_input: &serde_json::Value,
943        mode: Option<&str>,
944        agent_name: Option<&str>,
945    ) -> PolicyDecision {
946        let mut ctx = QueryContext::from_tool(tool_name, tool_input);
947        ctx.mode = mode.map(|m| m.to_string());
948        ctx.agent_name = agent_name.map(|a| a.to_string());
949        self.evaluate_ctx(&ctx)
950    }
951
952    /// Find the index path through the tree to the first matching decision.
953    ///
954    /// Returns `Some(vec![root_idx, child_idx, ...])` if a rule matched,
955    /// or `None` if no rule matched (default effect applies).
956    pub fn find_match_path(
957        &self,
958        tool_name: &str,
959        tool_input: &serde_json::Value,
960    ) -> Option<Vec<usize>> {
961        let ctx = QueryContext::from_tool(tool_name, tool_input);
962        find_match_path_dfs(&self.tree, &ctx)
963    }
964
965    /// Evaluate this policy against a prepared query context.
966    pub fn evaluate_ctx(&self, ctx: &QueryContext) -> PolicyDecision {
967        let mut trace = EvalTrace::default();
968        let mut path = Vec::new();
969
970        let decision = eval_traced(&self.tree, ctx, &mut trace, &mut path);
971
972        match decision {
973            Some(d) => {
974                let mut effect = d.effect();
975                let cwd_path = std::env::current_dir().unwrap_or_default();
976                let sandbox = d
977                    .sandbox_ref()
978                    .and_then(|sr| self.sandboxes.get(&sr.0))
979                    .cloned()
980                    .map(|sbx| sbx.expand_worktree_rules(&cwd_path));
981
982                // For non-Bash tools with file operations, enforce sandbox fs
983                // rules at policy level (there's no OS sandbox wrapper for these).
984                let mut sandbox_denial: Option<String> = None;
985                if effect == Effect::Allow
986                    && ctx.tool_name != "Bash"
987                    && let Some(ref sbx) = sandbox
988                    && let Some(ref fs_op) = ctx.fs_op
989                    && let Some(ref fs_path) = ctx.fs_path
990                    && fs_path.starts_with('/')
991                {
992                    use crate::sandbox_types::Cap;
993                    let required = match fs_op.as_str() {
994                        "read" => Cap::READ,
995                        "write" => Cap::WRITE | Cap::CREATE,
996                        _ => Cap::empty(),
997                    };
998                    let cwd = cwd_path.to_string_lossy().to_string();
999                    if let Some(explanation) = sbx.explain_denial(fs_path, &cwd, required) {
1000                        effect = Effect::Deny;
1001                        let sandbox_name =
1002                            d.sandbox_ref().map(|sr| sr.0.as_str()).unwrap_or("unnamed");
1003                        sandbox_denial = Some(format!("sandbox '{sandbox_name}': {explanation}"));
1004                    }
1005                }
1006
1007                let resolution = match sandbox_denial {
1008                    Some(ref detail) => format!("result: {effect} ({detail})"),
1009                    None => format!("result: {effect}"),
1010                };
1011
1012                PolicyDecision {
1013                    effect,
1014                    reason: Some(resolution.clone()),
1015                    trace: self.build_decision_trace(&trace, &resolution),
1016                    sandbox,
1017                    sandbox_name: d.sandbox_ref().cloned(),
1018                }
1019            }
1020            None => {
1021                let resolution = format!("no rules matched, default: {}", self.default_effect);
1022
1023                PolicyDecision {
1024                    effect: self.default_effect,
1025                    reason: Some(resolution.clone()),
1026                    trace: self.build_decision_trace(&trace, &resolution),
1027                    sandbox: None,
1028                    sandbox_name: None,
1029                }
1030            }
1031        }
1032    }
1033
1034    fn build_decision_trace(&self, trace: &EvalTrace, resolution: &str) -> DecisionTrace {
1035        let mut matched_rules = Vec::new();
1036        let mut skipped_rules = Vec::new();
1037
1038        for (i, entry) in trace.matched.iter().enumerate() {
1039            matched_rules.push(RuleMatch {
1040                rule_index: i,
1041                description: format!(
1042                    "{}={}",
1043                    entry.observable,
1044                    entry.tested_value.as_deref().unwrap_or("?")
1045                ),
1046                effect: Effect::Allow, // filled by caller context
1047                has_active_constraints: true,
1048                node_id: None,
1049            });
1050        }
1051
1052        for (i, entry) in trace.skipped.iter().enumerate() {
1053            skipped_rules.push(RuleSkip {
1054                rule_index: i,
1055                description: format!("{}: {}", entry.observable, entry.pattern_desc),
1056                reason: format!(
1057                    "pattern mismatch (value: {})",
1058                    entry.tested_value.as_deref().unwrap_or("absent")
1059                ),
1060            });
1061        }
1062
1063        DecisionTrace {
1064            matched_rules,
1065            skipped_rules,
1066            final_resolution: resolution.to_string(),
1067        }
1068    }
1069
1070    /// Validate that all sandbox references resolve.
1071    pub fn validate(&self) -> Vec<String> {
1072        let mut errors = Vec::new();
1073        self.validate_nodes(&self.tree, &mut errors);
1074        errors
1075    }
1076
1077    fn validate_nodes(&self, nodes: &[Node], errors: &mut Vec<String>) {
1078        for node in nodes {
1079            match node {
1080                Node::Decision(d) => {
1081                    if let Some(sr) = d.sandbox_ref()
1082                        && !self.sandboxes.contains_key(&sr.0)
1083                    {
1084                        errors.push(format!(
1085                            "sandbox reference '{}' not found in sandboxes map",
1086                            sr.0
1087                        ));
1088                    }
1089                }
1090                Node::Condition { children, .. } => {
1091                    self.validate_nodes(children, errors);
1092                }
1093            }
1094        }
1095    }
1096
1097    /// Return platform-specific warnings for sandbox policies.
1098    ///
1099    /// These are non-fatal: the policy is valid but some rules behave
1100    /// differently on certain platforms.
1101    pub fn platform_warnings(&self) -> Vec<String> {
1102        use crate::sandbox_types::{NetworkPolicy, PathMatch};
1103
1104        let mut warnings = Vec::new();
1105        for (name, sandbox) in &self.sandboxes {
1106            for rule in &sandbox.rules {
1107                if rule.path_match == PathMatch::Regex {
1108                    warnings.push(format!(
1109                        "sandbox '{}': regex path rule '{}' is not enforced on Linux \
1110                         (Landlock cannot match regex paths)",
1111                        name, rule.path,
1112                    ));
1113                }
1114            }
1115            if let NetworkPolicy::AllowDomains(domains) = &sandbox.network {
1116                warnings.push(format!(
1117                    "sandbox '{}': domain filtering ({}) is advisory on Linux \
1118                     (relies on HTTP_PROXY which programs can bypass)",
1119                    name,
1120                    domains.join(", "),
1121                ));
1122            }
1123        }
1124        warnings
1125    }
1126}
1127
1128// ---------------------------------------------------------------------------
1129// Compact: merge duplicate siblings + sort by specificity
1130// ---------------------------------------------------------------------------
1131
1132impl Node {
1133    /// Merge sibling `Condition` nodes that share the same `(observe, pattern)`,
1134    /// combining their children, then sort by specificity. Recurses into children.
1135    pub fn compact(nodes: Vec<Node>) -> Vec<Node> {
1136        let mut out: Vec<Node> = Vec::new();
1137
1138        // Merge duplicates via linear scan (no Hash needed, trees are small).
1139        for node in nodes {
1140            match node {
1141                Node::Condition {
1142                    observe,
1143                    pattern,
1144                    children,
1145                    doc,
1146                    source,
1147                    terminal,
1148                } => {
1149                    if let Some(existing) = out.iter_mut().find_map(|n| match n {
1150                        Node::Condition {
1151                            observe: o,
1152                            pattern: p,
1153                            children: c,
1154                            doc: d,
1155                            ..
1156                        } if *o == observe && *p == pattern => Some((c, d)),
1157                        _ => None,
1158                    }) {
1159                        existing.0.extend(children);
1160                        if existing.1.is_none() {
1161                            *existing.1 = doc;
1162                        }
1163                    } else {
1164                        out.push(Node::Condition {
1165                            observe,
1166                            pattern,
1167                            children,
1168                            doc,
1169                            source,
1170                            terminal,
1171                        });
1172                    }
1173                }
1174                decision => out.push(decision),
1175            }
1176        }
1177
1178        // Recurse into children.
1179        for node in &mut out {
1180            if let Node::Condition { children, .. } = node {
1181                *children = Self::compact(std::mem::take(children));
1182            }
1183        }
1184
1185        // Sort: Conditions before Decisions, then by specificity (most specific first).
1186        // TODO: unpack the nodes and do a direct comparison instead of a silly mapping to 8bit numbers
1187        out.sort_by(|a, b| Self::sort_key(b).cmp(&Self::sort_key(a)));
1188
1189        out
1190    }
1191
1192    /// Sort key: `(is_condition, pattern_specificity, observable_specificity)`.
1193    /// Higher values sort first (via reverse comparison in `compact`).
1194    fn sort_key(node: &Node) -> (u8, u8, u8) {
1195        match node {
1196            Node::Condition {
1197                observe, pattern, ..
1198            } => (1, pattern.specificity(), observe.specificity()),
1199            Node::Decision(_) => (0, 0, 0),
1200        }
1201    }
1202}
1203
1204/// Detect unreachable branches: warn if a Wildcard precedes more specific siblings.
1205pub fn detect_unreachable(nodes: &[Node]) -> Vec<String> {
1206    let mut warnings = Vec::new();
1207    detect_unreachable_inner(nodes, &mut warnings, &[]);
1208    warnings
1209}
1210
1211fn detect_unreachable_inner(nodes: &[Node], warnings: &mut Vec<String>, path: &[String]) {
1212    let mut seen_wildcard = false;
1213    for node in nodes {
1214        match node {
1215            Node::Condition {
1216                observe,
1217                pattern,
1218                children,
1219                ..
1220            } => {
1221                if seen_wildcard {
1222                    warnings.push(format!(
1223                        "unreachable branch at {:?}: {:?} after wildcard",
1224                        path, observe
1225                    ));
1226                }
1227                if matches!(pattern, Pattern::Wildcard) {
1228                    seen_wildcard = true;
1229                }
1230                let mut child_path = path.to_vec();
1231                child_path.push(format!("{observe:?}"));
1232                detect_unreachable_inner(children, warnings, &child_path);
1233            }
1234            Node::Decision(_) => {
1235                if seen_wildcard {
1236                    warnings.push(format!(
1237                        "unreachable decision at {:?}: decision after wildcard",
1238                        path
1239                    ));
1240                }
1241            }
1242        }
1243    }
1244}
1245
1246// ---------------------------------------------------------------------------
1247// Tests
1248// ---------------------------------------------------------------------------
1249
1250#[cfg(test)]
1251mod tests {
1252    use super::*;
1253
1254    fn make_ctx(tool: &str, command: &str) -> QueryContext {
1255        let input = if tool == "Bash" {
1256            serde_json::json!({"command": command})
1257        } else {
1258            serde_json::json!({})
1259        };
1260        QueryContext::from_tool(tool, &input)
1261    }
1262
1263    #[test]
1264    fn simple_decision() {
1265        let nodes = vec![Node::Decision(Decision::Allow(None))];
1266        let ctx = make_ctx("Bash", "echo hello");
1267        assert_eq!(eval(&nodes, &ctx), Some(Decision::Allow(None)));
1268    }
1269
1270    #[test]
1271    fn tool_name_match() {
1272        let nodes = vec![Node::Condition {
1273            observe: Observable::ToolName,
1274            pattern: Pattern::Literal(Value::Literal("Bash".into())),
1275            children: vec![Node::Decision(Decision::Allow(None))],
1276            doc: None,
1277            source: None,
1278            terminal: false,
1279        }];
1280        let ctx = make_ctx("Bash", "echo hello");
1281        assert_eq!(eval(&nodes, &ctx), Some(Decision::Allow(None)));
1282    }
1283
1284    #[test]
1285    fn tool_name_mismatch() {
1286        let nodes = vec![Node::Condition {
1287            observe: Observable::ToolName,
1288            pattern: Pattern::Literal(Value::Literal("Read".into())),
1289            children: vec![Node::Decision(Decision::Allow(None))],
1290            doc: None,
1291            source: None,
1292            terminal: false,
1293        }];
1294        let ctx = make_ctx("Bash", "echo hello");
1295        assert_eq!(eval(&nodes, &ctx), None);
1296    }
1297
1298    #[test]
1299    fn terminal_exact_match() {
1300        // terminal: true on arg[1] means "git commit" matches but "git commit --amend" does not
1301        let nodes = vec![Node::Condition {
1302            observe: Observable::ToolName,
1303            pattern: Pattern::Literal(Value::Literal("Bash".into())),
1304            children: vec![Node::Condition {
1305                observe: Observable::PositionalArg(0),
1306                pattern: Pattern::Literal(Value::Literal("git".into())),
1307                children: vec![Node::Condition {
1308                    observe: Observable::PositionalArg(1),
1309                    pattern: Pattern::Literal(Value::Literal("commit".into())),
1310                    children: vec![Node::Decision(Decision::Deny)],
1311                    doc: None,
1312                    source: None,
1313                    terminal: true,
1314                }],
1315                doc: None,
1316                source: None,
1317                terminal: false,
1318            }],
1319            doc: None,
1320            source: None,
1321            terminal: false,
1322        }];
1323
1324        // Exact match: "git commit" → deny
1325        let ctx = make_ctx("Bash", "git commit");
1326        assert_eq!(eval(&nodes, &ctx), Some(Decision::Deny));
1327
1328        // Extra args: "git commit --amend" → no match (terminal blocks it)
1329        let ctx = make_ctx("Bash", "git commit --amend");
1330        assert_eq!(eval(&nodes, &ctx), None);
1331
1332        // Different subcommand: "git push" → no match
1333        let ctx = make_ctx("Bash", "git push");
1334        assert_eq!(eval(&nodes, &ctx), None);
1335    }
1336
1337    #[test]
1338    fn terminal_single_binary() {
1339        // terminal: true on arg[0] means "ls" matches but "ls -la" does not
1340        let nodes = vec![Node::Condition {
1341            observe: Observable::PositionalArg(0),
1342            pattern: Pattern::Literal(Value::Literal("ls".into())),
1343            children: vec![Node::Decision(Decision::Allow(None))],
1344            doc: None,
1345            source: None,
1346            terminal: true,
1347        }];
1348
1349        let ctx = make_ctx("Bash", "ls");
1350        assert_eq!(eval(&nodes, &ctx), Some(Decision::Allow(None)));
1351
1352        let ctx = make_ctx("Bash", "ls -la");
1353        assert_eq!(eval(&nodes, &ctx), None);
1354    }
1355
1356    #[test]
1357    fn non_terminal_allows_extra_args() {
1358        // terminal: false (default) means "git commit --amend" still matches "git commit"
1359        let nodes = vec![Node::Condition {
1360            observe: Observable::PositionalArg(0),
1361            pattern: Pattern::Literal(Value::Literal("git".into())),
1362            children: vec![Node::Condition {
1363                observe: Observable::PositionalArg(1),
1364                pattern: Pattern::Literal(Value::Literal("commit".into())),
1365                children: vec![Node::Decision(Decision::Allow(None))],
1366                doc: None,
1367                source: None,
1368                terminal: false,
1369            }],
1370            doc: None,
1371            source: None,
1372            terminal: false,
1373        }];
1374
1375        let ctx = make_ctx("Bash", "git commit --amend");
1376        assert_eq!(eval(&nodes, &ctx), Some(Decision::Allow(None)));
1377    }
1378
1379    #[test]
1380    fn positional_arg_match() {
1381        let nodes = vec![Node::Condition {
1382            observe: Observable::ToolName,
1383            pattern: Pattern::Literal(Value::Literal("Bash".into())),
1384            children: vec![Node::Condition {
1385                observe: Observable::PositionalArg(0),
1386                pattern: Pattern::Literal(Value::Literal("git".into())),
1387                children: vec![Node::Decision(Decision::Allow(None))],
1388                doc: None,
1389                source: None,
1390                terminal: false,
1391            }],
1392            doc: None,
1393            source: None,
1394            terminal: false,
1395        }];
1396        let ctx = make_ctx("Bash", "git push");
1397        assert_eq!(eval(&nodes, &ctx), Some(Decision::Allow(None)));
1398    }
1399
1400    #[test]
1401    fn has_arg_match() {
1402        let nodes = vec![Node::Condition {
1403            observe: Observable::ToolName,
1404            pattern: Pattern::Literal(Value::Literal("Bash".into())),
1405            children: vec![Node::Condition {
1406                observe: Observable::HasArg,
1407                pattern: Pattern::Literal(Value::Literal("--force".into())),
1408                children: vec![Node::Decision(Decision::Deny)],
1409                doc: None,
1410                source: None,
1411                terminal: false,
1412            }],
1413            doc: None,
1414            source: None,
1415            terminal: false,
1416        }];
1417        let ctx = make_ctx("Bash", "git push --force origin main");
1418        assert_eq!(eval(&nodes, &ctx), Some(Decision::Deny));
1419    }
1420
1421    #[test]
1422    fn has_arg_no_match() {
1423        let nodes = vec![Node::Condition {
1424            observe: Observable::ToolName,
1425            pattern: Pattern::Literal(Value::Literal("Bash".into())),
1426            children: vec![Node::Condition {
1427                observe: Observable::HasArg,
1428                pattern: Pattern::Literal(Value::Literal("--force".into())),
1429                children: vec![Node::Decision(Decision::Deny)],
1430                doc: None,
1431                source: None,
1432                terminal: false,
1433            }],
1434            doc: None,
1435            source: None,
1436            terminal: false,
1437        }];
1438        let ctx = make_ctx("Bash", "git push origin main");
1439        assert_eq!(eval(&nodes, &ctx), None);
1440    }
1441
1442    #[test]
1443    fn specificity_ordering() {
1444        // More specific (Literal) should come before less specific (Wildcard)
1445        let nodes = vec![
1446            Node::Condition {
1447                observe: Observable::ToolName,
1448                pattern: Pattern::Wildcard,
1449                children: vec![Node::Decision(Decision::Ask(None))],
1450                doc: None,
1451                source: None,
1452                terminal: false,
1453            },
1454            Node::Condition {
1455                observe: Observable::ToolName,
1456                pattern: Pattern::Literal(Value::Literal("Bash".into())),
1457                children: vec![Node::Decision(Decision::Allow(None))],
1458                doc: None,
1459                source: None,
1460                terminal: false,
1461            },
1462        ];
1463        let nodes = Node::compact(nodes);
1464        // Literal should be first after sorting
1465        let ctx = make_ctx("Bash", "echo hello");
1466        assert_eq!(eval(&nodes, &ctx), Some(Decision::Allow(None)));
1467    }
1468
1469    #[test]
1470    fn backtracking() {
1471        // First branch matches ToolName but has no matching child,
1472        // should backtrack and try the second branch.
1473        let nodes = vec![
1474            Node::Condition {
1475                observe: Observable::ToolName,
1476                pattern: Pattern::Literal(Value::Literal("Bash".into())),
1477                children: vec![Node::Condition {
1478                    observe: Observable::PositionalArg(0),
1479                    pattern: Pattern::Literal(Value::Literal("cargo".into())),
1480                    children: vec![Node::Decision(Decision::Allow(None))],
1481                    doc: None,
1482                    source: None,
1483                    terminal: false,
1484                }],
1485                doc: None,
1486                source: None,
1487                terminal: false,
1488            },
1489            Node::Condition {
1490                observe: Observable::ToolName,
1491                pattern: Pattern::Wildcard,
1492                children: vec![Node::Decision(Decision::Ask(None))],
1493                doc: None,
1494                source: None,
1495                terminal: false,
1496            },
1497        ];
1498        // "git push" matches Bash but not cargo, so should backtrack to wildcard
1499        let ctx = make_ctx("Bash", "git push");
1500        assert_eq!(eval(&nodes, &ctx), Some(Decision::Ask(None)));
1501    }
1502
1503    #[test]
1504    fn nested_field_match() {
1505        let nodes = vec![Node::Condition {
1506            observe: Observable::NestedField(vec!["file_path".into()]),
1507            pattern: Pattern::Regex(Arc::new(Regex::new(r".*\.rs$").unwrap())),
1508            children: vec![Node::Decision(Decision::Allow(None))],
1509            doc: None,
1510            source: None,
1511            terminal: false,
1512        }];
1513        let input = serde_json::json!({"file_path": "/src/main.rs"});
1514        let ctx = QueryContext::from_tool("Edit", &input);
1515        assert_eq!(eval(&nodes, &ctx), Some(Decision::Allow(None)));
1516    }
1517
1518    #[test]
1519    fn regex_pattern() {
1520        let nodes = vec![Node::Condition {
1521            observe: Observable::PositionalArg(0),
1522            pattern: Pattern::Regex(Arc::new(Regex::new(r"^cargo").unwrap())),
1523            children: vec![Node::Decision(Decision::Allow(None))],
1524            doc: None,
1525            source: None,
1526            terminal: false,
1527        }];
1528        let ctx = make_ctx("Bash", "cargo-clippy check");
1529        assert_eq!(eval(&nodes, &ctx), Some(Decision::Allow(None)));
1530    }
1531
1532    #[test]
1533    fn any_of_pattern() {
1534        let nodes = vec![Node::Condition {
1535            observe: Observable::PositionalArg(0),
1536            pattern: Pattern::AnyOf(vec![
1537                Pattern::Literal(Value::Literal("cargo".into())),
1538                Pattern::Literal(Value::Literal("rustc".into())),
1539            ]),
1540            children: vec![Node::Decision(Decision::Allow(None))],
1541            doc: None,
1542            source: None,
1543            terminal: false,
1544        }];
1545
1546        let ctx = make_ctx("Bash", "rustc main.rs");
1547        assert_eq!(eval(&nodes, &ctx), Some(Decision::Allow(None)));
1548
1549        let ctx = make_ctx("Bash", "gcc main.c");
1550        assert_eq!(eval(&nodes, &ctx), None);
1551    }
1552
1553    #[test]
1554    fn not_pattern() {
1555        let nodes = vec![Node::Condition {
1556            observe: Observable::PositionalArg(0),
1557            pattern: Pattern::Not(Box::new(Pattern::Literal(Value::Literal("rm".into())))),
1558            children: vec![Node::Decision(Decision::Allow(None))],
1559            doc: None,
1560            source: None,
1561            terminal: false,
1562        }];
1563
1564        let ctx = make_ctx("Bash", "ls -la");
1565        assert_eq!(eval(&nodes, &ctx), Some(Decision::Allow(None)));
1566
1567        let ctx = make_ctx("Bash", "rm -rf /");
1568        assert_eq!(eval(&nodes, &ctx), None);
1569    }
1570
1571    #[test]
1572    fn sandbox_ref_validation() {
1573        let policy = CompiledPolicy {
1574            sandboxes: HashMap::new(),
1575            tree: vec![Node::Decision(Decision::Allow(Some(SandboxRef(
1576                "missing".into(),
1577            ))))],
1578            default_effect: Effect::Deny,
1579            default_sandbox: None,
1580            on_sandbox_violation: Default::default(),
1581            harness_defaults: None,
1582        };
1583        let errors = policy.validate();
1584        assert_eq!(errors.len(), 1);
1585        assert!(errors[0].contains("missing"));
1586    }
1587
1588    #[test]
1589    fn sandbox_ref_valid() {
1590        let mut sandboxes = HashMap::new();
1591        sandboxes.insert(
1592            "cwd_access".to_string(),
1593            SandboxPolicy {
1594                default: crate::sandbox_types::Cap::READ,
1595                rules: vec![],
1596                network: crate::sandbox_types::NetworkPolicy::Deny,
1597                doc: None,
1598            },
1599        );
1600        let policy = CompiledPolicy {
1601            sandboxes,
1602            tree: vec![Node::Decision(Decision::Allow(Some(SandboxRef(
1603                "cwd_access".into(),
1604            ))))],
1605            default_effect: Effect::Deny,
1606            default_sandbox: None,
1607            on_sandbox_violation: Default::default(),
1608            harness_defaults: None,
1609        };
1610        assert!(policy.validate().is_empty());
1611    }
1612
1613    #[test]
1614    fn compiled_policy_evaluate() {
1615        let policy = CompiledPolicy {
1616            sandboxes: HashMap::new(),
1617            tree: vec![
1618                Node::Condition {
1619                    observe: Observable::ToolName,
1620                    pattern: Pattern::Literal(Value::Literal("Bash".into())),
1621                    children: vec![Node::Condition {
1622                        observe: Observable::PositionalArg(0),
1623                        pattern: Pattern::Literal(Value::Literal("git".into())),
1624                        children: vec![
1625                            Node::Condition {
1626                                observe: Observable::HasArg,
1627                                pattern: Pattern::Literal(Value::Literal("--force".into())),
1628                                children: vec![Node::Decision(Decision::Deny)],
1629                                doc: None,
1630                                source: None,
1631                                terminal: false,
1632                            },
1633                            Node::Decision(Decision::Allow(None)),
1634                        ],
1635                        doc: None,
1636                        source: None,
1637                        terminal: false,
1638                    }],
1639                    doc: None,
1640                    source: None,
1641                    terminal: false,
1642                },
1643                Node::Condition {
1644                    observe: Observable::ToolName,
1645                    pattern: Pattern::Wildcard,
1646                    children: vec![Node::Decision(Decision::Allow(None))],
1647                    doc: None,
1648                    source: None,
1649                    terminal: false,
1650                },
1651            ],
1652            default_effect: Effect::Deny,
1653            default_sandbox: None,
1654            on_sandbox_violation: Default::default(),
1655            harness_defaults: None,
1656        };
1657
1658        // git push → allow
1659        let d = policy.evaluate("Bash", &serde_json::json!({"command": "git push"}));
1660        assert_eq!(d.effect, Effect::Allow);
1661
1662        // git push --force → deny
1663        let d = policy.evaluate("Bash", &serde_json::json!({"command": "git push --force"}));
1664        assert_eq!(d.effect, Effect::Deny);
1665
1666        // Read tool → allow (wildcard match)
1667        let d = policy.evaluate("Read", &serde_json::json!({}));
1668        assert_eq!(d.effect, Effect::Allow);
1669    }
1670
1671    #[test]
1672    fn unreachable_branch_detection() {
1673        let nodes = vec![
1674            Node::Condition {
1675                observe: Observable::ToolName,
1676                pattern: Pattern::Wildcard,
1677                children: vec![Node::Decision(Decision::Allow(None))],
1678                doc: None,
1679                source: None,
1680                terminal: false,
1681            },
1682            Node::Condition {
1683                observe: Observable::ToolName,
1684                pattern: Pattern::Literal(Value::Literal("Bash".into())),
1685                children: vec![Node::Decision(Decision::Deny)],
1686                doc: None,
1687                source: None,
1688                terminal: false,
1689            },
1690        ];
1691        let warnings = detect_unreachable(&nodes);
1692        assert!(!warnings.is_empty());
1693    }
1694
1695    #[test]
1696    fn value_env_resolve() {
1697        // SAFETY: test-only, single-threaded access
1698        unsafe { std::env::set_var("MATCH_TREE_TEST_VAR", "test_value") };
1699        let v = Value::Env("MATCH_TREE_TEST_VAR".into());
1700        assert_eq!(v.resolve(), "test_value");
1701        unsafe { std::env::remove_var("MATCH_TREE_TEST_VAR") };
1702    }
1703
1704    #[test]
1705    fn value_path_resolve() {
1706        let v = Value::Path(vec![
1707            Value::Literal("/home".into()),
1708            Value::Literal("user".into()),
1709            Value::Literal(".ssh".into()),
1710        ]);
1711        assert_eq!(v.resolve(), "/home/user/.ssh");
1712    }
1713
1714    #[test]
1715    fn eval_trace_collection() {
1716        let nodes = vec![
1717            Node::Condition {
1718                observe: Observable::ToolName,
1719                pattern: Pattern::Literal(Value::Literal("Read".into())),
1720                children: vec![Node::Decision(Decision::Allow(None))],
1721                doc: None,
1722                source: None,
1723                terminal: false,
1724            },
1725            Node::Condition {
1726                observe: Observable::ToolName,
1727                pattern: Pattern::Literal(Value::Literal("Bash".into())),
1728                children: vec![Node::Decision(Decision::Deny)],
1729                doc: None,
1730                source: None,
1731                terminal: false,
1732            },
1733        ];
1734
1735        let ctx = make_ctx("Bash", "echo hello");
1736        let mut trace = EvalTrace::default();
1737        let mut path = Vec::new();
1738        let result = eval_traced(&nodes, &ctx, &mut trace, &mut path);
1739
1740        assert_eq!(result, Some(Decision::Deny));
1741        assert_eq!(trace.skipped.len(), 1); // Read was skipped
1742        assert_eq!(trace.matched.len(), 1); // Bash matched
1743    }
1744
1745    #[test]
1746    fn pattern_specificity_order() {
1747        assert!(
1748            Pattern::Literal(Value::Literal("x".into())).specificity()
1749                > Pattern::Regex(Arc::new(Regex::new("x").unwrap())).specificity()
1750        );
1751        assert!(
1752            Pattern::Regex(Arc::new(Regex::new("x").unwrap())).specificity()
1753                > Pattern::Wildcard.specificity()
1754        );
1755        assert!(Pattern::AnyOf(vec![]).specificity() > Pattern::Wildcard.specificity());
1756    }
1757
1758    #[test]
1759    fn named_arg_match() {
1760        let nodes = vec![Node::Condition {
1761            observe: Observable::NamedArg("file_path".into()),
1762            pattern: Pattern::Regex(Arc::new(Regex::new(r"\.env").unwrap())),
1763            children: vec![Node::Decision(Decision::Deny)],
1764            doc: None,
1765            source: None,
1766            terminal: false,
1767        }];
1768        let input = serde_json::json!({"file_path": "/project/.env"});
1769        let ctx = QueryContext::from_tool("Write", &input);
1770        assert_eq!(eval(&nodes, &ctx), Some(Decision::Deny));
1771    }
1772
1773    #[test]
1774    fn env_var_prefix_stripped_in_bash() {
1775        // Simulates: exe("cargo").allow() with command "SOME_ENV=foo cargo check"
1776        let policy = CompiledPolicy {
1777            sandboxes: HashMap::new(),
1778            tree: vec![Node::Condition {
1779                observe: Observable::ToolName,
1780                pattern: Pattern::Literal(Value::Literal("Bash".into())),
1781                children: vec![Node::Condition {
1782                    observe: Observable::PositionalArg(0),
1783                    pattern: Pattern::Literal(Value::Literal("cargo".into())),
1784                    children: vec![Node::Decision(Decision::Allow(None))],
1785                    doc: None,
1786                    source: None,
1787                    terminal: false,
1788                }],
1789                doc: None,
1790                source: None,
1791                terminal: false,
1792            }],
1793            default_effect: Effect::Deny,
1794            default_sandbox: None,
1795            on_sandbox_violation: Default::default(),
1796            harness_defaults: None,
1797        };
1798
1799        // Plain command should match
1800        let input = serde_json::json!({"command": "cargo check"});
1801        let ctx = QueryContext::from_tool("Bash", &input);
1802        assert_eq!(ctx.args[0], "cargo");
1803        let result = policy.evaluate_ctx(&ctx);
1804        assert_eq!(result.effect, Effect::Allow);
1805
1806        // Env-prefixed command should also match
1807        let input2 = serde_json::json!({"command": "SOME_ENV=foo cargo check"});
1808        let ctx2 = QueryContext::from_tool("Bash", &input2);
1809        assert_eq!(ctx2.args[0], "cargo", "env var prefix should be stripped");
1810        let result2 = policy.evaluate_ctx(&ctx2);
1811        assert_eq!(result2.effect, Effect::Allow);
1812
1813        // Multiple env vars
1814        let input3 = serde_json::json!({"command": "A=1 B=2 cargo build"});
1815        let ctx3 = QueryContext::from_tool("Bash", &input3);
1816        assert_eq!(ctx3.args[0], "cargo");
1817
1818        // env utility
1819        let input4 = serde_json::json!({"command": "env RUST_BACKTRACE=1 cargo test"});
1820        let ctx4 = QueryContext::from_tool("Bash", &input4);
1821        assert_eq!(ctx4.args[0], "cargo");
1822    }
1823
1824    #[test]
1825    fn serde_roundtrip() {
1826        let policy = CompiledPolicy {
1827            sandboxes: HashMap::new(),
1828            tree: vec![Node::Condition {
1829                observe: Observable::ToolName,
1830                pattern: Pattern::Literal(Value::Literal("Bash".into())),
1831                children: vec![Node::Decision(Decision::Allow(None))],
1832                doc: None,
1833                source: None,
1834                terminal: false,
1835            }],
1836            default_effect: Effect::Deny,
1837            default_sandbox: None,
1838            on_sandbox_violation: Default::default(),
1839            harness_defaults: None,
1840        };
1841        let json = serde_json::to_string_pretty(&policy).unwrap();
1842        let deserialized: CompiledPolicy = serde_json::from_str(&json).unwrap();
1843        assert_eq!(deserialized.tree.len(), 1);
1844        assert_eq!(deserialized.default_effect, Effect::Deny);
1845    }
1846
1847    // -----------------------------------------------------------------------
1848    // resolve_relative_path tests
1849    // -----------------------------------------------------------------------
1850
1851    #[test]
1852    fn resolve_relative_path_absolute_unchanged() {
1853        let result = resolve_relative_path("/usr/bin/ls");
1854        assert_eq!(result, "/usr/bin/ls");
1855    }
1856
1857    #[test]
1858    fn resolve_relative_path_prepends_pwd() {
1859        // SAFETY: test-only, single-threaded access
1860        let saved = std::env::var("PWD").ok();
1861        unsafe { std::env::set_var("PWD", "/home/user/project") };
1862        let result = resolve_relative_path("src/main.rs");
1863        assert_eq!(result, "/home/user/project/src/main.rs");
1864        match saved {
1865            Some(v) => unsafe { std::env::set_var("PWD", v) },
1866            None => unsafe { std::env::remove_var("PWD") },
1867        }
1868    }
1869
1870    #[test]
1871    fn resolve_relative_path_empty() {
1872        let saved = std::env::var("PWD").ok();
1873        unsafe { std::env::set_var("PWD", "/home/user") };
1874        let result = resolve_relative_path("");
1875        // Empty path has no leading slash, so gets PWD prepended
1876        assert_eq!(result, "/home/user/");
1877        match saved {
1878            Some(v) => unsafe { std::env::set_var("PWD", v) },
1879            None => unsafe { std::env::remove_var("PWD") },
1880        }
1881    }
1882
1883    #[test]
1884    fn resolve_relative_path_no_leading_slash() {
1885        let saved = std::env::var("PWD").ok();
1886        unsafe { std::env::set_var("PWD", "/workspace") };
1887        let result = resolve_relative_path("foo/bar.txt");
1888        assert_eq!(result, "/workspace/foo/bar.txt");
1889        match saved {
1890            Some(v) => unsafe { std::env::set_var("PWD", v) },
1891            None => unsafe { std::env::remove_var("PWD") },
1892        }
1893    }
1894
1895    // -----------------------------------------------------------------------
1896    // extract_domain tests
1897    // -----------------------------------------------------------------------
1898
1899    #[test]
1900    fn extract_domain_https_url() {
1901        assert_eq!(
1902            extract_domain("https://example.com/path"),
1903            Some("example.com".into())
1904        );
1905    }
1906
1907    #[test]
1908    fn extract_domain_with_port() {
1909        assert_eq!(
1910            extract_domain("https://example.com:8080/path"),
1911            Some("example.com".into())
1912        );
1913    }
1914
1915    #[test]
1916    fn extract_domain_with_path() {
1917        assert_eq!(
1918            extract_domain("https://api.github.com/repos/owner/repo"),
1919            Some("api.github.com".into())
1920        );
1921    }
1922
1923    #[test]
1924    fn extract_domain_http_url() {
1925        assert_eq!(
1926            extract_domain("http://example.com"),
1927            Some("example.com".into())
1928        );
1929    }
1930
1931    #[test]
1932    fn extract_domain_without_scheme() {
1933        assert_eq!(
1934            extract_domain("example.com/path"),
1935            Some("example.com".into())
1936        );
1937    }
1938
1939    #[test]
1940    fn extract_domain_empty_input() {
1941        assert_eq!(extract_domain(""), None);
1942    }
1943
1944    #[test]
1945    fn extract_domain_scheme_only() {
1946        assert_eq!(extract_domain("https://"), None);
1947    }
1948
1949    // -----------------------------------------------------------------------
1950    // Value::resolve tests
1951    // -----------------------------------------------------------------------
1952
1953    #[test]
1954    fn value_literal_resolve() {
1955        let v = Value::Literal("hello".into());
1956        assert_eq!(v.resolve(), "hello");
1957    }
1958
1959    #[test]
1960    fn value_env_resolve_missing() {
1961        let v = Value::Env("NONEXISTENT_CLASH_TEST_VAR_XYZ".into());
1962        assert_eq!(v.resolve(), "");
1963    }
1964
1965    #[test]
1966    fn value_path_resolve_with_env() {
1967        unsafe { std::env::set_var("CLASH_TEST_HOME", "/home/testuser") };
1968        let v = Value::Path(vec![
1969            Value::Env("CLASH_TEST_HOME".into()),
1970            Value::Literal("projects".into()),
1971        ]);
1972        assert_eq!(v.resolve(), "/home/testuser/projects");
1973        unsafe { std::env::remove_var("CLASH_TEST_HOME") };
1974    }
1975
1976    // -----------------------------------------------------------------------
1977    // Pattern::matches tests
1978    // -----------------------------------------------------------------------
1979
1980    #[test]
1981    fn pattern_prefix_exact_match() {
1982        let pat = Pattern::Prefix(Value::Literal("/home/user".into()));
1983        assert!(pat.matches("/home/user"));
1984    }
1985
1986    #[test]
1987    fn pattern_prefix_child_path() {
1988        let pat = Pattern::Prefix(Value::Literal("/home/user".into()));
1989        assert!(pat.matches("/home/user/documents/file.txt"));
1990    }
1991
1992    #[test]
1993    fn pattern_prefix_non_child_similar() {
1994        // "/home/username" should NOT match prefix "/home/user"
1995        let pat = Pattern::Prefix(Value::Literal("/home/user".into()));
1996        assert!(!pat.matches("/home/username"));
1997    }
1998
1999    #[test]
2000    fn pattern_prefix_empty_matches_nothing() {
2001        // An unresolved env var produces an empty prefix — must not match anything.
2002        let pat = Pattern::Prefix(Value::Env("CLASH_SURELY_UNSET_VAR_XYZ".into()));
2003        assert!(!pat.matches("/any/path"));
2004        assert!(!pat.matches(""));
2005    }
2006
2007    #[test]
2008    fn pattern_literal_with_env() {
2009        unsafe { std::env::set_var("CLASH_TEST_TOOL", "Bash") };
2010        let pat = Pattern::Literal(Value::Env("CLASH_TEST_TOOL".into()));
2011        assert!(pat.matches("Bash"));
2012        assert!(!pat.matches("Read"));
2013        unsafe { std::env::remove_var("CLASH_TEST_TOOL") };
2014    }
2015
2016    #[test]
2017    fn pattern_wildcard_matches_anything() {
2018        assert!(Pattern::Wildcard.matches(""));
2019        assert!(Pattern::Wildcard.matches("anything"));
2020        assert!(Pattern::Wildcard.matches("/some/path"));
2021    }
2022
2023    #[test]
2024    fn pattern_any_of_matches_any() {
2025        let pat = Pattern::AnyOf(vec![
2026            Pattern::Literal(Value::Literal("cat".into())),
2027            Pattern::Literal(Value::Literal("dog".into())),
2028        ]);
2029        assert!(pat.matches("cat"));
2030        assert!(pat.matches("dog"));
2031        assert!(!pat.matches("fish"));
2032    }
2033
2034    #[test]
2035    fn pattern_not_inverts() {
2036        let pat = Pattern::Not(Box::new(Pattern::Literal(Value::Literal("rm".into()))));
2037        assert!(pat.matches("ls"));
2038        assert!(!pat.matches("rm"));
2039    }
2040
2041    // -----------------------------------------------------------------------
2042    // QueryContext::from_tool tests
2043    // -----------------------------------------------------------------------
2044
2045    #[test]
2046    fn from_tool_bash_parses_args() {
2047        let input = serde_json::json!({"command": "git push origin main"});
2048        let ctx = QueryContext::from_tool("Bash", &input);
2049        assert_eq!(ctx.tool_name, "Bash");
2050        assert_eq!(ctx.args[0], "git");
2051        assert!(ctx.args.contains(&"push".to_string()));
2052        assert!(ctx.fs_op.is_none());
2053        assert!(ctx.net_domain.is_none());
2054    }
2055
2056    #[test]
2057    fn from_tool_read_extracts_path() {
2058        let input = serde_json::json!({"file_path": "/src/main.rs"});
2059        let ctx = QueryContext::from_tool("Read", &input);
2060        assert_eq!(ctx.tool_name, "Read");
2061        assert_eq!(ctx.fs_op.as_deref(), Some("read"));
2062        assert_eq!(ctx.fs_path.as_deref(), Some("/src/main.rs"));
2063        assert!(ctx.args.is_empty());
2064    }
2065
2066    #[test]
2067    fn from_tool_write_extracts_path() {
2068        let input = serde_json::json!({"file_path": "/tmp/output.txt"});
2069        let ctx = QueryContext::from_tool("Write", &input);
2070        assert_eq!(ctx.fs_op.as_deref(), Some("write"));
2071        assert_eq!(ctx.fs_path.as_deref(), Some("/tmp/output.txt"));
2072    }
2073
2074    #[test]
2075    fn from_tool_edit_extracts_path() {
2076        let input = serde_json::json!({"file_path": "/project/lib.rs"});
2077        let ctx = QueryContext::from_tool("Edit", &input);
2078        assert_eq!(ctx.fs_op.as_deref(), Some("write"));
2079        assert_eq!(ctx.fs_path.as_deref(), Some("/project/lib.rs"));
2080    }
2081
2082    #[test]
2083    fn from_tool_glob_extracts_path() {
2084        let input = serde_json::json!({"path": "/src", "pattern": "*.rs"});
2085        let ctx = QueryContext::from_tool("Glob", &input);
2086        assert_eq!(ctx.fs_op.as_deref(), Some("read"));
2087        assert_eq!(ctx.fs_path.as_deref(), Some("/src"));
2088    }
2089
2090    #[test]
2091    fn from_tool_grep_falls_back_to_pattern() {
2092        let input = serde_json::json!({"pattern": "/some/path"});
2093        let ctx = QueryContext::from_tool("Grep", &input);
2094        assert_eq!(ctx.fs_op.as_deref(), Some("read"));
2095        assert_eq!(ctx.fs_path.as_deref(), Some("/some/path"));
2096    }
2097
2098    #[test]
2099    fn from_tool_webfetch_extracts_domain() {
2100        let input = serde_json::json!({"url": "https://api.github.com/repos"});
2101        let ctx = QueryContext::from_tool("WebFetch", &input);
2102        assert_eq!(ctx.net_domain.as_deref(), Some("api.github.com"));
2103        assert!(ctx.fs_op.is_none());
2104    }
2105
2106    #[test]
2107    fn from_tool_websearch_wildcard_domain() {
2108        let input = serde_json::json!({"query": "rust async"});
2109        let ctx = QueryContext::from_tool("WebSearch", &input);
2110        assert_eq!(ctx.net_domain.as_deref(), Some("*"));
2111    }
2112
2113    #[test]
2114    fn from_tool_unknown_empty() {
2115        let input = serde_json::json!({"some": "data"});
2116        let ctx = QueryContext::from_tool("UnknownTool", &input);
2117        assert_eq!(ctx.tool_name, "UnknownTool");
2118        assert!(ctx.args.is_empty());
2119        assert!(ctx.fs_op.is_none());
2120        assert!(ctx.fs_path.is_none());
2121        assert!(ctx.net_domain.is_none());
2122    }
2123
2124    // -----------------------------------------------------------------------
2125    // platform_warnings tests
2126    // -----------------------------------------------------------------------
2127
2128    #[test]
2129    fn platform_warnings_regex_path() {
2130        use crate::sandbox_types::*;
2131
2132        let policy = CompiledPolicy {
2133            sandboxes: HashMap::from([(
2134                "dev".to_string(),
2135                SandboxPolicy {
2136                    default: Cap::READ | Cap::EXECUTE,
2137                    rules: vec![SandboxRule {
2138                        effect: RuleEffect::Allow,
2139                        caps: Cap::WRITE,
2140                        path: r"/tmp/build-\d+".to_string(),
2141                        path_match: PathMatch::Regex,
2142                        follow_worktrees: false,
2143                        doc: None,
2144                    }],
2145                    network: NetworkPolicy::Deny,
2146                    doc: None,
2147                },
2148            )]),
2149            tree: vec![],
2150            default_effect: Effect::Deny,
2151            default_sandbox: None,
2152            on_sandbox_violation: Default::default(),
2153            harness_defaults: None,
2154        };
2155        let warnings = policy.platform_warnings();
2156        assert_eq!(warnings.len(), 1);
2157        assert!(warnings[0].contains("regex"));
2158        assert!(warnings[0].contains("Linux"));
2159    }
2160
2161    #[test]
2162    fn platform_warnings_allow_domains() {
2163        use crate::sandbox_types::*;
2164
2165        let policy = CompiledPolicy {
2166            sandboxes: HashMap::from([(
2167                "net".to_string(),
2168                SandboxPolicy {
2169                    default: Cap::READ | Cap::EXECUTE,
2170                    rules: vec![],
2171                    network: NetworkPolicy::AllowDomains(vec!["github.com".to_string()]),
2172                    doc: None,
2173                },
2174            )]),
2175            tree: vec![],
2176            default_effect: Effect::Deny,
2177            default_sandbox: None,
2178            on_sandbox_violation: Default::default(),
2179            harness_defaults: None,
2180        };
2181        let warnings = policy.platform_warnings();
2182        assert_eq!(warnings.len(), 1);
2183        assert!(warnings[0].contains("advisory"));
2184        assert!(warnings[0].contains("Linux"));
2185    }
2186
2187    #[test]
2188    fn platform_warnings_none_for_clean_policy() {
2189        use crate::sandbox_types::*;
2190
2191        let policy = CompiledPolicy {
2192            sandboxes: HashMap::from([(
2193                "dev".to_string(),
2194                SandboxPolicy {
2195                    default: Cap::READ | Cap::EXECUTE,
2196                    rules: vec![SandboxRule {
2197                        effect: RuleEffect::Allow,
2198                        caps: Cap::WRITE,
2199                        path: "/tmp".to_string(),
2200                        path_match: PathMatch::Subpath,
2201                        follow_worktrees: false,
2202                        doc: None,
2203                    }],
2204                    network: NetworkPolicy::Deny,
2205                    doc: None,
2206                },
2207            )]),
2208            tree: vec![],
2209            default_effect: Effect::Deny,
2210            default_sandbox: None,
2211            on_sandbox_violation: Default::default(),
2212            harness_defaults: None,
2213        };
2214        assert!(policy.platform_warnings().is_empty());
2215    }
2216
2217    // -----------------------------------------------------------------------
2218    // Canonical tool name matching tests
2219    // -----------------------------------------------------------------------
2220
2221    /// Helper: test if a ToolName pattern matches a given tool name in context.
2222    fn tool_name_matches(pattern_str: &str, context_tool: &str) -> bool {
2223        let pattern = Pattern::Literal(Value::Literal(pattern_str.to_string()));
2224        let ctx = QueryContext::from_tool(context_tool, &serde_json::json!({}));
2225        matches_observable(&Observable::ToolName, &pattern, false, &ctx)
2226    }
2227
2228    #[test]
2229    fn canonical_shell_matches_bash() {
2230        assert!(tool_name_matches("shell", "Bash"));
2231    }
2232
2233    #[test]
2234    fn canonical_read_matches_read() {
2235        assert!(tool_name_matches("read", "Read"));
2236    }
2237
2238    #[test]
2239    fn canonical_write_matches_write() {
2240        assert!(tool_name_matches("write", "Write"));
2241    }
2242
2243    #[test]
2244    fn canonical_edit_matches_edit() {
2245        assert!(tool_name_matches("edit", "Edit"));
2246    }
2247
2248    #[test]
2249    fn canonical_web_fetch_matches_webfetch() {
2250        assert!(tool_name_matches("web_fetch", "WebFetch"));
2251    }
2252
2253    #[test]
2254    fn canonical_web_search_matches_websearch() {
2255        assert!(tool_name_matches("web_search", "WebSearch"));
2256    }
2257
2258    #[test]
2259    fn case_insensitive_bash_matches() {
2260        assert!(tool_name_matches("bash", "Bash"));
2261        assert!(tool_name_matches("BASH", "Bash"));
2262        assert!(tool_name_matches("Bash", "Bash"));
2263    }
2264
2265    #[test]
2266    fn case_insensitive_shell_matches() {
2267        assert!(tool_name_matches("Shell", "Bash"));
2268        assert!(tool_name_matches("SHELL", "Bash"));
2269    }
2270
2271    #[test]
2272    fn internal_name_still_matches() {
2273        // Writing the internal name directly should still work
2274        assert!(tool_name_matches("Bash", "Bash"));
2275        assert!(tool_name_matches("Read", "Read"));
2276        assert!(tool_name_matches("WebFetch", "WebFetch"));
2277    }
2278
2279    #[test]
2280    fn unknown_tool_does_not_match() {
2281        assert!(!tool_name_matches("shell", "Read"));
2282        assert!(!tool_name_matches("run_shell_command", "Bash"));
2283    }
2284
2285    #[test]
2286    fn wildcard_matches_any_tool() {
2287        let pattern = Pattern::Wildcard;
2288        let ctx = QueryContext::from_tool("Bash", &serde_json::json!({}));
2289        assert!(matches_observable(
2290            &Observable::ToolName,
2291            &pattern,
2292            false,
2293            &ctx
2294        ));
2295    }
2296
2297    // -----------------------------------------------------------------------
2298    // CompiledPolicy harness_defaults field tests
2299    // -----------------------------------------------------------------------
2300
2301    #[test]
2302    fn compiled_policy_harness_defaults_field() {
2303        let json = r#"{
2304            "schema_version": 5,
2305            "default_effect": "ask",
2306            "sandboxes": {},
2307            "tree": [],
2308            "harness_defaults": false
2309        }"#;
2310        let policy: CompiledPolicy = serde_json::from_str(json).unwrap();
2311        assert_eq!(policy.harness_defaults, Some(false));
2312    }
2313
2314    #[test]
2315    fn count_harness_nodes() {
2316        let policy = CompiledPolicy {
2317            sandboxes: HashMap::new(),
2318            tree: vec![
2319                Node::Condition {
2320                    observe: Observable::FsOp,
2321                    pattern: Pattern::Literal(Value::Literal("read".to_string())),
2322                    children: vec![Node::Decision(Decision::Allow(None))],
2323                    doc: None,
2324                    source: Some("harness".to_string()),
2325                    terminal: false,
2326                },
2327                Node::Condition {
2328                    observe: Observable::ToolName,
2329                    pattern: Pattern::Literal(Value::Literal("Bash".to_string())),
2330                    children: vec![Node::Decision(Decision::Allow(None))],
2331                    doc: None,
2332                    source: Some("~/.clash/policy.star".to_string()),
2333                    terminal: false,
2334                },
2335            ],
2336            default_effect: Effect::Ask,
2337            default_sandbox: None,
2338            on_sandbox_violation: ViolationAction::default(),
2339            harness_defaults: None,
2340        };
2341        assert_eq!(policy.harness_node_count(), 1);
2342        assert_eq!(policy.tree_without_harness().len(), 1);
2343    }
2344
2345    #[test]
2346    fn compiled_policy_harness_defaults_absent() {
2347        let json = r#"{
2348            "schema_version": 5,
2349            "default_effect": "ask",
2350            "sandboxes": {},
2351            "tree": []
2352        }"#;
2353        let policy: CompiledPolicy = serde_json::from_str(json).unwrap();
2354        assert_eq!(policy.harness_defaults, None);
2355    }
2356}