Skip to main content

vellaveto_engine/
traced.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//! Traced policy evaluation methods.
9//!
10//! This module provides evaluation methods that record detailed per-policy
11//! match information for OPA-style decision explanations. Use these methods
12//! when `?trace=true` is requested - they have ~20% allocation overhead
13//! compared to the non-traced hot path.
14
15use crate::compiled::{CompiledConstraint, CompiledPolicy};
16use crate::error::EngineError;
17use crate::PolicyEngine;
18use std::time::Instant;
19use vellaveto_types::{
20    Action, ActionSummary, ConstraintResult, EvaluationContext, EvaluationTrace, Policy,
21    PolicyMatch, PolicyType, Verdict,
22};
23
24impl PolicyEngine {
25    /// Evaluate an action with full decision trace.
26    ///
27    /// Opt-in alternative to [`Self::evaluate_action`] that records per-policy match
28    /// details for OPA-style decision explanations. Has ~20% allocation overhead
29    /// compared to the non-traced hot path, so use only when `?trace=true`.
30    #[must_use = "security verdicts must not be discarded"]
31    pub fn evaluate_action_traced(
32        &self,
33        action: &Action,
34    ) -> Result<(Verdict, EvaluationTrace), EngineError> {
35        let start = Instant::now();
36        let mut policies_checked: usize = 0;
37        let mut final_verdict: Option<Verdict> = None;
38
39        let action_summary = ActionSummary {
40            tool: action.tool.clone(),
41            function: action.function.clone(),
42            param_count: action.parameters.as_object().map(|o| o.len()).unwrap_or(0),
43            param_keys: action
44                .parameters
45                .as_object()
46                .map(|o| o.keys().cloned().collect())
47                .unwrap_or_default(),
48        };
49
50        // Topology pre-filter: check if the tool exists in the topology graph.
51        #[cfg(feature = "discovery")]
52        if let Some(deny) = self.check_topology(action) {
53            let trace = EvaluationTrace {
54                action_summary,
55                policies_checked: 0,
56                policies_matched: 0,
57                matches: vec![],
58                verdict: deny.clone(),
59                duration_us: u64::try_from(start.elapsed().as_micros()).unwrap_or(u64::MAX),
60            };
61            return Ok((deny, trace));
62        }
63
64        if self.compiled_policies.is_empty() {
65            let verdict = Verdict::Deny {
66                reason: "No policies defined".to_string(),
67            };
68            let trace = EvaluationTrace {
69                action_summary,
70                policies_checked: 0,
71                policies_matched: 0,
72                matches: Vec::new(),
73                verdict: verdict.clone(),
74                duration_us: u64::try_from(start.elapsed().as_micros()).unwrap_or(u64::MAX),
75            };
76            return Ok((verdict, trace));
77        }
78
79        // SECURITY (FIND-R206-001): Normalize tool/function names through homoglyph
80        // normalization before policy matching. This prevents fullwidth/Cyrillic/Greek
81        // characters from bypassing exact-match Deny policies. Patterns are normalized
82        // at compile time; input must be normalized at evaluation time for consistency.
83        // Matches the normalization in evaluate_with_compiled().
84        let norm_tool = crate::normalize::normalize_full(&action.tool);
85        let norm_func = crate::normalize::normalize_full(&action.function);
86
87        // Walk compiled policies using the tool index (same order as evaluate_with_compiled)
88        let indices = self.collect_candidate_indices_normalized(&norm_tool);
89        let mut policy_matches: Vec<PolicyMatch> = Vec::with_capacity(indices.len());
90
91        for idx in &indices {
92            let cp = &self.compiled_policies[*idx];
93            policies_checked += 1;
94
95            let tool_matched = cp.tool_matcher.matches_normalized(&norm_tool, &norm_func);
96            if !tool_matched {
97                policy_matches.push(PolicyMatch {
98                    policy_id: cp.policy.id.clone(),
99                    policy_name: cp.policy.name.clone(),
100                    policy_type: Self::policy_type_str(&cp.policy.policy_type),
101                    priority: cp.policy.priority,
102                    tool_matched: false,
103                    constraint_results: Vec::new(),
104                    verdict_contribution: None,
105                });
106                continue;
107            }
108
109            // Tool matched — evaluate the policy and record constraint details
110            let (verdict, constraint_results) = self.apply_compiled_policy_traced(action, cp)?;
111
112            let pm = PolicyMatch {
113                policy_id: cp.policy.id.clone(),
114                policy_name: cp.policy.name.clone(),
115                policy_type: Self::policy_type_str(&cp.policy.policy_type),
116                priority: cp.policy.priority,
117                tool_matched: true,
118                constraint_results,
119                verdict_contribution: verdict.clone(),
120            };
121            policy_matches.push(pm);
122
123            if let Some(v) = verdict {
124                if final_verdict.is_none() {
125                    final_verdict = Some(v);
126                }
127                // First match wins — stop checking further policies
128                break;
129            }
130            // None: on_no_match="continue", try next policy
131        }
132
133        let verdict = final_verdict.unwrap_or(Verdict::Deny {
134            reason: "No matching policy".to_string(),
135        });
136
137        let policies_matched = policy_matches.iter().filter(|m| m.tool_matched).count();
138
139        let trace = EvaluationTrace {
140            action_summary,
141            policies_checked,
142            policies_matched,
143            matches: policy_matches,
144            verdict: verdict.clone(),
145            duration_us: u64::try_from(start.elapsed().as_micros()).unwrap_or(u64::MAX),
146        };
147
148        Ok((verdict, trace))
149    }
150
151    /// Traced evaluation with optional session context.
152    pub(crate) fn evaluate_action_traced_ctx(
153        &self,
154        action: &Action,
155        context: Option<&EvaluationContext>,
156    ) -> Result<(Verdict, EvaluationTrace), EngineError> {
157        let start = Instant::now();
158        let mut policies_checked: usize = 0;
159        let mut final_verdict: Option<Verdict> = None;
160
161        let action_summary = ActionSummary {
162            tool: action.tool.clone(),
163            function: action.function.clone(),
164            param_count: action.parameters.as_object().map(|o| o.len()).unwrap_or(0),
165            param_keys: action
166                .parameters
167                .as_object()
168                .map(|o| o.keys().cloned().collect())
169                .unwrap_or_default(),
170        };
171
172        // SECURITY (R230-ENG-5): Topology pre-filter must run on ALL traced paths.
173        #[cfg(feature = "discovery")]
174        if let Some(deny) = self.check_topology(action) {
175            let trace = EvaluationTrace {
176                action_summary,
177                policies_checked: 0,
178                policies_matched: 0,
179                matches: vec![],
180                verdict: deny.clone(),
181                duration_us: u64::try_from(start.elapsed().as_micros()).unwrap_or(u64::MAX),
182            };
183            return Ok((deny, trace));
184        }
185
186        if self.compiled_policies.is_empty() {
187            let verdict = Verdict::Deny {
188                reason: "No policies defined".to_string(),
189            };
190            let trace = EvaluationTrace {
191                action_summary,
192                policies_checked: 0,
193                policies_matched: 0,
194                matches: Vec::new(),
195                verdict: verdict.clone(),
196                duration_us: u64::try_from(start.elapsed().as_micros()).unwrap_or(u64::MAX),
197            };
198            return Ok((verdict, trace));
199        }
200
201        // SECURITY (FIND-R206-001): Normalize for homoglyph-safe matching.
202        let norm_tool = crate::normalize::normalize_full(&action.tool);
203        let norm_func = crate::normalize::normalize_full(&action.function);
204
205        let indices = self.collect_candidate_indices_normalized(&norm_tool);
206        let mut policy_matches: Vec<PolicyMatch> = Vec::with_capacity(indices.len());
207
208        for idx in &indices {
209            let cp = &self.compiled_policies[*idx];
210            policies_checked += 1;
211
212            let tool_matched = cp.tool_matcher.matches_normalized(&norm_tool, &norm_func);
213            if !tool_matched {
214                policy_matches.push(PolicyMatch {
215                    policy_id: cp.policy.id.clone(),
216                    policy_name: cp.policy.name.clone(),
217                    policy_type: Self::policy_type_str(&cp.policy.policy_type),
218                    priority: cp.policy.priority,
219                    tool_matched: false,
220                    constraint_results: Vec::new(),
221                    verdict_contribution: None,
222                });
223                continue;
224            }
225
226            let (verdict, constraint_results) =
227                self.apply_compiled_policy_traced_ctx(action, cp, context)?;
228
229            let pm = PolicyMatch {
230                policy_id: cp.policy.id.clone(),
231                policy_name: cp.policy.name.clone(),
232                policy_type: Self::policy_type_str(&cp.policy.policy_type),
233                priority: cp.policy.priority,
234                tool_matched: true,
235                constraint_results,
236                verdict_contribution: verdict.clone(),
237            };
238            policy_matches.push(pm);
239
240            if let Some(v) = verdict {
241                if final_verdict.is_none() {
242                    final_verdict = Some(v);
243                }
244                break;
245            }
246        }
247
248        let verdict = final_verdict.unwrap_or(Verdict::Deny {
249            reason: "No matching policy".to_string(),
250        });
251
252        let policies_matched = policy_matches.iter().filter(|m| m.tool_matched).count();
253
254        let trace = EvaluationTrace {
255            action_summary,
256            policies_checked,
257            policies_matched,
258            matches: policy_matches,
259            verdict: verdict.clone(),
260            duration_us: u64::try_from(start.elapsed().as_micros()).unwrap_or(u64::MAX),
261        };
262
263        Ok((verdict, trace))
264    }
265
266    /// Collect candidate policy indices using a pre-normalized tool name.
267    ///
268    /// SECURITY (FIND-R206-001): Uses the normalized tool name for index lookup,
269    /// ensuring homoglyph-normalized tool names match the normalized index keys.
270    fn collect_candidate_indices_normalized(&self, norm_tool: &str) -> Vec<usize> {
271        if self.tool_index.is_empty() && self.always_check.is_empty() {
272            // No index: return all indices in order
273            return (0..self.compiled_policies.len()).collect();
274        }
275
276        let tool_specific = self.tool_index.get(norm_tool);
277        let tool_slice = tool_specific.map(|v| v.as_slice()).unwrap_or(&[]);
278        let always_slice = &self.always_check;
279
280        // Merge two sorted index slices.
281        // SECURITY (R26-ENG-1): Deduplicate when same index in both slices.
282        let mut result = Vec::with_capacity(tool_slice.len() + always_slice.len());
283        let mut ti = 0;
284        let mut ai = 0;
285        loop {
286            let next_idx = match (tool_slice.get(ti), always_slice.get(ai)) {
287                (Some(&t), Some(&a)) => {
288                    if t < a {
289                        ti += 1;
290                        t
291                    } else if t > a {
292                        ai += 1;
293                        a
294                    } else {
295                        ti += 1;
296                        ai += 1;
297                        t
298                    }
299                }
300                (Some(&t), None) => {
301                    ti += 1;
302                    t
303                }
304                (None, Some(&a)) => {
305                    ai += 1;
306                    a
307                }
308                (None, None) => break,
309            };
310            result.push(next_idx);
311        }
312        result
313    }
314
315    /// Apply a compiled policy and return both the verdict and constraint trace.
316    /// Returns `None` as the verdict when `on_no_match: "continue"` and no constraints fired.
317    fn apply_compiled_policy_traced(
318        &self,
319        action: &Action,
320        cp: &CompiledPolicy,
321    ) -> Result<(Option<Verdict>, Vec<ConstraintResult>), EngineError> {
322        self.apply_compiled_policy_traced_ctx(action, cp, None)
323    }
324
325    fn apply_compiled_policy_traced_ctx(
326        &self,
327        action: &Action,
328        cp: &CompiledPolicy,
329        context: Option<&EvaluationContext>,
330    ) -> Result<(Option<Verdict>, Vec<ConstraintResult>), EngineError> {
331        // Check path rules BEFORE policy type dispatch (mirrors apply_compiled_policy).
332        // Without this, ?trace=true would bypass all path/domain blocking.
333        if let Some(denial) = self.check_path_rules(action, cp) {
334            return Ok((Some(denial), Vec::new()));
335        }
336        // Check network rules before policy type dispatch.
337        if let Some(denial) = self.check_network_rules(action, cp) {
338            return Ok((Some(denial), Vec::new()));
339        }
340        // Check IP rules (DNS rebinding protection) after network rules.
341        // SECURITY: Without this, ?trace=true would bypass IP-based blocking.
342        if let Some(denial) = self.check_ip_rules(action, cp) {
343            return Ok((Some(denial), Vec::new()));
344        }
345        // Check context conditions.
346        // SECURITY: Fail-closed when context conditions exist but no context provided.
347        if !cp.context_conditions.is_empty() {
348            match context {
349                Some(ctx) => {
350                    // SECURITY (R231-ENG-3): Normalize tool name before context
351                    // conditions (mirrors lib.rs).
352                    let norm_tool = crate::normalize::normalize_full(&action.tool);
353                    if let Some(denial) = self.check_context_conditions(ctx, cp, &norm_tool) {
354                        return Ok((Some(denial), Vec::new()));
355                    }
356                }
357                None => {
358                    return Ok((Some(Verdict::Deny {
359                        reason: format!(
360                            "Policy '{}' requires evaluation context (has {} context condition(s)) but none was provided",
361                            cp.policy.name,
362                            cp.context_conditions.len()
363                        ),
364                    }), Vec::new()));
365                }
366            }
367        }
368
369        match &cp.policy.policy_type {
370            PolicyType::Allow => Ok((Some(Verdict::Allow), Vec::new())),
371            PolicyType::Deny => Ok((
372                Some(Verdict::Deny {
373                    reason: cp.deny_reason.clone(),
374                }),
375                Vec::new(),
376            )),
377            PolicyType::Conditional { .. } => self.evaluate_compiled_conditions_traced(action, cp),
378            // Handle future variants - fail closed (deny)
379            _ => {
380                // SECURITY (R239-XCUT-5): Genericize — policy name in debug only.
381                tracing::debug!(policy = %cp.policy.name, "Request denied (unknown policy type)");
382                Ok((
383                    Some(Verdict::Deny {
384                        reason: "Request denied (unknown policy type)".to_string(),
385                    }),
386                    Vec::new(),
387                ))
388            }
389        }
390    }
391
392    /// Evaluate compiled conditions with full constraint tracing.
393    /// Delegates to `evaluate_compiled_conditions_core` with trace collection enabled.
394    /// Returns `None` as the verdict when `on_no_match: "continue"` and no constraints fired.
395    fn evaluate_compiled_conditions_traced(
396        &self,
397        action: &Action,
398        cp: &CompiledPolicy,
399    ) -> Result<(Option<Verdict>, Vec<ConstraintResult>), EngineError> {
400        let mut results = Some(Vec::with_capacity(cp.constraints.len()));
401        let verdict = self.evaluate_compiled_conditions_core(action, cp, &mut results)?;
402        Ok((verdict, results.unwrap_or_default()))
403    }
404
405    /// Evaluate a single compiled constraint with tracing.
406    pub(crate) fn evaluate_compiled_constraint_traced(
407        &self,
408        action: &Action,
409        policy: &Policy,
410        constraint: &CompiledConstraint,
411    ) -> Result<(Option<Verdict>, Vec<ConstraintResult>), EngineError> {
412        let param_name = constraint.param();
413        let on_match = constraint.on_match();
414        let on_missing = constraint.on_missing();
415
416        // Wildcard param: scan all string values
417        if param_name == "*" {
418            let (all_values, truncated) = Self::collect_all_string_values(&action.parameters);
419            let mut results = Vec::with_capacity(all_values.len());
420            if all_values.is_empty() {
421                if on_missing == "skip" {
422                    return Ok((None, Vec::new()));
423                }
424                results.push(ConstraintResult {
425                    constraint_type: Self::constraint_type_str(constraint),
426                    param: "*".to_string(),
427                    expected: "any string values".to_string(),
428                    actual: "none found".to_string(),
429                    passed: false,
430                });
431                let verdict = Self::make_constraint_verdict(
432                    "deny",
433                    &format!(
434                        "No string values found in parameters (fail-closed) in policy '{}'",
435                        policy.name
436                    ),
437                )?;
438                return Ok((Some(verdict), results));
439            }
440            for (value_path, value_str) in &all_values {
441                let json_val = serde_json::Value::String((*value_str).to_string());
442                let matched = self.constraint_matches_value(&json_val, constraint);
443                results.push(ConstraintResult {
444                    constraint_type: Self::constraint_type_str(constraint),
445                    param: value_path.clone(),
446                    expected: Self::constraint_expected_str(constraint),
447                    actual: value_str.to_string(),
448                    passed: !matched,
449                });
450                if matched {
451                    let verdict = Self::make_constraint_verdict(
452                        on_match,
453                        &format!(
454                            "Parameter '{}' value triggered constraint (policy '{}')",
455                            value_path, policy.name
456                        ),
457                    )?;
458                    return Ok((Some(verdict), results));
459                }
460            }
461            // SECURITY (R234-ENG-4): Fail-closed on truncated scan.
462            if truncated {
463                results.push(ConstraintResult {
464                    constraint_type: "scan_truncated_fail_closed".to_string(),
465                    param: "*".to_string(),
466                    expected: "complete scan".to_string(),
467                    actual: format!("truncated at {} values", Self::MAX_SCAN_VALUES),
468                    passed: false,
469                });
470                let verdict = Verdict::Deny {
471                    reason: format!(
472                        "Parameter scan truncated at {} values — deny (fail-closed) in policy '{}'",
473                        Self::MAX_SCAN_VALUES,
474                        policy.name,
475                    ),
476                };
477                return Ok((Some(verdict), results));
478            }
479            return Ok((None, results));
480        }
481
482        // Get specific parameter
483        let param_value = match Self::get_param_by_path(&action.parameters, param_name) {
484            Some(v) => v,
485            None => {
486                if on_missing == "skip" {
487                    return Ok((
488                        None,
489                        vec![ConstraintResult {
490                            constraint_type: Self::constraint_type_str(constraint),
491                            param: param_name.to_string(),
492                            expected: Self::constraint_expected_str(constraint),
493                            actual: "missing".to_string(),
494                            passed: true,
495                        }],
496                    ));
497                }
498                let verdict = Self::make_constraint_verdict(
499                    "deny",
500                    &format!(
501                        "Parameter '{}' missing (fail-closed) in policy '{}'",
502                        param_name, policy.name
503                    ),
504                )?;
505                return Ok((
506                    Some(verdict),
507                    vec![ConstraintResult {
508                        constraint_type: Self::constraint_type_str(constraint),
509                        param: param_name.to_string(),
510                        expected: Self::constraint_expected_str(constraint),
511                        actual: "missing".to_string(),
512                        passed: false,
513                    }],
514                ));
515            }
516        };
517
518        let matched = self.constraint_matches_value(param_value, constraint);
519        let actual_str = param_value
520            .as_str()
521            .unwrap_or(&param_value.to_string())
522            .to_string();
523        let result = ConstraintResult {
524            constraint_type: Self::constraint_type_str(constraint),
525            param: param_name.to_string(),
526            expected: Self::constraint_expected_str(constraint),
527            actual: actual_str,
528            passed: !matched,
529        };
530
531        if matched {
532            let verdict = self.evaluate_compiled_constraint_value(
533                policy,
534                param_name,
535                on_match,
536                param_value,
537                constraint,
538            )?;
539            Ok((verdict, vec![result]))
540        } else {
541            Ok((None, vec![result]))
542        }
543    }
544
545    /// Check if a constraint matches a given value (without producing a verdict).
546    fn constraint_matches_value(
547        &self,
548        value: &serde_json::Value,
549        constraint: &CompiledConstraint,
550    ) -> bool {
551        match constraint {
552            CompiledConstraint::Glob { matcher, .. } => {
553                if let Some(s) = value.as_str() {
554                    match Self::normalize_path_bounded(s, self.max_path_decode_iterations) {
555                        Ok(ref normalized) => matcher.is_match(normalized),
556                        Err(_) => true,
557                    }
558                } else {
559                    true // non-string → treated as match (fail-closed)
560                }
561            }
562            CompiledConstraint::NotGlob { matchers, .. } => {
563                if let Some(s) = value.as_str() {
564                    match Self::normalize_path_bounded(s, self.max_path_decode_iterations) {
565                        Ok(ref normalized) => !matchers.iter().any(|(_, m)| m.is_match(normalized)),
566                        Err(_) => true,
567                    }
568                } else {
569                    true
570                }
571            }
572            CompiledConstraint::Regex { regex, .. } => {
573                if let Some(s) = value.as_str() {
574                    // SECURITY (R255-ENG-1): Match raw first, then normalized.
575                    // See constraint_eval.rs for rationale.
576                    if regex.is_match(s) {
577                        return true;
578                    }
579                    // SECURITY (R237-ENG-2): Normalize path input before regex matching.
580                    let normalized =
581                        Self::normalize_path_bounded(s, self.max_path_decode_iterations)
582                            .unwrap_or_else(|_| s.to_string());
583                    normalized != s && regex.is_match(&normalized)
584                } else {
585                    true
586                }
587            }
588            CompiledConstraint::DomainMatch { pattern, .. } => {
589                if let Some(s) = value.as_str() {
590                    let domain = Self::extract_domain(s);
591                    // SECURITY (R34-ENG-1): IDNA fail-closed guard matching compiled path.
592                    // If domain contains non-ASCII and cannot be normalized, treat as match
593                    // (fail-closed: deny) to prevent bypass via unnormalizable domains.
594                    if !domain.is_ascii() && Self::normalize_domain_for_match(&domain).is_none() {
595                        return true;
596                    }
597                    Self::match_domain_pattern(&domain, pattern)
598                } else {
599                    true // non-string → fail-closed
600                }
601            }
602            CompiledConstraint::DomainNotIn { patterns, .. } => {
603                if let Some(s) = value.as_str() {
604                    let domain = Self::extract_domain(s);
605                    // SECURITY (R34-ENG-1): IDNA fail-closed for DomainNotIn.
606                    // If domain contains non-ASCII and cannot be normalized, it cannot
607                    // be in the allowlist — constraint fires (fail-closed).
608                    if !domain.is_ascii() && Self::normalize_domain_for_match(&domain).is_none() {
609                        return true;
610                    }
611                    !patterns
612                        .iter()
613                        .any(|p| Self::match_domain_pattern(&domain, p))
614                } else {
615                    true
616                }
617            }
618            CompiledConstraint::Eq {
619                value: expected, ..
620            } => value == expected,
621            CompiledConstraint::Ne {
622                value: expected, ..
623            } => value != expected,
624            CompiledConstraint::OneOf { values, .. } => values.contains(value),
625            CompiledConstraint::NoneOf { values, .. } => !values.contains(value),
626        }
627    }
628
629    fn policy_type_str(pt: &PolicyType) -> String {
630        match pt {
631            PolicyType::Allow => "allow".to_string(),
632            PolicyType::Deny => "deny".to_string(),
633            PolicyType::Conditional { .. } => "conditional".to_string(),
634            // Handle future variants
635            _ => "unknown".to_string(),
636        }
637    }
638
639    fn constraint_type_str(c: &CompiledConstraint) -> String {
640        match c {
641            CompiledConstraint::Glob { .. } => "glob".to_string(),
642            CompiledConstraint::NotGlob { .. } => "not_glob".to_string(),
643            CompiledConstraint::Regex { .. } => "regex".to_string(),
644            CompiledConstraint::DomainMatch { .. } => "domain_match".to_string(),
645            CompiledConstraint::DomainNotIn { .. } => "domain_not_in".to_string(),
646            CompiledConstraint::Eq { .. } => "eq".to_string(),
647            CompiledConstraint::Ne { .. } => "ne".to_string(),
648            CompiledConstraint::OneOf { .. } => "one_of".to_string(),
649            CompiledConstraint::NoneOf { .. } => "none_of".to_string(),
650        }
651    }
652
653    fn constraint_expected_str(c: &CompiledConstraint) -> String {
654        match c {
655            CompiledConstraint::Glob { pattern_str, .. } => {
656                format!("matches glob '{pattern_str}'")
657            }
658            CompiledConstraint::NotGlob { matchers, .. } => {
659                let pats: Vec<&str> = matchers.iter().map(|(s, _)| s.as_str()).collect();
660                format!("not in [{}]", pats.join(", "))
661            }
662            CompiledConstraint::Regex { pattern_str, .. } => {
663                format!("matches regex '{pattern_str}'")
664            }
665            CompiledConstraint::DomainMatch { pattern, .. } => {
666                format!("domain matches '{pattern}'")
667            }
668            CompiledConstraint::DomainNotIn { patterns, .. } => {
669                format!("domain not in [{}]", patterns.join(", "))
670            }
671            CompiledConstraint::Eq { value, .. } => format!("equals {value}"),
672            CompiledConstraint::Ne { value, .. } => format!("not equal {value}"),
673            CompiledConstraint::OneOf { values, .. } => format!("one of {values:?}"),
674            CompiledConstraint::NoneOf { values, .. } => format!("none of {values:?}"),
675        }
676    }
677
678    /// Describe a JSON value's type and size without exposing raw content.
679    /// Used in trace output to give useful debugging info without leaking secrets.
680    pub(crate) fn describe_value(value: &serde_json::Value) -> String {
681        match value {
682            serde_json::Value::Null => "null".to_string(),
683            serde_json::Value::Bool(b) => format!("bool({b})"),
684            serde_json::Value::Number(n) => format!("number({n})"),
685            serde_json::Value::String(s) => format!("string({} chars)", s.len()),
686            serde_json::Value::Array(arr) => format!("array({} items)", arr.len()),
687            serde_json::Value::Object(obj) => format!("object({} keys)", obj.len()),
688        }
689    }
690
691    /// Maximum nesting depth limit for JSON depth calculation.
692    /// Returns this limit when exceeded rather than continuing traversal.
693    const MAX_JSON_DEPTH_LIMIT: usize = 128;
694
695    /// Maximum number of nodes to visit during JSON depth calculation.
696    /// Prevents OOM from extremely wide JSON (e.g., objects with 100K keys).
697    const MAX_JSON_DEPTH_NODES: usize = 10_000;
698
699    /// Calculate the nesting depth of a JSON value using an iterative approach.
700    /// Avoids stack overflow on adversarially deep JSON (e.g., 10,000+ levels).
701    ///
702    /// SECURITY (FIND-R46-005): Bounded by [`MAX_JSON_DEPTH_LIMIT`] (128) for
703    /// depth and [`MAX_JSON_DEPTH_NODES`] (10,000) for total nodes visited.
704    /// Returns the depth limit when either bound is exceeded, ensuring the
705    /// caller's depth check triggers a reject.
706    pub(crate) fn json_depth(value: &serde_json::Value) -> usize {
707        let mut max_depth: usize = 0;
708        let mut nodes_visited: usize = 0;
709        // Stack of (value, current_depth) to process iteratively
710        let mut stack: Vec<(&serde_json::Value, usize)> = vec![(value, 0)];
711
712        while let Some((val, depth)) = stack.pop() {
713            nodes_visited = nodes_visited.saturating_add(1); // FIND-R58-ENG-007: Trap 9
714            if depth > max_depth {
715                max_depth = depth;
716            }
717            // Early termination: if we exceed depth limit or node budget, stop
718            if max_depth > Self::MAX_JSON_DEPTH_LIMIT || nodes_visited > Self::MAX_JSON_DEPTH_NODES
719            {
720                return max_depth;
721            }
722            match val {
723                serde_json::Value::Array(arr) => {
724                    for item in arr {
725                        stack.push((item, depth + 1));
726                    }
727                }
728                serde_json::Value::Object(obj) => {
729                    for item in obj.values() {
730                        stack.push((item, depth + 1));
731                    }
732                }
733                _ => {}
734            }
735        }
736
737        max_depth
738    }
739}
740
741#[cfg(test)]
742mod tests {
743    use super::*;
744    use serde_json::json;
745
746    // ---- describe_value tests ----
747
748    #[test]
749    fn test_describe_value_null() {
750        assert_eq!(PolicyEngine::describe_value(&json!(null)), "null");
751    }
752
753    #[test]
754    fn test_describe_value_bool_true() {
755        assert_eq!(PolicyEngine::describe_value(&json!(true)), "bool(true)");
756    }
757
758    #[test]
759    fn test_describe_value_bool_false() {
760        assert_eq!(PolicyEngine::describe_value(&json!(false)), "bool(false)");
761    }
762
763    #[test]
764    fn test_describe_value_number_integer() {
765        assert_eq!(PolicyEngine::describe_value(&json!(42)), "number(42)");
766    }
767
768    #[test]
769    fn test_describe_value_number_float() {
770        assert_eq!(PolicyEngine::describe_value(&json!(2.71)), "number(2.71)");
771    }
772
773    #[test]
774    fn test_describe_value_string_empty() {
775        assert_eq!(PolicyEngine::describe_value(&json!("")), "string(0 chars)");
776    }
777
778    #[test]
779    fn test_describe_value_string_nonempty() {
780        assert_eq!(
781            PolicyEngine::describe_value(&json!("hello")),
782            "string(5 chars)"
783        );
784    }
785
786    #[test]
787    fn test_describe_value_array_empty() {
788        assert_eq!(PolicyEngine::describe_value(&json!([])), "array(0 items)");
789    }
790
791    #[test]
792    fn test_describe_value_array_nonempty() {
793        assert_eq!(
794            PolicyEngine::describe_value(&json!([1, 2, 3])),
795            "array(3 items)"
796        );
797    }
798
799    #[test]
800    fn test_describe_value_object_empty() {
801        assert_eq!(PolicyEngine::describe_value(&json!({})), "object(0 keys)");
802    }
803
804    #[test]
805    fn test_describe_value_object_nonempty() {
806        assert_eq!(
807            PolicyEngine::describe_value(&json!({"a": 1, "b": 2})),
808            "object(2 keys)"
809        );
810    }
811
812    // ---- json_depth tests ----
813
814    #[test]
815    fn test_json_depth_scalar_zero() {
816        assert_eq!(PolicyEngine::json_depth(&json!(42)), 0);
817        assert_eq!(PolicyEngine::json_depth(&json!("hello")), 0);
818        assert_eq!(PolicyEngine::json_depth(&json!(null)), 0);
819        assert_eq!(PolicyEngine::json_depth(&json!(true)), 0);
820    }
821
822    #[test]
823    fn test_json_depth_flat_array() {
824        assert_eq!(PolicyEngine::json_depth(&json!([1, 2, 3])), 1);
825    }
826
827    #[test]
828    fn test_json_depth_flat_object() {
829        assert_eq!(PolicyEngine::json_depth(&json!({"a": 1, "b": 2})), 1);
830    }
831
832    #[test]
833    fn test_json_depth_nested_objects() {
834        let nested = json!({"a": {"b": {"c": 1}}});
835        assert_eq!(PolicyEngine::json_depth(&nested), 3);
836    }
837
838    #[test]
839    fn test_json_depth_nested_arrays() {
840        let nested = json!([[[1]]]);
841        assert_eq!(PolicyEngine::json_depth(&nested), 3);
842    }
843
844    #[test]
845    fn test_json_depth_mixed_nesting() {
846        let mixed = json!({"a": [{"b": [1]}]});
847        assert_eq!(PolicyEngine::json_depth(&mixed), 4);
848    }
849
850    #[test]
851    fn test_json_depth_empty_containers() {
852        assert_eq!(PolicyEngine::json_depth(&json!([])), 0);
853        assert_eq!(PolicyEngine::json_depth(&json!({})), 0);
854    }
855
856    #[test]
857    fn test_json_depth_wide_object() {
858        // A wide but shallow object (many keys at depth 1)
859        let mut obj = serde_json::Map::new();
860        for i in 0..100 {
861            obj.insert(format!("key_{i}"), json!(i));
862        }
863        let value = serde_json::Value::Object(obj);
864        assert_eq!(PolicyEngine::json_depth(&value), 1);
865    }
866
867    // ---- policy_type_str tests ----
868
869    #[test]
870    fn test_policy_type_str_allow() {
871        assert_eq!(PolicyEngine::policy_type_str(&PolicyType::Allow), "allow");
872    }
873
874    #[test]
875    fn test_policy_type_str_deny() {
876        assert_eq!(PolicyEngine::policy_type_str(&PolicyType::Deny), "deny");
877    }
878
879    #[test]
880    fn test_policy_type_str_conditional() {
881        let pt = PolicyType::Conditional {
882            conditions: json!({}),
883        };
884        assert_eq!(PolicyEngine::policy_type_str(&pt), "conditional");
885    }
886
887    // ---- constraint_type_str tests ----
888
889    #[test]
890    fn test_constraint_type_str_all_variants() {
891        use crate::compiled::CompiledConstraint;
892
893        let glob = globset::GlobBuilder::new("*.txt")
894            .literal_separator(true)
895            .build()
896            .unwrap()
897            .compile_matcher();
898        let c = CompiledConstraint::Glob {
899            param: "p".to_string(),
900            matcher: glob,
901            pattern_str: "*.txt".to_string(),
902            on_match: "deny".to_string(),
903            on_missing: "skip".to_string(),
904        };
905        assert_eq!(PolicyEngine::constraint_type_str(&c), "glob");
906
907        let c = CompiledConstraint::Eq {
908            param: "p".to_string(),
909            value: json!(1),
910            on_match: "deny".to_string(),
911            on_missing: "deny".to_string(),
912        };
913        assert_eq!(PolicyEngine::constraint_type_str(&c), "eq");
914
915        let c = CompiledConstraint::Ne {
916            param: "p".to_string(),
917            value: json!(1),
918            on_match: "deny".to_string(),
919            on_missing: "deny".to_string(),
920        };
921        assert_eq!(PolicyEngine::constraint_type_str(&c), "ne");
922
923        let c = CompiledConstraint::OneOf {
924            param: "p".to_string(),
925            values: vec![],
926            on_match: "deny".to_string(),
927            on_missing: "deny".to_string(),
928        };
929        assert_eq!(PolicyEngine::constraint_type_str(&c), "one_of");
930
931        let c = CompiledConstraint::NoneOf {
932            param: "p".to_string(),
933            values: vec![],
934            on_match: "deny".to_string(),
935            on_missing: "deny".to_string(),
936        };
937        assert_eq!(PolicyEngine::constraint_type_str(&c), "none_of");
938
939        let c = CompiledConstraint::DomainMatch {
940            param: "p".to_string(),
941            pattern: "example.com".to_string(),
942            on_match: "deny".to_string(),
943            on_missing: "deny".to_string(),
944        };
945        assert_eq!(PolicyEngine::constraint_type_str(&c), "domain_match");
946
947        let c = CompiledConstraint::DomainNotIn {
948            param: "p".to_string(),
949            patterns: vec![],
950            on_match: "deny".to_string(),
951            on_missing: "deny".to_string(),
952        };
953        assert_eq!(PolicyEngine::constraint_type_str(&c), "domain_not_in");
954
955        let c = CompiledConstraint::Regex {
956            param: "p".to_string(),
957            regex: regex::Regex::new(".*").unwrap(),
958            pattern_str: ".*".to_string(),
959            on_match: "deny".to_string(),
960            on_missing: "deny".to_string(),
961        };
962        assert_eq!(PolicyEngine::constraint_type_str(&c), "regex");
963
964        let c = CompiledConstraint::NotGlob {
965            param: "p".to_string(),
966            matchers: vec![],
967            on_match: "deny".to_string(),
968            on_missing: "deny".to_string(),
969        };
970        assert_eq!(PolicyEngine::constraint_type_str(&c), "not_glob");
971    }
972
973    // ---- constraint_expected_str tests ----
974
975    #[test]
976    fn test_constraint_expected_str_eq() {
977        let c = CompiledConstraint::Eq {
978            param: "p".to_string(),
979            value: json!("hello"),
980            on_match: "deny".to_string(),
981            on_missing: "deny".to_string(),
982        };
983        assert_eq!(
984            PolicyEngine::constraint_expected_str(&c),
985            "equals \"hello\""
986        );
987    }
988
989    #[test]
990    fn test_constraint_expected_str_ne() {
991        let c = CompiledConstraint::Ne {
992            param: "p".to_string(),
993            value: json!(42),
994            on_match: "deny".to_string(),
995            on_missing: "deny".to_string(),
996        };
997        assert_eq!(PolicyEngine::constraint_expected_str(&c), "not equal 42");
998    }
999
1000    #[test]
1001    fn test_constraint_expected_str_domain_match() {
1002        let c = CompiledConstraint::DomainMatch {
1003            param: "url".to_string(),
1004            pattern: "*.evil.com".to_string(),
1005            on_match: "deny".to_string(),
1006            on_missing: "deny".to_string(),
1007        };
1008        assert_eq!(
1009            PolicyEngine::constraint_expected_str(&c),
1010            "domain matches '*.evil.com'"
1011        );
1012    }
1013
1014    #[test]
1015    fn test_constraint_expected_str_domain_not_in() {
1016        let c = CompiledConstraint::DomainNotIn {
1017            param: "url".to_string(),
1018            patterns: vec!["a.com".to_string(), "b.com".to_string()],
1019            on_match: "deny".to_string(),
1020            on_missing: "deny".to_string(),
1021        };
1022        assert_eq!(
1023            PolicyEngine::constraint_expected_str(&c),
1024            "domain not in [a.com, b.com]"
1025        );
1026    }
1027
1028    // ---- evaluate_action_traced tests ----
1029
1030    #[test]
1031    fn test_evaluate_action_traced_no_policies_deny() {
1032        let engine = PolicyEngine::new(false);
1033        let action = Action::new("tool", "func", json!({}));
1034        let (verdict, trace) = engine.evaluate_action_traced(&action).unwrap();
1035        assert!(matches!(verdict, Verdict::Deny { .. }));
1036        assert_eq!(trace.policies_checked, 0);
1037        assert_eq!(trace.policies_matched, 0);
1038        assert!(matches!(trace.verdict, Verdict::Deny { .. }));
1039    }
1040
1041    #[test]
1042    fn test_evaluate_action_traced_allow_policy() {
1043        let policies = vec![Policy {
1044            id: "*".to_string(),
1045            name: "allow-all".to_string(),
1046            policy_type: PolicyType::Allow,
1047            priority: 100,
1048            path_rules: None,
1049            network_rules: None,
1050        }];
1051        let engine = PolicyEngine::with_policies(false, &policies).unwrap();
1052        let action = Action::new("tool", "func", json!({}));
1053        let (verdict, trace) = engine.evaluate_action_traced(&action).unwrap();
1054        assert!(matches!(verdict, Verdict::Allow));
1055        assert!(trace.policies_checked >= 1);
1056        assert!(trace.policies_matched >= 1);
1057        assert!(trace.duration_us < 10_000_000); // sanity: should complete in <10s
1058    }
1059
1060    #[test]
1061    fn test_evaluate_action_traced_deny_policy() {
1062        let policies = vec![Policy {
1063            id: "bash:*".to_string(),
1064            name: "block-bash".to_string(),
1065            policy_type: PolicyType::Deny,
1066            priority: 100,
1067            path_rules: None,
1068            network_rules: None,
1069        }];
1070        let engine = PolicyEngine::with_policies(false, &policies).unwrap();
1071        let action = Action::new("bash", "execute", json!({}));
1072        let (verdict, trace) = engine.evaluate_action_traced(&action).unwrap();
1073        assert!(matches!(verdict, Verdict::Deny { .. }));
1074        assert_eq!(trace.action_summary.tool, "bash");
1075        assert_eq!(trace.action_summary.function, "execute");
1076    }
1077
1078    #[test]
1079    fn test_evaluate_action_traced_action_summary_param_count() {
1080        let policies = vec![Policy {
1081            id: "*".to_string(),
1082            name: "allow-all".to_string(),
1083            policy_type: PolicyType::Allow,
1084            priority: 100,
1085            path_rules: None,
1086            network_rules: None,
1087        }];
1088        let engine = PolicyEngine::with_policies(false, &policies).unwrap();
1089        let action = Action::new("tool", "func", json!({"a": 1, "b": 2, "c": 3}));
1090        let (_, trace) = engine.evaluate_action_traced(&action).unwrap();
1091        assert_eq!(trace.action_summary.param_count, 3);
1092        assert_eq!(trace.action_summary.param_keys.len(), 3);
1093    }
1094
1095    #[test]
1096    fn test_evaluate_action_traced_no_match_deny() {
1097        let policies = vec![Policy {
1098            id: "other_tool:*".to_string(),
1099            name: "allow-other".to_string(),
1100            policy_type: PolicyType::Allow,
1101            priority: 100,
1102            path_rules: None,
1103            network_rules: None,
1104        }];
1105        let engine = PolicyEngine::with_policies(false, &policies).unwrap();
1106        let action = Action::new("bash", "execute", json!({}));
1107        let (verdict, trace) = engine.evaluate_action_traced(&action).unwrap();
1108        // No matching policy -> deny
1109        assert!(matches!(verdict, Verdict::Deny { .. }));
1110        assert_eq!(trace.policies_matched, 0);
1111    }
1112
1113    #[test]
1114    fn test_evaluate_action_traced_conditional_require_approval() {
1115        let policies = vec![Policy {
1116            id: "network:*".to_string(),
1117            name: "net-approval".to_string(),
1118            policy_type: PolicyType::Conditional {
1119                conditions: json!({ "require_approval": true }),
1120            },
1121            priority: 100,
1122            path_rules: None,
1123            network_rules: None,
1124        }];
1125        let engine = PolicyEngine::with_policies(false, &policies).unwrap();
1126        let action = Action::new("network", "connect", json!({}));
1127        let (verdict, _trace) = engine.evaluate_action_traced(&action).unwrap();
1128        assert!(matches!(verdict, Verdict::RequireApproval { .. }));
1129    }
1130}