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;