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