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