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 (R237-ENG-2): Normalize path input before regex matching.
575                    let normalized =
576                        Self::normalize_path_bounded(s, self.max_path_decode_iterations)
577                            .unwrap_or_else(|_| s.to_string());
578                    regex.is_match(&normalized)
579                } else {
580                    true
581                }
582            }
583            CompiledConstraint::DomainMatch { pattern, .. } => {
584                if let Some(s) = value.as_str() {
585                    let domain = Self::extract_domain(s);
586                    // SECURITY (R34-ENG-1): IDNA fail-closed guard matching compiled path.
587                    // If domain contains non-ASCII and cannot be normalized, treat as match
588                    // (fail-closed: deny) to prevent bypass via unnormalizable domains.
589                    if !domain.is_ascii() && Self::normalize_domain_for_match(&domain).is_none() {
590                        return true;
591                    }
592                    Self::match_domain_pattern(&domain, pattern)
593                } else {
594                    true // non-string → fail-closed
595                }
596            }
597            CompiledConstraint::DomainNotIn { patterns, .. } => {
598                if let Some(s) = value.as_str() {
599                    let domain = Self::extract_domain(s);
600                    // SECURITY (R34-ENG-1): IDNA fail-closed for DomainNotIn.
601                    // If domain contains non-ASCII and cannot be normalized, it cannot
602                    // be in the allowlist — constraint fires (fail-closed).
603                    if !domain.is_ascii() && Self::normalize_domain_for_match(&domain).is_none() {
604                        return true;
605                    }
606                    !patterns
607                        .iter()
608                        .any(|p| Self::match_domain_pattern(&domain, p))
609                } else {
610                    true
611                }
612            }
613            CompiledConstraint::Eq {
614                value: expected, ..
615            } => value == expected,
616            CompiledConstraint::Ne {
617                value: expected, ..
618            } => value != expected,
619            CompiledConstraint::OneOf { values, .. } => values.contains(value),
620            CompiledConstraint::NoneOf { values, .. } => !values.contains(value),
621        }
622    }
623
624    fn policy_type_str(pt: &PolicyType) -> String {
625        match pt {
626            PolicyType::Allow => "allow".to_string(),
627            PolicyType::Deny => "deny".to_string(),
628            PolicyType::Conditional { .. } => "conditional".to_string(),
629            // Handle future variants
630            _ => "unknown".to_string(),
631        }
632    }
633
634    fn constraint_type_str(c: &CompiledConstraint) -> String {
635        match c {
636            CompiledConstraint::Glob { .. } => "glob".to_string(),
637            CompiledConstraint::NotGlob { .. } => "not_glob".to_string(),
638            CompiledConstraint::Regex { .. } => "regex".to_string(),
639            CompiledConstraint::DomainMatch { .. } => "domain_match".to_string(),
640            CompiledConstraint::DomainNotIn { .. } => "domain_not_in".to_string(),
641            CompiledConstraint::Eq { .. } => "eq".to_string(),
642            CompiledConstraint::Ne { .. } => "ne".to_string(),
643            CompiledConstraint::OneOf { .. } => "one_of".to_string(),
644            CompiledConstraint::NoneOf { .. } => "none_of".to_string(),
645        }
646    }
647
648    fn constraint_expected_str(c: &CompiledConstraint) -> String {
649        match c {
650            CompiledConstraint::Glob { pattern_str, .. } => {
651                format!("matches glob '{pattern_str}'")
652            }
653            CompiledConstraint::NotGlob { matchers, .. } => {
654                let pats: Vec<&str> = matchers.iter().map(|(s, _)| s.as_str()).collect();
655                format!("not in [{}]", pats.join(", "))
656            }
657            CompiledConstraint::Regex { pattern_str, .. } => {
658                format!("matches regex '{pattern_str}'")
659            }
660            CompiledConstraint::DomainMatch { pattern, .. } => {
661                format!("domain matches '{pattern}'")
662            }
663            CompiledConstraint::DomainNotIn { patterns, .. } => {
664                format!("domain not in [{}]", patterns.join(", "))
665            }
666            CompiledConstraint::Eq { value, .. } => format!("equals {value}"),
667            CompiledConstraint::Ne { value, .. } => format!("not equal {value}"),
668            CompiledConstraint::OneOf { values, .. } => format!("one of {values:?}"),
669            CompiledConstraint::NoneOf { values, .. } => format!("none of {values:?}"),
670        }
671    }
672
673    /// Describe a JSON value's type and size without exposing raw content.
674    /// Used in trace output to give useful debugging info without leaking secrets.
675    pub(crate) fn describe_value(value: &serde_json::Value) -> String {
676        match value {
677            serde_json::Value::Null => "null".to_string(),
678            serde_json::Value::Bool(b) => format!("bool({b})"),
679            serde_json::Value::Number(n) => format!("number({n})"),
680            serde_json::Value::String(s) => format!("string({} chars)", s.len()),
681            serde_json::Value::Array(arr) => format!("array({} items)", arr.len()),
682            serde_json::Value::Object(obj) => format!("object({} keys)", obj.len()),
683        }
684    }
685
686    /// Maximum nesting depth limit for JSON depth calculation.
687    /// Returns this limit when exceeded rather than continuing traversal.
688    const MAX_JSON_DEPTH_LIMIT: usize = 128;
689
690    /// Maximum number of nodes to visit during JSON depth calculation.
691    /// Prevents OOM from extremely wide JSON (e.g., objects with 100K keys).
692    const MAX_JSON_DEPTH_NODES: usize = 10_000;
693
694    /// Calculate the nesting depth of a JSON value using an iterative approach.
695    /// Avoids stack overflow on adversarially deep JSON (e.g., 10,000+ levels).
696    ///
697    /// SECURITY (FIND-R46-005): Bounded by [`MAX_JSON_DEPTH_LIMIT`] (128) for
698    /// depth and [`MAX_JSON_DEPTH_NODES`] (10,000) for total nodes visited.
699    /// Returns the depth limit when either bound is exceeded, ensuring the
700    /// caller's depth check triggers a reject.
701    pub(crate) fn json_depth(value: &serde_json::Value) -> usize {
702        let mut max_depth: usize = 0;
703        let mut nodes_visited: usize = 0;
704        // Stack of (value, current_depth) to process iteratively
705        let mut stack: Vec<(&serde_json::Value, usize)> = vec![(value, 0)];
706
707        while let Some((val, depth)) = stack.pop() {
708            nodes_visited = nodes_visited.saturating_add(1); // FIND-R58-ENG-007: Trap 9
709            if depth > max_depth {
710                max_depth = depth;
711            }
712            // Early termination: if we exceed depth limit or node budget, stop
713            if max_depth > Self::MAX_JSON_DEPTH_LIMIT || nodes_visited > Self::MAX_JSON_DEPTH_NODES
714            {
715                return max_depth;
716            }
717            match val {
718                serde_json::Value::Array(arr) => {
719                    for item in arr {
720                        stack.push((item, depth + 1));
721                    }
722                }
723                serde_json::Value::Object(obj) => {
724                    for item in obj.values() {
725                        stack.push((item, depth + 1));
726                    }
727                }
728                _ => {}
729            }
730        }
731
732        max_depth
733    }
734}
735
736#[cfg(test)]
737mod tests {
738    use super::*;
739    use serde_json::json;
740
741    // ---- describe_value tests ----
742
743    #[test]
744    fn test_describe_value_null() {
745        assert_eq!(PolicyEngine::describe_value(&json!(null)), "null");
746    }
747
748    #[test]
749    fn test_describe_value_bool_true() {
750        assert_eq!(PolicyEngine::describe_value(&json!(true)), "bool(true)");
751    }
752
753    #[test]
754    fn test_describe_value_bool_false() {
755        assert_eq!(PolicyEngine::describe_value(&json!(false)), "bool(false)");
756    }
757
758    #[test]
759    fn test_describe_value_number_integer() {
760        assert_eq!(PolicyEngine::describe_value(&json!(42)), "number(42)");
761    }
762
763    #[test]
764    fn test_describe_value_number_float() {
765        assert_eq!(PolicyEngine::describe_value(&json!(2.71)), "number(2.71)");
766    }
767
768    #[test]
769    fn test_describe_value_string_empty() {
770        assert_eq!(PolicyEngine::describe_value(&json!("")), "string(0 chars)");
771    }
772
773    #[test]
774    fn test_describe_value_string_nonempty() {
775        assert_eq!(
776            PolicyEngine::describe_value(&json!("hello")),
777            "string(5 chars)"
778        );
779    }
780
781    #[test]
782    fn test_describe_value_array_empty() {
783        assert_eq!(PolicyEngine::describe_value(&json!([])), "array(0 items)");
784    }
785
786    #[test]
787    fn test_describe_value_array_nonempty() {
788        assert_eq!(
789            PolicyEngine::describe_value(&json!([1, 2, 3])),
790            "array(3 items)"
791        );
792    }
793
794    #[test]
795    fn test_describe_value_object_empty() {
796        assert_eq!(PolicyEngine::describe_value(&json!({})), "object(0 keys)");
797    }
798
799    #[test]
800    fn test_describe_value_object_nonempty() {
801        assert_eq!(
802            PolicyEngine::describe_value(&json!({"a": 1, "b": 2})),
803            "object(2 keys)"
804        );
805    }
806
807    // ---- json_depth tests ----
808
809    #[test]
810    fn test_json_depth_scalar_zero() {
811        assert_eq!(PolicyEngine::json_depth(&json!(42)), 0);
812        assert_eq!(PolicyEngine::json_depth(&json!("hello")), 0);
813        assert_eq!(PolicyEngine::json_depth(&json!(null)), 0);
814        assert_eq!(PolicyEngine::json_depth(&json!(true)), 0);
815    }
816
817    #[test]
818    fn test_json_depth_flat_array() {
819        assert_eq!(PolicyEngine::json_depth(&json!([1, 2, 3])), 1);
820    }
821
822    #[test]
823    fn test_json_depth_flat_object() {
824        assert_eq!(PolicyEngine::json_depth(&json!({"a": 1, "b": 2})), 1);
825    }
826
827    #[test]
828    fn test_json_depth_nested_objects() {
829        let nested = json!({"a": {"b": {"c": 1}}});
830        assert_eq!(PolicyEngine::json_depth(&nested), 3);
831    }
832
833    #[test]
834    fn test_json_depth_nested_arrays() {
835        let nested = json!([[[1]]]);
836        assert_eq!(PolicyEngine::json_depth(&nested), 3);
837    }
838
839    #[test]
840    fn test_json_depth_mixed_nesting() {
841        let mixed = json!({"a": [{"b": [1]}]});
842        assert_eq!(PolicyEngine::json_depth(&mixed), 4);
843    }
844
845    #[test]
846    fn test_json_depth_empty_containers() {
847        assert_eq!(PolicyEngine::json_depth(&json!([])), 0);
848        assert_eq!(PolicyEngine::json_depth(&json!({})), 0);
849    }
850
851    #[test]
852    fn test_json_depth_wide_object() {
853        // A wide but shallow object (many keys at depth 1)
854        let mut obj = serde_json::Map::new();
855        for i in 0..100 {
856            obj.insert(format!("key_{i}"), json!(i));
857        }
858        let value = serde_json::Value::Object(obj);
859        assert_eq!(PolicyEngine::json_depth(&value), 1);
860    }
861
862    // ---- policy_type_str tests ----
863
864    #[test]
865    fn test_policy_type_str_allow() {
866        assert_eq!(PolicyEngine::policy_type_str(&PolicyType::Allow), "allow");
867    }
868
869    #[test]
870    fn test_policy_type_str_deny() {
871        assert_eq!(PolicyEngine::policy_type_str(&PolicyType::Deny), "deny");
872    }
873
874    #[test]
875    fn test_policy_type_str_conditional() {
876        let pt = PolicyType::Conditional {
877            conditions: json!({}),
878        };
879        assert_eq!(PolicyEngine::policy_type_str(&pt), "conditional");
880    }
881
882    // ---- constraint_type_str tests ----
883
884    #[test]
885    fn test_constraint_type_str_all_variants() {
886        use crate::compiled::CompiledConstraint;
887
888        let glob = globset::GlobBuilder::new("*.txt")
889            .literal_separator(true)
890            .build()
891            .unwrap()
892            .compile_matcher();
893        let c = CompiledConstraint::Glob {
894            param: "p".to_string(),
895            matcher: glob,
896            pattern_str: "*.txt".to_string(),
897            on_match: "deny".to_string(),
898            on_missing: "skip".to_string(),
899        };
900        assert_eq!(PolicyEngine::constraint_type_str(&c), "glob");
901
902        let c = CompiledConstraint::Eq {
903            param: "p".to_string(),
904            value: json!(1),
905            on_match: "deny".to_string(),
906            on_missing: "deny".to_string(),
907        };
908        assert_eq!(PolicyEngine::constraint_type_str(&c), "eq");
909
910        let c = CompiledConstraint::Ne {
911            param: "p".to_string(),
912            value: json!(1),
913            on_match: "deny".to_string(),
914            on_missing: "deny".to_string(),
915        };
916        assert_eq!(PolicyEngine::constraint_type_str(&c), "ne");
917
918        let c = CompiledConstraint::OneOf {
919            param: "p".to_string(),
920            values: vec![],
921            on_match: "deny".to_string(),
922            on_missing: "deny".to_string(),
923        };
924        assert_eq!(PolicyEngine::constraint_type_str(&c), "one_of");
925
926        let c = CompiledConstraint::NoneOf {
927            param: "p".to_string(),
928            values: vec![],
929            on_match: "deny".to_string(),
930            on_missing: "deny".to_string(),
931        };
932        assert_eq!(PolicyEngine::constraint_type_str(&c), "none_of");
933
934        let c = CompiledConstraint::DomainMatch {
935            param: "p".to_string(),
936            pattern: "example.com".to_string(),
937            on_match: "deny".to_string(),
938            on_missing: "deny".to_string(),
939        };
940        assert_eq!(PolicyEngine::constraint_type_str(&c), "domain_match");
941
942        let c = CompiledConstraint::DomainNotIn {
943            param: "p".to_string(),
944            patterns: vec![],
945            on_match: "deny".to_string(),
946            on_missing: "deny".to_string(),
947        };
948        assert_eq!(PolicyEngine::constraint_type_str(&c), "domain_not_in");
949
950        let c = CompiledConstraint::Regex {
951            param: "p".to_string(),
952            regex: regex::Regex::new(".*").unwrap(),
953            pattern_str: ".*".to_string(),
954            on_match: "deny".to_string(),
955            on_missing: "deny".to_string(),
956        };
957        assert_eq!(PolicyEngine::constraint_type_str(&c), "regex");
958
959        let c = CompiledConstraint::NotGlob {
960            param: "p".to_string(),
961            matchers: vec![],
962            on_match: "deny".to_string(),
963            on_missing: "deny".to_string(),
964        };
965        assert_eq!(PolicyEngine::constraint_type_str(&c), "not_glob");
966    }
967
968    // ---- constraint_expected_str tests ----
969
970    #[test]
971    fn test_constraint_expected_str_eq() {
972        let c = CompiledConstraint::Eq {
973            param: "p".to_string(),
974            value: json!("hello"),
975            on_match: "deny".to_string(),
976            on_missing: "deny".to_string(),
977        };
978        assert_eq!(
979            PolicyEngine::constraint_expected_str(&c),
980            "equals \"hello\""
981        );
982    }
983
984    #[test]
985    fn test_constraint_expected_str_ne() {
986        let c = CompiledConstraint::Ne {
987            param: "p".to_string(),
988            value: json!(42),
989            on_match: "deny".to_string(),
990            on_missing: "deny".to_string(),
991        };
992        assert_eq!(PolicyEngine::constraint_expected_str(&c), "not equal 42");
993    }
994
995    #[test]
996    fn test_constraint_expected_str_domain_match() {
997        let c = CompiledConstraint::DomainMatch {
998            param: "url".to_string(),
999            pattern: "*.evil.com".to_string(),
1000            on_match: "deny".to_string(),
1001            on_missing: "deny".to_string(),
1002        };
1003        assert_eq!(
1004            PolicyEngine::constraint_expected_str(&c),
1005            "domain matches '*.evil.com'"
1006        );
1007    }
1008
1009    #[test]
1010    fn test_constraint_expected_str_domain_not_in() {
1011        let c = CompiledConstraint::DomainNotIn {
1012            param: "url".to_string(),
1013            patterns: vec!["a.com".to_string(), "b.com".to_string()],
1014            on_match: "deny".to_string(),
1015            on_missing: "deny".to_string(),
1016        };
1017        assert_eq!(
1018            PolicyEngine::constraint_expected_str(&c),
1019            "domain not in [a.com, b.com]"
1020        );
1021    }
1022
1023    // ---- evaluate_action_traced tests ----
1024
1025    #[test]
1026    fn test_evaluate_action_traced_no_policies_deny() {
1027        let engine = PolicyEngine::new(false);
1028        let action = Action::new("tool", "func", json!({}));
1029        let (verdict, trace) = engine.evaluate_action_traced(&action).unwrap();
1030        assert!(matches!(verdict, Verdict::Deny { .. }));
1031        assert_eq!(trace.policies_checked, 0);
1032        assert_eq!(trace.policies_matched, 0);
1033        assert!(matches!(trace.verdict, Verdict::Deny { .. }));
1034    }
1035
1036    #[test]
1037    fn test_evaluate_action_traced_allow_policy() {
1038        let policies = vec![Policy {
1039            id: "*".to_string(),
1040            name: "allow-all".to_string(),
1041            policy_type: PolicyType::Allow,
1042            priority: 100,
1043            path_rules: None,
1044            network_rules: None,
1045        }];
1046        let engine = PolicyEngine::with_policies(false, &policies).unwrap();
1047        let action = Action::new("tool", "func", json!({}));
1048        let (verdict, trace) = engine.evaluate_action_traced(&action).unwrap();
1049        assert!(matches!(verdict, Verdict::Allow));
1050        assert!(trace.policies_checked >= 1);
1051        assert!(trace.policies_matched >= 1);
1052        assert!(trace.duration_us < 10_000_000); // sanity: should complete in <10s
1053    }
1054
1055    #[test]
1056    fn test_evaluate_action_traced_deny_policy() {
1057        let policies = vec![Policy {
1058            id: "bash:*".to_string(),
1059            name: "block-bash".to_string(),
1060            policy_type: PolicyType::Deny,
1061            priority: 100,
1062            path_rules: None,
1063            network_rules: None,
1064        }];
1065        let engine = PolicyEngine::with_policies(false, &policies).unwrap();
1066        let action = Action::new("bash", "execute", json!({}));
1067        let (verdict, trace) = engine.evaluate_action_traced(&action).unwrap();
1068        assert!(matches!(verdict, Verdict::Deny { .. }));
1069        assert_eq!(trace.action_summary.tool, "bash");
1070        assert_eq!(trace.action_summary.function, "execute");
1071    }
1072
1073    #[test]
1074    fn test_evaluate_action_traced_action_summary_param_count() {
1075        let policies = vec![Policy {
1076            id: "*".to_string(),
1077            name: "allow-all".to_string(),
1078            policy_type: PolicyType::Allow,
1079            priority: 100,
1080            path_rules: None,
1081            network_rules: None,
1082        }];
1083        let engine = PolicyEngine::with_policies(false, &policies).unwrap();
1084        let action = Action::new("tool", "func", json!({"a": 1, "b": 2, "c": 3}));
1085        let (_, trace) = engine.evaluate_action_traced(&action).unwrap();
1086        assert_eq!(trace.action_summary.param_count, 3);
1087        assert_eq!(trace.action_summary.param_keys.len(), 3);
1088    }
1089
1090    #[test]
1091    fn test_evaluate_action_traced_no_match_deny() {
1092        let policies = vec![Policy {
1093            id: "other_tool:*".to_string(),
1094            name: "allow-other".to_string(),
1095            policy_type: PolicyType::Allow,
1096            priority: 100,
1097            path_rules: None,
1098            network_rules: None,
1099        }];
1100        let engine = PolicyEngine::with_policies(false, &policies).unwrap();
1101        let action = Action::new("bash", "execute", json!({}));
1102        let (verdict, trace) = engine.evaluate_action_traced(&action).unwrap();
1103        // No matching policy -> deny
1104        assert!(matches!(verdict, Verdict::Deny { .. }));
1105        assert_eq!(trace.policies_matched, 0);
1106    }
1107
1108    #[test]
1109    fn test_evaluate_action_traced_conditional_require_approval() {
1110        let policies = vec![Policy {
1111            id: "network:*".to_string(),
1112            name: "net-approval".to_string(),
1113            policy_type: PolicyType::Conditional {
1114                conditions: json!({ "require_approval": true }),
1115            },
1116            priority: 100,
1117            path_rules: None,
1118            network_rules: None,
1119        }];
1120        let engine = PolicyEngine::with_policies(false, &policies).unwrap();
1121        let action = Action::new("network", "connect", json!({}));
1122        let (verdict, _trace) = engine.evaluate_action_traced(&action).unwrap();
1123        assert!(matches!(verdict, Verdict::RequireApproval { .. }));
1124    }
1125}