1use 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 #[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 #[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 let norm_tool = crate::normalize::normalize_full(&action.tool);
85 let norm_func = crate::normalize::normalize_full(&action.function);
86
87 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 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 break;
129 }
130 }
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 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 #[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 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 fn collect_candidate_indices_normalized(&self, norm_tool: &str) -> Vec<usize> {
271 if self.tool_index.is_empty() && self.always_check.is_empty() {
272 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 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 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 if let Some(denial) = self.check_path_rules(action, cp) {
334 return Ok((Some(denial), Vec::new()));
335 }
336 if let Some(denial) = self.check_network_rules(action, cp) {
338 return Ok((Some(denial), Vec::new()));
339 }
340 if let Some(denial) = self.check_ip_rules(action, cp) {
343 return Ok((Some(denial), Vec::new()));
344 }
345 if !cp.context_conditions.is_empty() {
348 match context {
349 Some(ctx) => {
350 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 _ => {
380 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 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 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 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 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 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(¶m_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 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 }
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 if regex.is_match(s) {
577 return true;
578 }
579 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 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 }
601 }
602 CompiledConstraint::DomainNotIn { patterns, .. } => {
603 if let Some(s) = value.as_str() {
604 let domain = Self::extract_domain(s);
605 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 _ => "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 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 const MAX_JSON_DEPTH_LIMIT: usize = 128;
694
695 const MAX_JSON_DEPTH_NODES: usize = 10_000;
698
699 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 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); if depth > max_depth {
715 max_depth = depth;
716 }
717 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 #[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 #[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 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 #[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 #[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 #[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 #[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); }
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 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}