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: start.elapsed().as_micros() as u64,
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: start.elapsed().as_micros() as u64,
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: start.elapsed().as_micros() as u64,
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: start.elapsed().as_micros() as u64,
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: start.elapsed().as_micros() as u64,
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: start.elapsed().as_micros() as u64,
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 _ => Ok((
380 Some(Verdict::Deny {
381 reason: format!("Unknown policy type for '{}'", cp.policy.name),
382 }),
383 Vec::new(),
384 )),
385 }
386 }
387
388 fn evaluate_compiled_conditions_traced(
392 &self,
393 action: &Action,
394 cp: &CompiledPolicy,
395 ) -> Result<(Option<Verdict>, Vec<ConstraintResult>), EngineError> {
396 let mut results = Some(Vec::with_capacity(cp.constraints.len()));
397 let verdict = self.evaluate_compiled_conditions_core(action, cp, &mut results)?;
398 Ok((verdict, results.unwrap_or_default()))
399 }
400
401 pub(crate) fn evaluate_compiled_constraint_traced(
403 &self,
404 action: &Action,
405 policy: &Policy,
406 constraint: &CompiledConstraint,
407 ) -> Result<(Option<Verdict>, Vec<ConstraintResult>), EngineError> {
408 let param_name = constraint.param();
409 let on_match = constraint.on_match();
410 let on_missing = constraint.on_missing();
411
412 if param_name == "*" {
414 let all_values = Self::collect_all_string_values(&action.parameters);
415 let mut results = Vec::with_capacity(all_values.len());
416 if all_values.is_empty() {
417 if on_missing == "skip" {
418 return Ok((None, Vec::new()));
419 }
420 results.push(ConstraintResult {
421 constraint_type: Self::constraint_type_str(constraint),
422 param: "*".to_string(),
423 expected: "any string values".to_string(),
424 actual: "none found".to_string(),
425 passed: false,
426 });
427 let verdict = Self::make_constraint_verdict(
428 "deny",
429 &format!(
430 "No string values found in parameters (fail-closed) in policy '{}'",
431 policy.name
432 ),
433 )?;
434 return Ok((Some(verdict), results));
435 }
436 for (value_path, value_str) in &all_values {
437 let json_val = serde_json::Value::String((*value_str).to_string());
438 let matched = self.constraint_matches_value(&json_val, constraint);
439 results.push(ConstraintResult {
440 constraint_type: Self::constraint_type_str(constraint),
441 param: value_path.clone(),
442 expected: Self::constraint_expected_str(constraint),
443 actual: value_str.to_string(),
444 passed: !matched,
445 });
446 if matched {
447 let verdict = Self::make_constraint_verdict(
448 on_match,
449 &format!(
450 "Parameter '{}' value triggered constraint (policy '{}')",
451 value_path, policy.name
452 ),
453 )?;
454 return Ok((Some(verdict), results));
455 }
456 }
457 return Ok((None, results));
458 }
459
460 let param_value = match Self::get_param_by_path(&action.parameters, param_name) {
462 Some(v) => v,
463 None => {
464 if on_missing == "skip" {
465 return Ok((
466 None,
467 vec![ConstraintResult {
468 constraint_type: Self::constraint_type_str(constraint),
469 param: param_name.to_string(),
470 expected: Self::constraint_expected_str(constraint),
471 actual: "missing".to_string(),
472 passed: true,
473 }],
474 ));
475 }
476 let verdict = Self::make_constraint_verdict(
477 "deny",
478 &format!(
479 "Parameter '{}' missing (fail-closed) in policy '{}'",
480 param_name, policy.name
481 ),
482 )?;
483 return Ok((
484 Some(verdict),
485 vec![ConstraintResult {
486 constraint_type: Self::constraint_type_str(constraint),
487 param: param_name.to_string(),
488 expected: Self::constraint_expected_str(constraint),
489 actual: "missing".to_string(),
490 passed: false,
491 }],
492 ));
493 }
494 };
495
496 let matched = self.constraint_matches_value(param_value, constraint);
497 let actual_str = param_value
498 .as_str()
499 .unwrap_or(¶m_value.to_string())
500 .to_string();
501 let result = ConstraintResult {
502 constraint_type: Self::constraint_type_str(constraint),
503 param: param_name.to_string(),
504 expected: Self::constraint_expected_str(constraint),
505 actual: actual_str,
506 passed: !matched,
507 };
508
509 if matched {
510 let verdict = self.evaluate_compiled_constraint_value(
511 policy,
512 param_name,
513 on_match,
514 param_value,
515 constraint,
516 )?;
517 Ok((verdict, vec![result]))
518 } else {
519 Ok((None, vec![result]))
520 }
521 }
522
523 fn constraint_matches_value(
525 &self,
526 value: &serde_json::Value,
527 constraint: &CompiledConstraint,
528 ) -> bool {
529 match constraint {
530 CompiledConstraint::Glob { matcher, .. } => {
531 if let Some(s) = value.as_str() {
532 match Self::normalize_path_bounded(s, self.max_path_decode_iterations) {
533 Ok(ref normalized) => matcher.is_match(normalized),
534 Err(_) => true,
535 }
536 } else {
537 true }
539 }
540 CompiledConstraint::NotGlob { matchers, .. } => {
541 if let Some(s) = value.as_str() {
542 match Self::normalize_path_bounded(s, self.max_path_decode_iterations) {
543 Ok(ref normalized) => !matchers.iter().any(|(_, m)| m.is_match(normalized)),
544 Err(_) => true,
545 }
546 } else {
547 true
548 }
549 }
550 CompiledConstraint::Regex { regex, .. } => {
551 if let Some(s) = value.as_str() {
552 regex.is_match(s)
553 } else {
554 true
555 }
556 }
557 CompiledConstraint::DomainMatch { pattern, .. } => {
558 if let Some(s) = value.as_str() {
559 let domain = Self::extract_domain(s);
560 if !domain.is_ascii() && Self::normalize_domain_for_match(&domain).is_none() {
564 return true;
565 }
566 Self::match_domain_pattern(&domain, pattern)
567 } else {
568 true }
570 }
571 CompiledConstraint::DomainNotIn { patterns, .. } => {
572 if let Some(s) = value.as_str() {
573 let domain = Self::extract_domain(s);
574 if !domain.is_ascii() && Self::normalize_domain_for_match(&domain).is_none() {
578 return true;
579 }
580 !patterns
581 .iter()
582 .any(|p| Self::match_domain_pattern(&domain, p))
583 } else {
584 true
585 }
586 }
587 CompiledConstraint::Eq {
588 value: expected, ..
589 } => value == expected,
590 CompiledConstraint::Ne {
591 value: expected, ..
592 } => value != expected,
593 CompiledConstraint::OneOf { values, .. } => values.contains(value),
594 CompiledConstraint::NoneOf { values, .. } => !values.contains(value),
595 }
596 }
597
598 fn policy_type_str(pt: &PolicyType) -> String {
599 match pt {
600 PolicyType::Allow => "allow".to_string(),
601 PolicyType::Deny => "deny".to_string(),
602 PolicyType::Conditional { .. } => "conditional".to_string(),
603 _ => "unknown".to_string(),
605 }
606 }
607
608 fn constraint_type_str(c: &CompiledConstraint) -> String {
609 match c {
610 CompiledConstraint::Glob { .. } => "glob".to_string(),
611 CompiledConstraint::NotGlob { .. } => "not_glob".to_string(),
612 CompiledConstraint::Regex { .. } => "regex".to_string(),
613 CompiledConstraint::DomainMatch { .. } => "domain_match".to_string(),
614 CompiledConstraint::DomainNotIn { .. } => "domain_not_in".to_string(),
615 CompiledConstraint::Eq { .. } => "eq".to_string(),
616 CompiledConstraint::Ne { .. } => "ne".to_string(),
617 CompiledConstraint::OneOf { .. } => "one_of".to_string(),
618 CompiledConstraint::NoneOf { .. } => "none_of".to_string(),
619 }
620 }
621
622 fn constraint_expected_str(c: &CompiledConstraint) -> String {
623 match c {
624 CompiledConstraint::Glob { pattern_str, .. } => {
625 format!("matches glob '{}'", pattern_str)
626 }
627 CompiledConstraint::NotGlob { matchers, .. } => {
628 let pats: Vec<&str> = matchers.iter().map(|(s, _)| s.as_str()).collect();
629 format!("not in [{}]", pats.join(", "))
630 }
631 CompiledConstraint::Regex { pattern_str, .. } => {
632 format!("matches regex '{}'", pattern_str)
633 }
634 CompiledConstraint::DomainMatch { pattern, .. } => {
635 format!("domain matches '{}'", pattern)
636 }
637 CompiledConstraint::DomainNotIn { patterns, .. } => {
638 format!("domain not in [{}]", patterns.join(", "))
639 }
640 CompiledConstraint::Eq { value, .. } => format!("equals {}", value),
641 CompiledConstraint::Ne { value, .. } => format!("not equal {}", value),
642 CompiledConstraint::OneOf { values, .. } => format!("one of {:?}", values),
643 CompiledConstraint::NoneOf { values, .. } => format!("none of {:?}", values),
644 }
645 }
646
647 pub(crate) fn describe_value(value: &serde_json::Value) -> String {
650 match value {
651 serde_json::Value::Null => "null".to_string(),
652 serde_json::Value::Bool(b) => format!("bool({})", b),
653 serde_json::Value::Number(n) => format!("number({})", n),
654 serde_json::Value::String(s) => format!("string({} chars)", s.len()),
655 serde_json::Value::Array(arr) => format!("array({} items)", arr.len()),
656 serde_json::Value::Object(obj) => format!("object({} keys)", obj.len()),
657 }
658 }
659
660 const MAX_JSON_DEPTH_LIMIT: usize = 128;
663
664 const MAX_JSON_DEPTH_NODES: usize = 10_000;
667
668 pub(crate) fn json_depth(value: &serde_json::Value) -> usize {
676 let mut max_depth: usize = 0;
677 let mut nodes_visited: usize = 0;
678 let mut stack: Vec<(&serde_json::Value, usize)> = vec![(value, 0)];
680
681 while let Some((val, depth)) = stack.pop() {
682 nodes_visited = nodes_visited.saturating_add(1); if depth > max_depth {
684 max_depth = depth;
685 }
686 if max_depth > Self::MAX_JSON_DEPTH_LIMIT || nodes_visited > Self::MAX_JSON_DEPTH_NODES
688 {
689 return max_depth;
690 }
691 match val {
692 serde_json::Value::Array(arr) => {
693 for item in arr {
694 stack.push((item, depth + 1));
695 }
696 }
697 serde_json::Value::Object(obj) => {
698 for item in obj.values() {
699 stack.push((item, depth + 1));
700 }
701 }
702 _ => {}
703 }
704 }
705
706 max_depth
707 }
708}