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 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 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 }
596 }
597 CompiledConstraint::DomainNotIn { patterns, .. } => {
598 if let Some(s) = value.as_str() {
599 let domain = Self::extract_domain(s);
600 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 _ => "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 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 const MAX_JSON_DEPTH_LIMIT: usize = 128;
689
690 const MAX_JSON_DEPTH_NODES: usize = 10_000;
693
694 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 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); if depth > max_depth {
710 max_depth = depth;
711 }
712 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 #[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 #[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 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 #[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 #[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 #[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 #[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); }
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 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}