Skip to main content

vellaveto_engine/
lib.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4//
5// Copyright 2026 Paolo Vella
6// SPDX-License-Identifier: MPL-2.0
7
8pub mod abac;
9pub mod adaptive_rate;
10pub mod behavioral;
11pub mod cache;
12pub mod cascading;
13pub mod circuit_breaker;
14pub mod collusion;
15mod compiled;
16mod constraint_eval;
17mod context_check;
18pub mod coverage;
19pub mod deputy;
20mod domain;
21mod error;
22pub mod impact;
23mod ip;
24pub mod least_agency;
25mod legacy;
26pub mod lint;
27mod matcher;
28mod normalize;
29mod path;
30mod policy_compile;
31mod rule_check;
32mod traced;
33pub mod wasm_plugin;
34
35pub use compiled::{
36    CompiledConstraint, CompiledContextCondition, CompiledIpRules, CompiledNetworkRules,
37    CompiledPathRules, CompiledPolicy,
38};
39pub use error::{EngineError, PolicyValidationError};
40pub use matcher::{CompiledToolMatcher, PatternMatcher};
41pub use path::DEFAULT_MAX_PATH_DECODE_ITERATIONS;
42
43use vellaveto_types::{
44    Action, ActionSummary, EvaluationContext, EvaluationTrace, Policy, PolicyType, Verdict,
45};
46
47use globset::{Glob, GlobMatcher};
48use regex::Regex;
49use std::collections::HashMap;
50use std::sync::RwLock;
51
52/// Maximum number of compiled glob matchers kept in the legacy runtime cache.
53const MAX_GLOB_MATCHER_CACHE_ENTRIES: usize = 2048;
54/// Maximum number of domain normalization results kept in the runtime cache.
55///
56/// Currently the cache starts empty and is not actively populated by
57/// evaluation paths (domain normalization is done inline).  The constant is
58/// retained as the documented eviction cap for the `domain_norm_cache`
59/// field so that any future population path has a bound ready.
60#[allow(dead_code)]
61const MAX_DOMAIN_NORM_CACHE_ENTRIES: usize = 4096;
62
63/// The core policy evaluation engine.
64///
65/// Evaluates [`Action`]s against a set of [`Policy`] rules to produce a [`Verdict`].
66///
67/// # Security Model
68///
69/// - **Fail-closed**: An empty policy set produces `Verdict::Deny`.
70/// - **Priority ordering**: Higher-priority policies are evaluated first.
71/// - **Pattern matching**: Policy IDs use `"tool:function"` convention with wildcard support.
72pub struct PolicyEngine {
73    strict_mode: bool,
74    compiled_policies: Vec<CompiledPolicy>,
75    /// Maps exact tool names to sorted indices in `compiled_policies`.
76    /// Only policies with an exact tool name pattern are indexed here.
77    tool_index: HashMap<String, Vec<usize>>,
78    /// Indices of policies that cannot be indexed by tool name
79    /// (Universal, prefix, suffix, or Any tool patterns).
80    /// Already sorted by position in `compiled_policies` (= priority order).
81    always_check: Vec<usize>,
82    /// When false (default), time-window context conditions always use wall-clock
83    /// time. When true, the engine honors `EvaluationContext.timestamp` from the
84    /// caller. **Only enable for deterministic testing** — in production, a client
85    /// could supply a fake timestamp to bypass time-window policies.
86    trust_context_timestamps: bool,
87    /// Maximum percent-decoding iterations in `normalize_path` before
88    /// fail-closing to `"/"`. Defaults to [`DEFAULT_MAX_PATH_DECODE_ITERATIONS`] (20).
89    max_path_decode_iterations: u32,
90    /// Legacy runtime cache for glob matcher compilation.
91    ///
92    /// This cache is used by `glob_is_match` on the non-precompiled path.
93    glob_matcher_cache: RwLock<HashMap<String, GlobMatcher>>,
94    /// Runtime cache for domain normalization results.
95    ///
96    /// Caches both successful normalization (Some) and invalid domains (None)
97    /// to avoid repeated IDNA parsing on hot network/domain constraint paths.
98    ///
99    /// SECURITY (FIND-R46-003): Bounded to [`MAX_DOMAIN_NORM_CACHE_ENTRIES`].
100    /// When capacity is exceeded, the cache is cleared to prevent unbounded
101    /// memory growth from attacker-controlled domain strings. Currently this
102    /// cache is not actively populated — domain normalization is done inline
103    /// via [`domain::normalize_domain_for_match`]. The eviction guard exists
104    /// as a defense-in-depth measure for future caching additions.
105    domain_norm_cache: RwLock<HashMap<String, Option<String>>>,
106    /// Optional topology guard for pre-policy tool call filtering.
107    /// When set, tool calls are checked against the live topology graph
108    /// before policy evaluation. Unknown tools may be denied or trigger
109    /// a re-crawl depending on configuration.
110    ///
111    /// Only available when the `discovery` feature is enabled.
112    #[cfg(feature = "discovery")]
113    topology_guard: Option<std::sync::Arc<vellaveto_discovery::guard::TopologyGuard>>,
114}
115
116impl std::fmt::Debug for PolicyEngine {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        let mut s = f.debug_struct("PolicyEngine");
119        s.field("strict_mode", &self.strict_mode)
120            .field("compiled_policies_count", &self.compiled_policies.len())
121            .field("indexed_tools", &self.tool_index.len())
122            .field("always_check_count", &self.always_check.len())
123            .field(
124                "max_path_decode_iterations",
125                &self.max_path_decode_iterations,
126            )
127            .field(
128                "glob_matcher_cache_size",
129                &self
130                    .glob_matcher_cache
131                    .read()
132                    .map(|c| c.len())
133                    .unwrap_or_default(),
134            )
135            .field(
136                "domain_norm_cache_size",
137                &self
138                    .domain_norm_cache
139                    .read()
140                    .map(|c| c.len())
141                    .unwrap_or_default(),
142            );
143        #[cfg(feature = "discovery")]
144        {
145            s.field("topology_guard", &self.topology_guard.is_some());
146        }
147        s.finish()
148    }
149}
150
151impl PolicyEngine {
152    /// Create a new policy engine.
153    ///
154    /// When `strict_mode` is true, the engine applies stricter validation
155    /// on conditions and parameters.
156    pub fn new(strict_mode: bool) -> Self {
157        Self {
158            strict_mode,
159            compiled_policies: Vec::new(),
160            tool_index: HashMap::new(),
161            always_check: Vec::new(),
162            trust_context_timestamps: false,
163            max_path_decode_iterations: DEFAULT_MAX_PATH_DECODE_ITERATIONS,
164            glob_matcher_cache: RwLock::new(HashMap::with_capacity(256)),
165            // IMP-R208-001: Zero initial capacity — cache not actively populated.
166            domain_norm_cache: RwLock::new(HashMap::new()),
167            #[cfg(feature = "discovery")]
168            topology_guard: None,
169        }
170    }
171
172    /// Returns the engine's strict_mode setting.
173    pub fn strict_mode(&self) -> bool {
174        self.strict_mode
175    }
176
177    /// Validate a domain pattern used in network_rules.
178    ///
179    /// Rules per RFC 1035:
180    /// - Labels (parts between dots) must be 1-63 characters each
181    /// - Each label must be alphanumeric + hyphen only (no leading/trailing hyphen)
182    /// - Total domain length max 253 characters
183    /// - Wildcard `*.` prefix is allowed (only at the beginning)
184    /// - Empty string is rejected
185    ///
186    /// See the internal `domain::validate_domain_pattern` function for details.
187    pub fn validate_domain_pattern(pattern: &str) -> Result<(), String> {
188        domain::validate_domain_pattern(pattern)
189    }
190
191    /// Create a new policy engine with pre-compiled policies.
192    ///
193    /// All regex and glob patterns are compiled at construction time.
194    /// Invalid patterns cause immediate rejection with descriptive errors.
195    /// The compiled policies are sorted by priority (highest first, deny-overrides).
196    pub fn with_policies(
197        strict_mode: bool,
198        policies: &[Policy],
199    ) -> Result<Self, Vec<PolicyValidationError>> {
200        let compiled = Self::compile_policies(policies, strict_mode)?;
201        let (tool_index, always_check) = Self::build_tool_index(&compiled);
202        Ok(Self {
203            strict_mode,
204            compiled_policies: compiled,
205            tool_index,
206            always_check,
207            trust_context_timestamps: false,
208            max_path_decode_iterations: DEFAULT_MAX_PATH_DECODE_ITERATIONS,
209            glob_matcher_cache: RwLock::new(HashMap::with_capacity(256)),
210            // IMP-R208-001: Zero initial capacity — cache not actively populated.
211            domain_norm_cache: RwLock::new(HashMap::new()),
212            #[cfg(feature = "discovery")]
213            topology_guard: None,
214        })
215    }
216
217    /// Enable trusting `EvaluationContext.timestamp` for time-window checks.
218    ///
219    /// **WARNING:** Only use for deterministic testing. In production, a client
220    /// can supply a fake timestamp to bypass time-window policies.
221    #[cfg(test)]
222    pub fn set_trust_context_timestamps(&mut self, trust: bool) {
223        self.trust_context_timestamps = trust;
224    }
225
226    /// Set the topology guard for pre-policy tool call filtering.
227    ///
228    /// When set, `evaluate_action` checks the tool against the topology graph
229    /// before policy evaluation. Unknown tools produce `Verdict::Deny` with a
230    /// topology-specific reason, unless the guard returns `Bypassed`.
231    #[cfg(feature = "discovery")]
232    pub fn set_topology_guard(
233        &mut self,
234        guard: std::sync::Arc<vellaveto_discovery::guard::TopologyGuard>,
235    ) {
236        self.topology_guard = Some(guard);
237    }
238
239    /// Check the topology guard (if set) before policy evaluation.
240    ///
241    /// Returns `Some(Verdict::Deny)` if the tool is unknown or ambiguous
242    /// and the guard is configured to block. Returns `None` to proceed
243    /// with normal policy evaluation.
244    #[cfg(feature = "discovery")]
245    fn check_topology(&self, action: &Action) -> Option<Verdict> {
246        let guard = self.topology_guard.as_ref()?;
247        let tool_name = &action.tool;
248        match guard.check(tool_name) {
249            vellaveto_discovery::guard::TopologyVerdict::Known { .. } => None,
250            vellaveto_discovery::guard::TopologyVerdict::Bypassed => None,
251            vellaveto_discovery::guard::TopologyVerdict::Unknown { suggestion, .. } => {
252                let reason = if let Some(closest) = suggestion {
253                    format!(
254                        "Tool '{}' not found in topology graph (did you mean '{}'?)",
255                        tool_name, closest
256                    )
257                } else {
258                    format!("Tool '{}' not found in topology graph", tool_name)
259                };
260                Some(Verdict::Deny { reason })
261            }
262            vellaveto_discovery::guard::TopologyVerdict::Ambiguous { matches, .. } => {
263                Some(Verdict::Deny {
264                    reason: format!(
265                        "Tool '{}' is ambiguous — matches servers: {}. Use qualified name (server::tool).",
266                        tool_name,
267                        matches.join(", ")
268                    ),
269                })
270            }
271        }
272    }
273
274    /// Set the maximum percent-decoding iterations for path normalization.
275    ///
276    /// Paths requiring more iterations fail-closed to `"/"`. The default is
277    /// [`DEFAULT_MAX_PATH_DECODE_ITERATIONS`] (20). A value of 0 disables
278    /// iterative decoding entirely (single pass only).
279    pub fn set_max_path_decode_iterations(&mut self, max: u32) {
280        self.max_path_decode_iterations = max;
281    }
282
283    /// Build a tool-name index for O(matching) evaluation.
284    fn build_tool_index(compiled: &[CompiledPolicy]) -> (HashMap<String, Vec<usize>>, Vec<usize>) {
285        let mut index: HashMap<String, Vec<usize>> = HashMap::with_capacity(compiled.len());
286        let mut always_check = Vec::with_capacity(compiled.len());
287        for (i, cp) in compiled.iter().enumerate() {
288            match &cp.tool_matcher {
289                CompiledToolMatcher::Universal => always_check.push(i),
290                CompiledToolMatcher::ToolOnly(PatternMatcher::Exact(name)) => {
291                    index.entry(name.clone()).or_default().push(i);
292                }
293                CompiledToolMatcher::ToolAndFunction(PatternMatcher::Exact(name), _) => {
294                    index.entry(name.clone()).or_default().push(i);
295                }
296                _ => always_check.push(i),
297            }
298        }
299        // SECURITY (FIND-R49-003): Assert sorted invariant in debug builds.
300        // The always_check list must be sorted by index for deterministic evaluation order.
301        // Tool index values must also be sorted per-key for the same reason.
302        debug_assert!(
303            always_check.windows(2).all(|w| w[0] < w[1]),
304            "always_check must be sorted"
305        );
306        debug_assert!(
307            index.values().all(|v| v.windows(2).all(|w| w[0] < w[1])),
308            "tool_index values must be sorted"
309        );
310        (index, always_check)
311    }
312
313    /// Sort policies by priority (highest first), with deny-overrides at equal priority,
314    /// and a stable tertiary tiebreaker by policy ID for deterministic ordering.
315    ///
316    /// Call this once when loading or modifying policies, then pass the sorted
317    /// slice to [`Self::evaluate_action`] to avoid re-sorting on every evaluation.
318    pub fn sort_policies(policies: &mut [Policy]) {
319        policies.sort_by(|a, b| {
320            let pri = b.priority.cmp(&a.priority);
321            if pri != std::cmp::Ordering::Equal {
322                return pri;
323            }
324            let a_deny = matches!(a.policy_type, PolicyType::Deny);
325            let b_deny = matches!(b.policy_type, PolicyType::Deny);
326            let deny_ord = b_deny.cmp(&a_deny);
327            if deny_ord != std::cmp::Ordering::Equal {
328                return deny_ord;
329            }
330            // Tertiary tiebreaker: lexicographic by ID for deterministic ordering
331            a.id.cmp(&b.id)
332        });
333    }
334
335    // VERIFIED [S1]: Deny-by-default — empty policy set produces Deny (MCPPolicyEngine.tla S1)
336    // VERIFIED [S2]: Priority ordering — higher priority wins (MCPPolicyEngine.tla S2)
337    // VERIFIED [S3]: Deny-overrides — Deny beats Allow at same priority (MCPPolicyEngine.tla S3)
338    // VERIFIED [S5]: Errors produce Deny — every Allow verdict has a matching Allow policy (MCPPolicyEngine.tla S5)
339    // VERIFIED [L1]: Progress — every action gets a verdict (MCPPolicyEngine.tla L1)
340    /// Evaluate an action against a set of policies.
341    ///
342    /// For best performance, pass policies that have been pre-sorted with
343    /// [`Self::sort_policies`]. If not pre-sorted, this method will sort a temporary
344    /// copy (which adds O(n log n) overhead per call).
345    ///
346    /// The first matching policy determines the verdict.
347    /// If no policy matches, the default is Deny (fail-closed).
348    #[must_use = "security verdicts must not be discarded"]
349    pub fn evaluate_action(
350        &self,
351        action: &Action,
352        policies: &[Policy],
353    ) -> Result<Verdict, EngineError> {
354        // Topology pre-filter: check if the tool exists in the topology graph.
355        // Unknown/ambiguous tools are denied before policy evaluation.
356        #[cfg(feature = "discovery")]
357        if let Some(deny) = self.check_topology(action) {
358            return Ok(deny);
359        }
360
361        // Fast path: use pre-compiled policies (zero Mutex, zero runtime compilation)
362        if !self.compiled_policies.is_empty() {
363            return self.evaluate_with_compiled(action);
364        }
365
366        // Legacy path: evaluate ad-hoc policies (compiles patterns on the fly)
367        if policies.is_empty() {
368            return Ok(Verdict::Deny {
369                reason: "No policies defined".to_string(),
370            });
371        }
372
373        // Check if already sorted (by priority desc, deny-first at equal priority,
374        // then by ID ascending as a tiebreaker — FIND-R44-057)
375        let is_sorted = policies.windows(2).all(|w| {
376            let pri = w[0].priority.cmp(&w[1].priority);
377            if pri == std::cmp::Ordering::Equal {
378                let a_deny = matches!(w[0].policy_type, PolicyType::Deny);
379                let b_deny = matches!(w[1].policy_type, PolicyType::Deny);
380                if a_deny == b_deny {
381                    // FIND-R44-057: Tertiary tiebreaker by ID for deterministic ordering
382                    w[0].id.cmp(&w[1].id) != std::cmp::Ordering::Greater
383                } else {
384                    b_deny <= a_deny
385                }
386            } else {
387                pri != std::cmp::Ordering::Less
388            }
389        });
390
391        if is_sorted {
392            for policy in policies {
393                if self.matches_action(action, policy) {
394                    if let Some(verdict) = self.apply_policy(action, policy)? {
395                        return Ok(verdict);
396                    }
397                    // None: on_no_match="continue", try next policy
398                }
399            }
400        } else {
401            let mut sorted: Vec<&Policy> = policies.iter().collect();
402            sorted.sort_by(|a, b| {
403                let pri = b.priority.cmp(&a.priority);
404                if pri != std::cmp::Ordering::Equal {
405                    return pri;
406                }
407                let a_deny = matches!(a.policy_type, PolicyType::Deny);
408                let b_deny = matches!(b.policy_type, PolicyType::Deny);
409                let deny_cmp = b_deny.cmp(&a_deny);
410                if deny_cmp != std::cmp::Ordering::Equal {
411                    return deny_cmp;
412                }
413                // FIND-R44-057: Tertiary tiebreaker by ID for deterministic ordering
414                a.id.cmp(&b.id)
415            });
416            for policy in &sorted {
417                if self.matches_action(action, policy) {
418                    if let Some(verdict) = self.apply_policy(action, policy)? {
419                        return Ok(verdict);
420                    }
421                    // None: on_no_match="continue", try next policy
422                }
423            }
424        }
425
426        Ok(Verdict::Deny {
427            reason: "No matching policy".to_string(),
428        })
429    }
430
431    /// Evaluate an action with optional session context.
432    ///
433    /// This is the context-aware counterpart to [`Self::evaluate_action`].
434    /// When `context` is `Some`, context conditions (time windows, call limits,
435    /// agent identity, action history) are evaluated. When `None`, behaves
436    /// identically to `evaluate_action`.
437    ///
438    /// # WARNING: `policies` parameter ignored when compiled policies exist
439    ///
440    /// When the engine was constructed with [`Self::with_policies`] (or any
441    /// builder that populates `compiled_policies`), the `policies` parameter
442    /// is **completely ignored**. The engine uses its pre-compiled policy set
443    /// instead. This is a known API contract violation that cannot be fixed
444    /// without a breaking change.
445    ///
446    /// This behavior led to a P0 tenant isolation bypass where callers passed
447    /// tenant-specific policies in the `policies` parameter, but the engine
448    /// silently used its global compiled set instead.
449    ///
450    /// # Migration guidance
451    ///
452    /// - **For compiled engines** (the common case): Use [`Self::evaluate_action`]
453    ///   which does not accept a `policies` parameter and makes the contract
454    ///   explicit. Context-aware evaluation with compiled policies works via
455    ///   [`Self::evaluate_action_traced_with_context`].
456    /// - **For dynamic policy sets**: Construct a new engine with
457    ///   [`Self::with_policies`] for each policy set, then call
458    ///   [`Self::evaluate_action`].
459    /// - **Do not** pass tenant-specific or request-scoped policies via the
460    ///   `policies` parameter on an engine that has compiled policies — they
461    ///   will be silently discarded.
462    #[deprecated(
463        since = "4.0.1",
464        note = "policies parameter is silently ignored when compiled policies exist. \
465                Use evaluate_action() for compiled engines or build a new engine \
466                with with_policies() for dynamic policy sets."
467    )]
468    #[must_use = "security verdicts must not be discarded"]
469    pub fn evaluate_action_with_context(
470        &self,
471        action: &Action,
472        policies: &[Policy],
473        context: Option<&EvaluationContext>,
474    ) -> Result<Verdict, EngineError> {
475        // Topology pre-filter: check if the tool exists in the topology graph.
476        #[cfg(feature = "discovery")]
477        if let Some(deny) = self.check_topology(action) {
478            return Ok(deny);
479        }
480
481        // SECURITY (FIND-R50-063): Validate context bounds before evaluation.
482        // Without this, crafted EvaluationContext with >10K previous_actions
483        // bypasses the bounds checks and causes unbounded CPU/memory usage.
484        if let Some(ctx) = context {
485            if let Err(reason) = ctx.validate() {
486                return Ok(Verdict::Deny { reason });
487            }
488        }
489        if context.is_none() {
490            return self.evaluate_action(action, policies);
491        }
492        // Fast path: use pre-compiled policies
493        if !self.compiled_policies.is_empty() {
494            return self.evaluate_with_compiled_ctx(action, context);
495        }
496        // SECURITY (R13-LEG-7): Fail-closed when context is provided but
497        // compiled policies are unavailable. The legacy path cannot evaluate
498        // context conditions (time windows, call limits, agent identity,
499        // forbidden sequences). Silently dropping context would bypass all
500        // context-based restrictions.
501        if let Some(ctx) = context {
502            if ctx.has_any_meaningful_fields() {
503                return Ok(Verdict::Deny {
504                    reason: "Policy engine has no compiled policies; \
505                             context conditions cannot be evaluated (fail-closed)"
506                        .to_string(),
507                });
508            }
509        }
510        // Context was provided but empty — safe to fall through to legacy
511        self.evaluate_action(action, policies)
512    }
513
514    /// Evaluate an action with full decision trace and optional session context.
515    #[must_use = "security verdicts must not be discarded"]
516    pub fn evaluate_action_traced_with_context(
517        &self,
518        action: &Action,
519        context: Option<&EvaluationContext>,
520    ) -> Result<(Verdict, EvaluationTrace), EngineError> {
521        // Topology pre-filter: check if the tool exists in the topology graph.
522        #[cfg(feature = "discovery")]
523        if let Some(deny) = self.check_topology(action) {
524            let param_keys: Vec<String> = action
525                .parameters
526                .as_object()
527                .map(|o| o.keys().cloned().collect::<Vec<String>>())
528                .unwrap_or_default();
529            let trace = EvaluationTrace {
530                action_summary: ActionSummary {
531                    tool: action.tool.clone(),
532                    function: action.function.clone(),
533                    param_count: param_keys.len(),
534                    param_keys,
535                },
536                policies_checked: 0,
537                policies_matched: 0,
538                matches: vec![],
539                verdict: deny.clone(),
540                duration_us: 0,
541            };
542            return Ok((deny, trace));
543        }
544
545        // SECURITY (FIND-R50-063): Validate context bounds before evaluation.
546        if let Some(ctx) = context {
547            if let Err(reason) = ctx.validate() {
548                let deny = Verdict::Deny {
549                    reason: reason.clone(),
550                };
551                let param_keys: Vec<String> = action
552                    .parameters
553                    .as_object()
554                    .map(|o| o.keys().cloned().collect::<Vec<String>>())
555                    .unwrap_or_default();
556                let trace = EvaluationTrace {
557                    action_summary: ActionSummary {
558                        tool: action.tool.clone(),
559                        function: action.function.clone(),
560                        param_count: param_keys.len(),
561                        param_keys,
562                    },
563                    policies_checked: 0,
564                    policies_matched: 0,
565                    matches: vec![],
566                    verdict: deny.clone(),
567                    duration_us: 0,
568                };
569                return Ok((deny, trace));
570            }
571        }
572        if context.is_none() {
573            return self.evaluate_action_traced(action);
574        }
575        // Traced context-aware path
576        self.evaluate_action_traced_ctx(action, context)
577    }
578
579    // ═══════════════════════════════════════════════════
580    // COMPILED EVALUATION PATH (zero Mutex, zero runtime compilation)
581    // ═══════════════════════════════════════════════════
582
583    /// Evaluate an action using pre-compiled policies. Zero Mutex acquisitions.
584    /// Compiled policies are already sorted at compile time.
585    ///
586    /// Uses the tool-name index when available: only checks policies whose tool
587    /// pattern could match `action.tool`, plus `always_check` (wildcard/prefix/suffix).
588    /// Falls back to linear scan when no index has been built.
589    fn evaluate_with_compiled(&self, action: &Action) -> Result<Verdict, EngineError> {
590        // SECURITY (FIND-SEM-003, R227-TYP-1): Normalize tool/function names through
591        // the full pipeline (NFKC + lowercase + homoglyph) before policy matching.
592        // This prevents fullwidth Unicode, circled letters (Ⓐ), and mathematical
593        // variants from bypassing exact-match Deny policies. Patterns are also
594        // normalized via normalize_full at compile time for consistency.
595        let norm_tool = crate::normalize::normalize_full(&action.tool);
596        let norm_func = crate::normalize::normalize_full(&action.function);
597
598        // If index was built, use it for O(matching) instead of O(all)
599        if !self.tool_index.is_empty() || !self.always_check.is_empty() {
600            let tool_specific = self.tool_index.get(&norm_tool);
601            let tool_slice = tool_specific.map_or(&[][..], |v| v.as_slice());
602            let always_slice = &self.always_check;
603
604            // Merge two sorted index slices, iterating in priority order.
605            // SECURITY (R26-ENG-1): When both slices reference the same policy index,
606            // increment BOTH pointers to avoid evaluating the policy twice.
607            let mut ti = 0;
608            let mut ai = 0;
609            loop {
610                let next_idx = match (tool_slice.get(ti), always_slice.get(ai)) {
611                    (Some(&t), Some(&a)) => {
612                        if t < a {
613                            ti += 1;
614                            t
615                        } else if t > a {
616                            ai += 1;
617                            a
618                        } else {
619                            // t == a: same policy in both slices, skip duplicate
620                            ti += 1;
621                            ai += 1;
622                            t
623                        }
624                    }
625                    (Some(&t), None) => {
626                        ti += 1;
627                        t
628                    }
629                    (None, Some(&a)) => {
630                        ai += 1;
631                        a
632                    }
633                    (None, None) => break,
634                };
635
636                let cp = &self.compiled_policies[next_idx];
637                if cp.tool_matcher.matches_normalized(&norm_tool, &norm_func) {
638                    if let Some(verdict) = self.apply_compiled_policy(action, cp)? {
639                        return Ok(verdict);
640                    }
641                    // None: on_no_match="continue", try next policy
642                }
643            }
644        } else {
645            // No index: linear scan (legacy compiled path)
646            for cp in &self.compiled_policies {
647                if cp.tool_matcher.matches_normalized(&norm_tool, &norm_func) {
648                    if let Some(verdict) = self.apply_compiled_policy(action, cp)? {
649                        return Ok(verdict);
650                    }
651                    // None: on_no_match="continue", try next policy
652                }
653            }
654        }
655
656        Ok(Verdict::Deny {
657            reason: "No matching policy".to_string(),
658        })
659    }
660
661    /// Evaluate with compiled policies and session context.
662    fn evaluate_with_compiled_ctx(
663        &self,
664        action: &Action,
665        context: Option<&EvaluationContext>,
666    ) -> Result<Verdict, EngineError> {
667        // SECURITY (FIND-SEM-003, R227-TYP-1): Normalize tool/function names through
668        // the full pipeline (same as evaluate_with_compiled).
669        let norm_tool = crate::normalize::normalize_full(&action.tool);
670        let norm_func = crate::normalize::normalize_full(&action.function);
671
672        if !self.tool_index.is_empty() || !self.always_check.is_empty() {
673            let tool_specific = self.tool_index.get(&norm_tool);
674            let tool_slice = tool_specific.map_or(&[][..], |v| v.as_slice());
675            let always_slice = &self.always_check;
676
677            // SECURITY (R26-ENG-1): Deduplicate merge — see evaluate_compiled().
678            let mut ti = 0;
679            let mut ai = 0;
680            loop {
681                let next_idx = match (tool_slice.get(ti), always_slice.get(ai)) {
682                    (Some(&t), Some(&a)) => {
683                        if t < a {
684                            ti += 1;
685                            t
686                        } else if t > a {
687                            ai += 1;
688                            a
689                        } else {
690                            ti += 1;
691                            ai += 1;
692                            t
693                        }
694                    }
695                    (Some(&t), None) => {
696                        ti += 1;
697                        t
698                    }
699                    (None, Some(&a)) => {
700                        ai += 1;
701                        a
702                    }
703                    (None, None) => break,
704                };
705
706                let cp = &self.compiled_policies[next_idx];
707                if cp.tool_matcher.matches_normalized(&norm_tool, &norm_func) {
708                    if let Some(verdict) = self.apply_compiled_policy_ctx(action, cp, context)? {
709                        return Ok(verdict);
710                    }
711                }
712            }
713        } else {
714            for cp in &self.compiled_policies {
715                if cp.tool_matcher.matches_normalized(&norm_tool, &norm_func) {
716                    if let Some(verdict) = self.apply_compiled_policy_ctx(action, cp, context)? {
717                        return Ok(verdict);
718                    }
719                }
720            }
721        }
722
723        Ok(Verdict::Deny {
724            reason: "No matching policy".to_string(),
725        })
726    }
727
728    /// Apply a matched compiled policy to produce a verdict (no context).
729    /// Returns `None` when a Conditional policy with `on_no_match: "continue"` has no
730    /// constraints fire, signaling the evaluation loop to try the next policy.
731    fn apply_compiled_policy(
732        &self,
733        action: &Action,
734        cp: &CompiledPolicy,
735    ) -> Result<Option<Verdict>, EngineError> {
736        self.apply_compiled_policy_ctx(action, cp, None)
737    }
738
739    /// Apply a matched compiled policy with optional context.
740    fn apply_compiled_policy_ctx(
741        &self,
742        action: &Action,
743        cp: &CompiledPolicy,
744        context: Option<&EvaluationContext>,
745    ) -> Result<Option<Verdict>, EngineError> {
746        // Check path rules before policy type dispatch.
747        // Blocked paths → deny immediately regardless of policy type.
748        if let Some(denial) = self.check_path_rules(action, cp) {
749            return Ok(Some(denial));
750        }
751        // Check network rules before policy type dispatch.
752        if let Some(denial) = self.check_network_rules(action, cp) {
753            return Ok(Some(denial));
754        }
755        // Check IP rules (DNS rebinding protection) after network rules.
756        if let Some(denial) = self.check_ip_rules(action, cp) {
757            return Ok(Some(denial));
758        }
759        // Check context conditions (session-level) before policy type dispatch.
760        // SECURITY: If a policy declares context conditions but no context is
761        // provided, deny the action (fail-closed). Skipping would let callers
762        // bypass time-window / max-calls / agent-id restrictions by omitting context.
763        if !cp.context_conditions.is_empty() {
764            match context {
765                Some(ctx) => {
766                    // SECURITY (R231-ENG-3): Normalize tool name before passing to
767                    // context conditions, consistent with policy matching which uses
768                    // normalize_full(). Prevents future context conditions from
769                    // receiving raw attacker-controlled tool names.
770                    let norm_tool = crate::normalize::normalize_full(&action.tool);
771                    if let Some(denial) = self.check_context_conditions(ctx, cp, &norm_tool) {
772                        return Ok(Some(denial));
773                    }
774                }
775                None => {
776                    return Ok(Some(Verdict::Deny {
777                        reason: format!(
778                            "Policy '{}' requires evaluation context (has {} context condition(s)) but none was provided",
779                            cp.policy.name,
780                            cp.context_conditions.len()
781                        ),
782                    }));
783                }
784            }
785        }
786
787        match &cp.policy.policy_type {
788            PolicyType::Allow => Ok(Some(Verdict::Allow)),
789            PolicyType::Deny => Ok(Some(Verdict::Deny {
790                reason: cp.deny_reason.clone(),
791            })),
792            PolicyType::Conditional { .. } => self.evaluate_compiled_conditions(action, cp),
793            // Handle future variants - fail closed (deny)
794            _ => Ok(Some(Verdict::Deny {
795                reason: format!("Unknown policy type for '{}'", cp.policy.name),
796            })),
797        }
798    }
799    /// Normalize a file path: resolve `..`, `.`, reject null bytes, ensure deterministic form.
800    ///
801    /// Handles percent-encoding, null bytes, and path traversal attempts.
802    pub fn normalize_path(raw: &str) -> Result<String, EngineError> {
803        path::normalize_path(raw)
804    }
805
806    /// Normalize a file path with a configurable percent-decoding iteration limit.
807    ///
808    /// Use this variant when you need to control the maximum decode iterations
809    /// to prevent DoS from deeply nested percent-encoding.
810    pub fn normalize_path_bounded(raw: &str, max_iterations: u32) -> Result<String, EngineError> {
811        path::normalize_path_bounded(raw, max_iterations)
812    }
813
814    /// Extract the domain from a URL string.
815    ///
816    /// Returns the host portion of the URL, or the original string if parsing fails.
817    pub fn extract_domain(url: &str) -> String {
818        domain::extract_domain(url)
819    }
820
821    /// Match a domain against a pattern like `*.example.com` or `example.com`.
822    ///
823    /// Supports wildcard patterns with `*.` prefix for subdomain matching.
824    pub fn match_domain_pattern(domain_str: &str, pattern: &str) -> bool {
825        domain::match_domain_pattern(domain_str, pattern)
826    }
827
828    /// Normalize a domain for matching: lowercase, strip trailing dots, apply IDNA.
829    ///
830    /// See [`domain::normalize_domain_for_match`] for details.
831    fn normalize_domain_for_match(s: &str) -> Option<std::borrow::Cow<'_, str>> {
832        domain::normalize_domain_for_match(s)
833    }
834
835    /// Maximum regex pattern length to prevent ReDoS via overlength patterns.
836    const MAX_REGEX_LEN: usize = 1024;
837
838    /// Validate a regex pattern for ReDoS safety.
839    ///
840    /// Rejects patterns that are too long (>1024 chars) or contain constructs
841    /// known to cause exponential backtracking:
842    ///
843    /// 1. **Nested quantifiers** like `(a+)+`, `(a*)*`, `(a+)*`, `(a*)+`
844    /// 2. **Overlapping alternation with quantifiers** like `(a|a)+` or `(a|ab)+`
845    ///
846    /// **Known limitations (FIND-R46-007):** This is a heuristic check, not a
847    /// full NFA analysis. It does NOT detect all possible ReDoS patterns:
848    /// - Alternation with overlapping character classes (e.g., `([a-z]|[a-m])+`)
849    /// - Backreferences with quantifiers
850    /// - Lookahead/lookbehind with quantifiers
851    /// - Possessive quantifiers (these are actually safe but not recognized)
852    ///
853    /// The `regex` crate uses a DFA/NFA hybrid that is immune to most ReDoS,
854    /// but pattern compilation itself can be expensive for very complex patterns,
855    /// hence the length limit.
856    fn validate_regex_safety(pattern: &str) -> Result<(), String> {
857        if pattern.len() > Self::MAX_REGEX_LEN {
858            return Err(format!(
859                "Regex pattern exceeds maximum length of {} chars ({} chars)",
860                Self::MAX_REGEX_LEN,
861                pattern.len()
862            ));
863        }
864
865        // Detect nested quantifiers: a quantifier applied to a group that
866        // itself contains a quantifier. Simplified check for common patterns.
867        let quantifiers = ['+', '*'];
868        let mut paren_depth = 0i32;
869        let mut has_inner_quantifier = false;
870        let chars: Vec<char> = pattern.chars().collect();
871        // SECURITY (R8-5): Use a skip_next flag to correctly handle escape
872        // sequences. The previous approach checked chars[i-1] == '\\' but
873        // failed for double-escapes like `\\\\(` (literal backslash + open paren).
874        let mut skip_next = false;
875
876        // Track alternation branches within groups to detect overlapping alternation.
877        // SECURITY (FIND-R46-007): Detect `(branch1|branch2)+` where branches share
878        // a common prefix, which can cause backtracking even without nested quantifiers.
879        let mut group_has_alternation = false;
880
881        for i in 0..chars.len() {
882            if skip_next {
883                skip_next = false;
884                continue;
885            }
886            match chars[i] {
887                '\\' => {
888                    // Skip the NEXT character (the escaped one)
889                    skip_next = true;
890                    continue;
891                }
892                '(' => {
893                    paren_depth += 1;
894                    has_inner_quantifier = false;
895                    group_has_alternation = false;
896                }
897                ')' => {
898                    paren_depth -= 1;
899                    // SECURITY (FIND-R58-ENG-002): Reject unbalanced closing parens.
900                    // Negative paren_depth disables alternation/inner-quantifier
901                    // tracking, allowing ReDoS patterns to bypass the safety check.
902                    if paren_depth < 0 {
903                        return Err(format!(
904                            "Invalid regex pattern — unbalanced parentheses: '{}'",
905                            &pattern[..pattern.len().min(100)]
906                        ));
907                    }
908                    // Check if the next char is a quantifier
909                    if i + 1 < chars.len() && quantifiers.contains(&chars[i + 1]) {
910                        if has_inner_quantifier {
911                            return Err(format!(
912                                "Regex pattern contains nested quantifiers (potential ReDoS): '{}'",
913                                &pattern[..pattern.len().min(100)]
914                            ));
915                        }
916                        // FIND-R46-007: Alternation with a quantifier on the group
917                        // can cause backtracking if branches overlap.
918                        if group_has_alternation {
919                            return Err(format!(
920                                "Regex pattern contains alternation with outer quantifier (potential ReDoS): '{}'",
921                                &pattern[..pattern.len().min(100)]
922                            ));
923                        }
924                    }
925                }
926                '|' if paren_depth > 0 => {
927                    group_has_alternation = true;
928                }
929                c if quantifiers.contains(&c) && paren_depth > 0 => {
930                    has_inner_quantifier = true;
931                }
932                _ => {}
933            }
934        }
935
936        // SECURITY (FIND-R58-ENG-004): Reject patterns with unclosed parentheses.
937        if paren_depth != 0 {
938            return Err(format!(
939                "Invalid regex pattern — unbalanced parentheses ({} unclosed): '{}'",
940                paren_depth,
941                &pattern[..pattern.len().min(100)]
942            ));
943        }
944
945        Ok(())
946    }
947
948    /// Compile a regex pattern and test whether it matches the input.
949    ///
950    /// Legacy path: compiles the pattern on each call (no caching).
951    /// For zero-overhead evaluation, use `with_policies()` to pre-compile.
952    ///
953    /// Validates the pattern for ReDoS safety before compilation (H2).
954    fn regex_is_match(
955        &self,
956        pattern: &str,
957        input: &str,
958        policy_id: &str,
959    ) -> Result<bool, EngineError> {
960        Self::validate_regex_safety(pattern).map_err(|reason| EngineError::InvalidCondition {
961            policy_id: policy_id.to_string(),
962            reason,
963        })?;
964        let re = Regex::new(pattern).map_err(|e| EngineError::InvalidCondition {
965            policy_id: policy_id.to_string(),
966            reason: format!("Invalid regex pattern '{}': {}", pattern, e),
967        })?;
968        Ok(re.is_match(input))
969    }
970
971    /// Compile a glob pattern and test whether it matches the input.
972    ///
973    /// Legacy path: compiles the pattern on each call (no caching).
974    /// For zero-overhead evaluation, use `with_policies()` to pre-compile.
975    fn glob_is_match(
976        &self,
977        pattern: &str,
978        input: &str,
979        policy_id: &str,
980    ) -> Result<bool, EngineError> {
981        // SECURITY: On poisoned read lock, treat as cache miss rather than
982        // accessing potentially corrupted data. The pattern will be compiled fresh.
983        {
984            let cache_result = self.glob_matcher_cache.read();
985            match cache_result {
986                Ok(cache) => {
987                    if let Some(matcher) = cache.get(pattern) {
988                        return Ok(matcher.is_match(input));
989                    }
990                }
991                Err(e) => {
992                    tracing::warn!(
993                        "glob_matcher_cache read lock poisoned, treating as cache miss: {}",
994                        e
995                    );
996                    // Fall through to compile the pattern fresh
997                }
998            }
999        }
1000
1001        let matcher = Glob::new(pattern)
1002            .map_err(|e| EngineError::InvalidCondition {
1003                policy_id: policy_id.to_string(),
1004                reason: format!("Invalid glob pattern '{}': {}", pattern, e),
1005            })?
1006            .compile_matcher();
1007        let is_match = matcher.is_match(input);
1008
1009        // SECURITY: On poisoned write lock, skip cache insertion rather than
1010        // writing into potentially corrupted state. The result is still correct,
1011        // just not cached.
1012        let cache_write = self.glob_matcher_cache.write();
1013        let mut cache = match cache_write {
1014            Ok(guard) => guard,
1015            Err(e) => {
1016                tracing::warn!(
1017                    "glob_matcher_cache write lock poisoned, skipping cache insert: {}",
1018                    e
1019                );
1020                return Ok(is_match);
1021            }
1022        };
1023        // FIND-R58-ENG-011: Full cache.clear() can cause a thundering herd of
1024        // recompilation on the legacy (non-precompiled) path. For production,
1025        // use with_policies() to pre-compile patterns and avoid this cache entirely.
1026        if cache.len() >= MAX_GLOB_MATCHER_CACHE_ENTRIES {
1027            // SECURITY (P3-ENG-004): Warn on cache eviction so cache thrashing is
1028            // observable in logs. This indicates a policy set with more unique glob
1029            // patterns than MAX_GLOB_MATCHER_CACHE_ENTRIES, which causes repeated
1030            // recompilation and may indicate a misconfiguration or DoS attempt.
1031            tracing::warn!(
1032                capacity = MAX_GLOB_MATCHER_CACHE_ENTRIES,
1033                "glob_matcher_cache capacity exceeded — clearing cache (cache thrashing possible; prefer with_policies() to pre-compile patterns)"
1034            );
1035            cache.clear();
1036        }
1037        cache.insert(pattern.to_string(), matcher);
1038
1039        Ok(is_match)
1040    }
1041
1042    /// Retrieve a parameter value by dot-separated path.
1043    ///
1044    /// Supports both simple keys (`"path"`) and nested paths (`"config.output.path"`).
1045    ///
1046    /// **Resolution order** (Exploit #5 fix): When the path contains dots, the function
1047    /// checks both an exact key match (e.g., `params["config.path"]`) and dot-split
1048    /// traversal (e.g., `params["config"]["path"]`).
1049    ///
1050    /// **Ambiguity handling (fail-closed):** If both interpretations resolve to different
1051    /// values, the function returns `None`. This prevents an attacker from shadowing a
1052    /// nested value with a literal dotted key (or vice versa). The `None` triggers
1053    /// deny behavior through the constraint's `on_missing` handling.
1054    ///
1055    /// When only one interpretation resolves, that value is returned.
1056    /// When both resolve to the same value, that value is returned.
1057    ///
1058    /// IMPROVEMENT_PLAN 4.1: Also supports bracket notation for array access:
1059    /// - `items[0]` — access first element of array "items"
1060    /// - `config.items[0].path` — traverse nested path with array access
1061    /// - `matrix[0][1]` — multi-dimensional array access
1062    pub fn get_param_by_path<'a>(
1063        params: &'a serde_json::Value,
1064        path: &str,
1065    ) -> Option<&'a serde_json::Value> {
1066        let exact_match = params.get(path);
1067
1068        // For non-dotted paths without brackets, exact match is the only interpretation
1069        if !path.contains('.') && !path.contains('[') {
1070            return exact_match;
1071        }
1072
1073        // Try dot-split traversal for nested objects with bracket notation support
1074        let traversal_match = Self::traverse_path(params, path);
1075
1076        match (exact_match, traversal_match) {
1077            // Both exist but differ: ambiguous — fail-closed (return None)
1078            (Some(exact), Some(traversal)) if exact != traversal => None,
1079            // Both exist and are equal: no ambiguity
1080            (Some(exact), Some(_)) => Some(exact),
1081            // Only one interpretation resolves
1082            (Some(exact), None) => Some(exact),
1083            (None, Some(traversal)) => Some(traversal),
1084            (None, None) => None,
1085        }
1086    }
1087
1088    /// Traverse a JSON value using a path with dot notation and bracket notation.
1089    ///
1090    /// Supports:
1091    /// - `foo.bar` — nested object access
1092    /// - `items[0]` — array index access
1093    /// - `foo.items[0].bar` — mixed traversal
1094    /// - `matrix[0][1]` — consecutive array access
1095    fn traverse_path<'a>(
1096        params: &'a serde_json::Value,
1097        path: &str,
1098    ) -> Option<&'a serde_json::Value> {
1099        let mut current = params;
1100
1101        // Split by dots first, then handle bracket notation within each segment
1102        for segment in path.split('.') {
1103            if segment.is_empty() {
1104                continue;
1105            }
1106
1107            // Check for bracket notation: field[index] or just [index]
1108            if let Some(bracket_pos) = segment.find('[') {
1109                // Get the field name before the bracket (may be empty for [0][1] style)
1110                let field_name = &segment[..bracket_pos];
1111
1112                // If there's a field name, traverse into it first
1113                if !field_name.is_empty() {
1114                    current = current.get(field_name)?;
1115                }
1116
1117                // Parse all bracket indices in this segment: [0][1][2]...
1118                let mut rest = &segment[bracket_pos..];
1119                while rest.starts_with('[') {
1120                    let close_pos = rest.find(']')?;
1121                    let index_str = &rest[1..close_pos];
1122                    let index: usize = index_str.parse().ok()?;
1123
1124                    // Access array element
1125                    current = current.get(index)?;
1126
1127                    // Move past this bracket pair
1128                    rest = &rest[close_pos + 1..];
1129                }
1130
1131                // If there's remaining content after brackets, it's malformed
1132                if !rest.is_empty() {
1133                    return None;
1134                }
1135            } else {
1136                // Simple field access
1137                current = current.get(segment)?;
1138            }
1139        }
1140
1141        Some(current)
1142    }
1143
1144    /// Maximum number of string values to collect during recursive parameter scanning.
1145    /// Prevents DoS from parameters with thousands of nested string values.
1146    const MAX_SCAN_VALUES: usize = 500;
1147
1148    /// Maximum nesting depth for recursive parameter scanning.
1149    ///
1150    /// 32 levels is sufficient for any reasonable MCP tool parameter structure
1151    /// (typical JSON has 3-5 levels; 32 provides ample headroom). Objects or
1152    /// arrays nested beyond this depth are silently skipped — their string
1153    /// values will not be collected for constraint evaluation or DLP scanning.
1154    /// This prevents stack/memory exhaustion from attacker-crafted deeply nested JSON.
1155    const MAX_JSON_DEPTH: usize = 32;
1156
1157    /// Maximum work stack size for iterative JSON traversal.
1158    ///
1159    /// SECURITY (FIND-R168-003): Caps the iterative traversal stack to prevent
1160    /// transient memory spikes from flat JSON objects/arrays with many children.
1161    /// Without this, a 1MB JSON with 100K keys at depth 0 would push all 100K
1162    /// items before the depth/results checks trigger.
1163    const MAX_STACK_SIZE: usize = 10_000;
1164
1165    /// Recursively collect all string values from a JSON structure.
1166    ///
1167    /// Returns a list of `(path, value)` pairs where `path` is a dot-separated
1168    /// description of where the value was found (e.g., `"options.target"`).
1169    /// Uses an iterative approach to avoid stack overflow on deep JSON.
1170    ///
1171    /// Bounded by [`MAX_SCAN_VALUES`] total values and [`MAX_JSON_DEPTH`] nesting depth.
1172    fn collect_all_string_values(params: &serde_json::Value) -> Vec<(String, &str)> {
1173        // Pre-allocate for typical parameter sizes; bounded by MAX_SCAN_VALUES
1174        let mut results = Vec::with_capacity(16);
1175        // Stack: (value, current_path, depth)
1176        let mut stack: Vec<(&serde_json::Value, String, usize)> = vec![(params, String::new(), 0)];
1177
1178        while let Some((val, path, depth)) = stack.pop() {
1179            if results.len() >= Self::MAX_SCAN_VALUES {
1180                break;
1181            }
1182            match val {
1183                serde_json::Value::String(s) => {
1184                    if !path.is_empty() {
1185                        results.push((path, s.as_str()));
1186                    }
1187                }
1188                serde_json::Value::Object(obj) => {
1189                    if depth >= Self::MAX_JSON_DEPTH {
1190                        continue;
1191                    }
1192                    for (key, child) in obj {
1193                        // SECURITY (FIND-R168-003): Bound stack inside push loop.
1194                        if stack.len() >= Self::MAX_STACK_SIZE {
1195                            break;
1196                        }
1197                        let child_path = if path.is_empty() {
1198                            key.clone()
1199                        } else {
1200                            let mut p = String::with_capacity(path.len() + 1 + key.len());
1201                            p.push_str(&path);
1202                            p.push('.');
1203                            p.push_str(key);
1204                            p
1205                        };
1206                        stack.push((child, child_path, depth + 1));
1207                    }
1208                }
1209                serde_json::Value::Array(arr) => {
1210                    if depth >= Self::MAX_JSON_DEPTH {
1211                        continue;
1212                    }
1213                    for (i, child) in arr.iter().enumerate() {
1214                        if stack.len() >= Self::MAX_STACK_SIZE {
1215                            break;
1216                        }
1217                        let child_path = if path.is_empty() {
1218                            format!("[{}]", i)
1219                        } else {
1220                            format!("{}[{}]", path, i)
1221                        };
1222                        stack.push((child, child_path, depth + 1));
1223                    }
1224                }
1225                _ => {}
1226            }
1227        }
1228
1229        results
1230    }
1231
1232    /// Convert an `on_match` action string into a Verdict.
1233    fn make_constraint_verdict(on_match: &str, reason: &str) -> Result<Verdict, EngineError> {
1234        match on_match {
1235            "deny" => Ok(Verdict::Deny {
1236                reason: reason.to_string(),
1237            }),
1238            "require_approval" => Ok(Verdict::RequireApproval {
1239                reason: reason.to_string(),
1240            }),
1241            "allow" => Ok(Verdict::Allow),
1242            other => Err(EngineError::EvaluationError(format!(
1243                "Unknown on_match action: '{}'",
1244                other
1245            ))),
1246        }
1247    }
1248    /// Returns true if any compiled policy has IP rules configured.
1249    ///
1250    /// Used by proxy layers to skip DNS resolution when no policies require it.
1251    pub fn has_ip_rules(&self) -> bool {
1252        self.compiled_policies
1253            .iter()
1254            .any(|cp| cp.compiled_ip_rules.is_some())
1255    }
1256}
1257
1258#[cfg(test)]
1259#[allow(deprecated)] // evaluate_action_with_context: migration tracked in FIND-CREATIVE-005
1260#[path = "engine_tests.rs"]
1261mod tests;