1use crate::compiled::{
15 CompiledConditions, CompiledConstraint, CompiledContextCondition, CompiledIpRules,
16 CompiledNetworkRules, CompiledPathRules, CompiledPolicy,
17};
18use crate::error::PolicyValidationError;
19use crate::matcher::{CompiledToolMatcher, PatternMatcher};
20use crate::normalize::normalize_full;
21use crate::PolicyEngine;
22use globset::Glob;
23use ipnet::IpNet;
24use vellaveto_types::{Policy, PolicyType, MAX_CONDITIONS_SIZE};
25
26impl PolicyEngine {
27 pub fn compile_policies(
32 policies: &[Policy],
33 strict_mode: bool,
34 ) -> Result<Vec<CompiledPolicy>, Vec<PolicyValidationError>> {
35 let mut compiled = Vec::with_capacity(policies.len());
36 let mut errors = Vec::new();
37
38 for policy in policies {
39 match Self::compile_single_policy(policy, strict_mode) {
40 Ok(cp) => compiled.push(cp),
41 Err(e) => errors.push(e),
42 }
43 }
44
45 if !errors.is_empty() {
46 return Err(errors);
47 }
48
49 compiled.sort_by(|a, b| {
51 let pri = b.policy.priority.cmp(&a.policy.priority);
52 if pri != std::cmp::Ordering::Equal {
53 return pri;
54 }
55 let a_deny = matches!(a.policy.policy_type, PolicyType::Deny);
56 let b_deny = matches!(b.policy.policy_type, PolicyType::Deny);
57 let deny_ord = b_deny.cmp(&a_deny);
58 if deny_ord != std::cmp::Ordering::Equal {
59 return deny_ord;
60 }
61 a.policy.id.cmp(&b.policy.id)
62 });
63 Ok(compiled)
64 }
65
66 fn compile_single_policy(
68 policy: &Policy,
69 strict_mode: bool,
70 ) -> Result<CompiledPolicy, PolicyValidationError> {
71 const MAX_POLICY_NAME_LEN: usize = 512;
76 if policy.name.len() > MAX_POLICY_NAME_LEN {
77 return Err(PolicyValidationError {
78 policy_id: policy.id.clone(),
79 policy_name: policy.name.clone(),
80 reason: format!(
81 "Policy name is {} bytes, max is {}",
82 policy.name.len(),
83 MAX_POLICY_NAME_LEN
84 ),
85 });
86 }
87
88 let tool_matcher = CompiledToolMatcher::compile(&policy.id);
89
90 let CompiledConditions {
91 require_approval,
92 forbidden_parameters,
93 required_parameters,
94 constraints,
95 on_no_match_continue,
96 context_conditions,
97 } = match &policy.policy_type {
98 PolicyType::Allow | PolicyType::Deny => CompiledConditions {
99 require_approval: false,
100 forbidden_parameters: Vec::new(),
101 required_parameters: Vec::new(),
102 constraints: Vec::new(),
103 on_no_match_continue: false,
104 context_conditions: Vec::new(),
105 },
106 PolicyType::Conditional { conditions } => {
107 Self::compile_conditions(policy, conditions, strict_mode)?
108 }
109 _ => {
113 return Err(PolicyValidationError {
114 policy_id: policy.id.clone(),
115 policy_name: policy.name.clone(),
116 reason: format!(
117 "Unknown policy type variant for policy '{}' — cannot compile",
118 policy.name
119 ),
120 });
121 }
122 };
123
124 let deny_reason = format!("Denied by policy '{}'", policy.name);
125 let approval_reason = format!("Approval required by policy '{}'", policy.name);
126 let forbidden_reasons = forbidden_parameters
127 .iter()
128 .map(|p| format!("Parameter '{}' is forbidden by policy '{}'", p, policy.name))
129 .collect();
130 let required_reasons = required_parameters
131 .iter()
132 .map(|p| {
133 format!(
134 "Required parameter '{}' missing (policy '{}')",
135 p, policy.name
136 )
137 })
138 .collect();
139
140 let compiled_path_rules = match policy.path_rules.as_ref() {
144 Some(pr) => {
145 let mut allowed = Vec::with_capacity(pr.allowed.len());
146 for pattern in &pr.allowed {
147 let g = Glob::new(pattern).map_err(|e| PolicyValidationError {
148 policy_id: policy.id.clone(),
149 policy_name: policy.name.clone(),
150 reason: format!("Invalid allowed path glob '{pattern}': {e}"),
151 })?;
152 allowed.push((pattern.clone(), g.compile_matcher()));
153 }
154 let mut blocked = Vec::with_capacity(pr.blocked.len());
155 for pattern in &pr.blocked {
156 let g = Glob::new(pattern).map_err(|e| PolicyValidationError {
157 policy_id: policy.id.clone(),
158 policy_name: policy.name.clone(),
159 reason: format!("Invalid blocked path glob '{pattern}': {e}"),
160 })?;
161 blocked.push((pattern.clone(), g.compile_matcher()));
162 }
163 Some(CompiledPathRules { allowed, blocked })
164 }
165 None => None,
166 };
167
168 let compiled_network_rules = match policy.network_rules.as_ref() {
171 Some(nr) => {
172 for domain in nr.allowed_domains.iter().chain(nr.blocked_domains.iter()) {
173 if let Err(reason) = Self::validate_domain_pattern(domain) {
174 return Err(PolicyValidationError {
175 policy_id: policy.id.clone(),
176 policy_name: policy.name.clone(),
177 reason: format!("Invalid domain pattern: {reason}"),
178 });
179 }
180 }
181 Some(CompiledNetworkRules {
182 allowed_domains: nr.allowed_domains.clone(),
183 blocked_domains: nr.blocked_domains.clone(),
184 })
185 }
186 None => None,
187 };
188
189 let compiled_ip_rules = match policy
191 .network_rules
192 .as_ref()
193 .and_then(|nr| nr.ip_rules.as_ref())
194 {
195 Some(ir) => {
196 let mut blocked_cidrs = Vec::with_capacity(ir.blocked_cidrs.len());
197 for cidr_str in &ir.blocked_cidrs {
198 let cidr: IpNet = cidr_str.parse().map_err(|e| PolicyValidationError {
199 policy_id: policy.id.clone(),
200 policy_name: policy.name.clone(),
201 reason: format!("Invalid blocked CIDR '{cidr_str}': {e}"),
202 })?;
203 blocked_cidrs.push(cidr);
204 }
205 let mut allowed_cidrs = Vec::with_capacity(ir.allowed_cidrs.len());
206 for cidr_str in &ir.allowed_cidrs {
207 let cidr: IpNet = cidr_str.parse().map_err(|e| PolicyValidationError {
208 policy_id: policy.id.clone(),
209 policy_name: policy.name.clone(),
210 reason: format!("Invalid allowed CIDR '{cidr_str}': {e}"),
211 })?;
212 allowed_cidrs.push(cidr);
213 }
214 Some(CompiledIpRules {
215 block_private: ir.block_private,
216 blocked_cidrs,
217 allowed_cidrs,
218 })
219 }
220 None => None,
221 };
222
223 Ok(CompiledPolicy {
224 policy: policy.clone(),
225 tool_matcher,
226 require_approval,
227 forbidden_parameters,
228 required_parameters,
229 constraints,
230 on_no_match_continue,
231 deny_reason,
232 approval_reason,
233 forbidden_reasons,
234 required_reasons,
235 compiled_path_rules,
236 compiled_network_rules,
237 compiled_ip_rules,
238 context_conditions,
239 })
240 }
241
242 fn compile_conditions(
244 policy: &Policy,
245 conditions: &serde_json::Value,
246 strict_mode: bool,
247 ) -> Result<CompiledConditions, PolicyValidationError> {
248 if Self::json_depth(conditions) > 10 {
250 return Err(PolicyValidationError {
251 policy_id: policy.id.clone(),
252 policy_name: policy.name.clone(),
253 reason: "Condition JSON exceeds maximum nesting depth of 10".to_string(),
254 });
255 }
256
257 let size = conditions.to_string().len();
264 if size > MAX_CONDITIONS_SIZE {
265 return Err(PolicyValidationError {
266 policy_id: policy.id.clone(),
267 policy_name: policy.name.clone(),
268 reason: format!(
269 "Condition JSON too large: {size} bytes (max {MAX_CONDITIONS_SIZE})"
270 ),
271 });
272 }
273
274 let require_approval = conditions
277 .get("require_approval")
278 .map(|v| v.as_bool().unwrap_or(true))
279 .unwrap_or(false);
280
281 let on_no_match_continue = conditions
282 .get("on_no_match")
283 .and_then(|v| v.as_str())
284 .map(|s| s == "continue")
285 .unwrap_or(false);
286
287 let forbidden_parameters: Vec<String> = conditions
288 .get("forbidden_parameters")
289 .and_then(|v| v.as_array())
290 .map(|arr| {
291 arr.iter()
292 .filter_map(|v| v.as_str().map(|s| s.to_string()))
293 .collect()
294 })
295 .unwrap_or_default();
296
297 let required_parameters: Vec<String> = conditions
298 .get("required_parameters")
299 .and_then(|v| v.as_array())
300 .map(|arr| {
301 arr.iter()
302 .filter_map(|v| v.as_str().map(|s| s.to_string()))
303 .collect()
304 })
305 .unwrap_or_default();
306
307 const MAX_PARAMETER_CONSTRAINTS: usize = 100;
309
310 let constraints = if let Some(constraint_arr) = conditions.get("parameter_constraints") {
311 let arr = constraint_arr
312 .as_array()
313 .ok_or_else(|| PolicyValidationError {
314 policy_id: policy.id.clone(),
315 policy_name: policy.name.clone(),
316 reason: "parameter_constraints must be an array".to_string(),
317 })?;
318
319 if arr.len() > MAX_PARAMETER_CONSTRAINTS {
322 return Err(PolicyValidationError {
323 policy_id: policy.id.clone(),
324 policy_name: policy.name.clone(),
325 reason: format!(
326 "parameter_constraints has {} entries, max is {}",
327 arr.len(),
328 MAX_PARAMETER_CONSTRAINTS
329 ),
330 });
331 }
332
333 let mut constraints = Vec::with_capacity(arr.len());
334 for constraint_val in arr {
335 constraints.push(Self::compile_constraint(policy, constraint_val)?);
336 }
337 constraints
338 } else {
339 Vec::new()
340 };
341
342 const MAX_CONTEXT_CONDITIONS: usize = 50;
344
345 let context_conditions = if let Some(ctx_arr) = conditions.get("context_conditions") {
347 let arr = ctx_arr.as_array().ok_or_else(|| PolicyValidationError {
348 policy_id: policy.id.clone(),
349 policy_name: policy.name.clone(),
350 reason: "context_conditions must be an array".to_string(),
351 })?;
352
353 if arr.len() > MAX_CONTEXT_CONDITIONS {
356 return Err(PolicyValidationError {
357 policy_id: policy.id.clone(),
358 policy_name: policy.name.clone(),
359 reason: format!(
360 "context_conditions has {} entries, max is {}",
361 arr.len(),
362 MAX_CONTEXT_CONDITIONS
363 ),
364 });
365 }
366
367 let mut context_conditions = Vec::with_capacity(arr.len());
368 for ctx_val in arr {
369 context_conditions.push(Self::compile_context_condition(policy, ctx_val)?);
370 }
371 context_conditions
372 } else {
373 Vec::new()
374 };
375
376 if strict_mode {
378 let known_keys = [
379 "require_approval",
380 "forbidden_parameters",
381 "required_parameters",
382 "parameter_constraints",
383 "context_conditions",
384 "on_no_match",
385 ];
386 if let Some(obj) = conditions.as_object() {
387 for key in obj.keys() {
388 if !known_keys.contains(&key.as_str()) {
389 return Err(PolicyValidationError {
390 policy_id: policy.id.clone(),
391 policy_name: policy.name.clone(),
392 reason: format!("Unknown condition key '{key}' in strict mode"),
393 });
394 }
395 }
396 }
397 }
398
399 let all_wildcard_params = constraints.iter().all(|c| c.param() == "*");
413 if on_no_match_continue
414 && !constraints.is_empty()
415 && constraints.iter().all(|c| c.on_missing() == "skip")
416 && forbidden_parameters.is_empty()
417 && required_parameters.is_empty()
418 && !all_wildcard_params
419 {
420 tracing::warn!(
421 policy_id = %policy.id,
422 policy_name = %policy.name,
423 constraint_count = constraints.len(),
424 "Conditional policy has on_no_match=\"continue\" with ALL constraints \
425 using on_missing=\"skip\" — an attacker can bypass this policy entirely \
426 by omitting the required parameters. Set at least one constraint to \
427 on_missing=\"deny\" or remove on_no_match=\"continue\"."
428 );
429 }
430
431 Ok(CompiledConditions {
432 require_approval,
433 forbidden_parameters,
434 required_parameters,
435 constraints,
436 on_no_match_continue,
437 context_conditions,
438 })
439 }
440
441 fn compile_constraint(
446 policy: &Policy,
447 constraint: &serde_json::Value,
448 ) -> Result<CompiledConstraint, PolicyValidationError> {
449 const MAX_CONSTRAINT_ELEMENTS: usize = 1000;
451 let obj = constraint
452 .as_object()
453 .ok_or_else(|| PolicyValidationError {
454 policy_id: policy.id.clone(),
455 policy_name: policy.name.clone(),
456 reason: "Each parameter constraint must be a JSON object".to_string(),
457 })?;
458
459 let param = obj
460 .get("param")
461 .and_then(|v| v.as_str())
462 .ok_or_else(|| PolicyValidationError {
463 policy_id: policy.id.clone(),
464 policy_name: policy.name.clone(),
465 reason: "Constraint missing required 'param' string field".to_string(),
466 })?
467 .to_string();
468
469 let op = obj
470 .get("op")
471 .and_then(|v| v.as_str())
472 .ok_or_else(|| PolicyValidationError {
473 policy_id: policy.id.clone(),
474 policy_name: policy.name.clone(),
475 reason: "Constraint missing required 'op' string field".to_string(),
476 })?;
477
478 let on_match = obj
479 .get("on_match")
480 .and_then(|v| v.as_str())
481 .unwrap_or("deny")
482 .to_string();
483 match on_match.as_str() {
486 "deny" | "allow" | "require_approval" => {}
487 other => {
488 return Err(PolicyValidationError {
489 policy_id: policy.id.clone(),
490 policy_name: policy.name.clone(),
491 reason: format!(
492 "Constraint 'on_match' value '{other}' is invalid; expected 'deny', 'allow', or 'require_approval'"
493 ),
494 });
495 }
496 }
497 let on_missing = obj
498 .get("on_missing")
499 .and_then(|v| v.as_str())
500 .unwrap_or("deny")
501 .to_string();
502 match on_missing.as_str() {
503 "deny" | "skip" => {}
504 other => {
505 return Err(PolicyValidationError {
506 policy_id: policy.id.clone(),
507 policy_name: policy.name.clone(),
508 reason: format!(
509 "Constraint 'on_missing' value '{other}' is invalid; expected 'deny' or 'skip'"
510 ),
511 });
512 }
513 }
514
515 match op {
516 "glob" => {
517 let pattern_str = obj
518 .get("pattern")
519 .and_then(|v| v.as_str())
520 .ok_or_else(|| PolicyValidationError {
521 policy_id: policy.id.clone(),
522 policy_name: policy.name.clone(),
523 reason: "glob constraint missing 'pattern' string".to_string(),
524 })?
525 .to_string();
526
527 let matcher = Glob::new(&pattern_str)
528 .map_err(|e| PolicyValidationError {
529 policy_id: policy.id.clone(),
530 policy_name: policy.name.clone(),
531 reason: format!("Invalid glob pattern '{pattern_str}': {e}"),
532 })?
533 .compile_matcher();
534
535 Ok(CompiledConstraint::Glob {
536 param,
537 matcher,
538 pattern_str,
539 on_match,
540 on_missing,
541 })
542 }
543 "not_glob" => {
544 let patterns = obj
545 .get("patterns")
546 .and_then(|v| v.as_array())
547 .ok_or_else(|| PolicyValidationError {
548 policy_id: policy.id.clone(),
549 policy_name: policy.name.clone(),
550 reason: "not_glob constraint missing 'patterns' array".to_string(),
551 })?;
552
553 if patterns.len() > MAX_CONSTRAINT_ELEMENTS {
555 return Err(PolicyValidationError {
556 policy_id: policy.id.clone(),
557 policy_name: policy.name.clone(),
558 reason: format!(
559 "not_glob patterns count {} exceeds maximum {}",
560 patterns.len(),
561 MAX_CONSTRAINT_ELEMENTS
562 ),
563 });
564 }
565
566 let mut matchers = Vec::new();
567 for pat_val in patterns {
568 let pat_str = pat_val.as_str().ok_or_else(|| PolicyValidationError {
569 policy_id: policy.id.clone(),
570 policy_name: policy.name.clone(),
571 reason: "not_glob patterns must be strings".to_string(),
572 })?;
573 let matcher = Glob::new(pat_str)
574 .map_err(|e| PolicyValidationError {
575 policy_id: policy.id.clone(),
576 policy_name: policy.name.clone(),
577 reason: format!("Invalid glob pattern '{pat_str}': {e}"),
578 })?
579 .compile_matcher();
580 matchers.push((pat_str.to_string(), matcher));
581 }
582
583 Ok(CompiledConstraint::NotGlob {
584 param,
585 matchers,
586 on_match,
587 on_missing,
588 })
589 }
590 "regex" => {
591 let pattern_str = obj
592 .get("pattern")
593 .and_then(|v| v.as_str())
594 .ok_or_else(|| PolicyValidationError {
595 policy_id: policy.id.clone(),
596 policy_name: policy.name.clone(),
597 reason: "regex constraint missing 'pattern' string".to_string(),
598 })?
599 .to_string();
600
601 Self::validate_regex_safety(&pattern_str).map_err(|reason| {
603 PolicyValidationError {
604 policy_id: policy.id.clone(),
605 policy_name: policy.name.clone(),
606 reason,
607 }
608 })?;
609
610 let regex = regex::RegexBuilder::new(&pattern_str)
613 .dfa_size_limit(256 * 1024)
614 .size_limit(256 * 1024)
615 .build()
616 .map_err(|e| PolicyValidationError {
617 policy_id: policy.id.clone(),
618 policy_name: policy.name.clone(),
619 reason: format!("Invalid regex pattern '{pattern_str}': {e}"),
620 })?;
621
622 Ok(CompiledConstraint::Regex {
623 param,
624 regex,
625 pattern_str,
626 on_match,
627 on_missing,
628 })
629 }
630 "domain_match" => {
631 let pattern = obj
632 .get("pattern")
633 .and_then(|v| v.as_str())
634 .ok_or_else(|| PolicyValidationError {
635 policy_id: policy.id.clone(),
636 policy_name: policy.name.clone(),
637 reason: "domain_match constraint missing 'pattern' string".to_string(),
638 })?
639 .to_string();
640
641 if let Err(reason) = crate::domain::validate_domain_pattern(&pattern) {
643 return Err(PolicyValidationError {
644 policy_id: policy.id.clone(),
645 policy_name: policy.name.clone(),
646 reason: format!("domain_match pattern invalid: {reason}"),
647 });
648 }
649
650 Ok(CompiledConstraint::DomainMatch {
651 param,
652 pattern,
653 on_match,
654 on_missing,
655 })
656 }
657 "domain_not_in" => {
658 let patterns_arr =
659 obj.get("patterns")
660 .and_then(|v| v.as_array())
661 .ok_or_else(|| PolicyValidationError {
662 policy_id: policy.id.clone(),
663 policy_name: policy.name.clone(),
664 reason: "domain_not_in constraint missing 'patterns' array".to_string(),
665 })?;
666
667 if patterns_arr.len() > MAX_CONSTRAINT_ELEMENTS {
669 return Err(PolicyValidationError {
670 policy_id: policy.id.clone(),
671 policy_name: policy.name.clone(),
672 reason: format!(
673 "domain_not_in patterns count {} exceeds maximum {}",
674 patterns_arr.len(),
675 MAX_CONSTRAINT_ELEMENTS
676 ),
677 });
678 }
679
680 let mut patterns = Vec::new();
681 for pat_val in patterns_arr {
682 let pat_str = pat_val.as_str().ok_or_else(|| PolicyValidationError {
683 policy_id: policy.id.clone(),
684 policy_name: policy.name.clone(),
685 reason: "domain_not_in patterns must be strings".to_string(),
686 })?;
687 if let Err(reason) = crate::domain::validate_domain_pattern(pat_str) {
689 return Err(PolicyValidationError {
690 policy_id: policy.id.clone(),
691 policy_name: policy.name.clone(),
692 reason: format!("domain_not_in pattern invalid: {reason}"),
693 });
694 }
695 patterns.push(pat_str.to_string());
696 }
697
698 Ok(CompiledConstraint::DomainNotIn {
699 param,
700 patterns,
701 on_match,
702 on_missing,
703 })
704 }
705 "eq" => {
706 let value = obj
707 .get("value")
708 .ok_or_else(|| PolicyValidationError {
709 policy_id: policy.id.clone(),
710 policy_name: policy.name.clone(),
711 reason: "eq constraint missing 'value' field".to_string(),
712 })?
713 .clone();
714
715 Ok(CompiledConstraint::Eq {
716 param,
717 value,
718 on_match,
719 on_missing,
720 })
721 }
722 "ne" => {
723 let value = obj
724 .get("value")
725 .ok_or_else(|| PolicyValidationError {
726 policy_id: policy.id.clone(),
727 policy_name: policy.name.clone(),
728 reason: "ne constraint missing 'value' field".to_string(),
729 })?
730 .clone();
731
732 Ok(CompiledConstraint::Ne {
733 param,
734 value,
735 on_match,
736 on_missing,
737 })
738 }
739 "one_of" => {
740 let values = obj
741 .get("values")
742 .and_then(|v| v.as_array())
743 .ok_or_else(|| PolicyValidationError {
744 policy_id: policy.id.clone(),
745 policy_name: policy.name.clone(),
746 reason: "one_of constraint missing 'values' array".to_string(),
747 })?;
748
749 if values.len() > MAX_CONSTRAINT_ELEMENTS {
751 return Err(PolicyValidationError {
752 policy_id: policy.id.clone(),
753 policy_name: policy.name.clone(),
754 reason: format!(
755 "one_of values count {} exceeds maximum {}",
756 values.len(),
757 MAX_CONSTRAINT_ELEMENTS
758 ),
759 });
760 }
761 let values = values.clone();
762
763 Ok(CompiledConstraint::OneOf {
764 param,
765 values,
766 on_match,
767 on_missing,
768 })
769 }
770 "none_of" => {
771 let values = obj
772 .get("values")
773 .and_then(|v| v.as_array())
774 .ok_or_else(|| PolicyValidationError {
775 policy_id: policy.id.clone(),
776 policy_name: policy.name.clone(),
777 reason: "none_of constraint missing 'values' array".to_string(),
778 })?;
779
780 if values.len() > MAX_CONSTRAINT_ELEMENTS {
782 return Err(PolicyValidationError {
783 policy_id: policy.id.clone(),
784 policy_name: policy.name.clone(),
785 reason: format!(
786 "none_of values count {} exceeds maximum {}",
787 values.len(),
788 MAX_CONSTRAINT_ELEMENTS
789 ),
790 });
791 }
792 let values = values.clone();
793
794 Ok(CompiledConstraint::NoneOf {
795 param,
796 values,
797 on_match,
798 on_missing,
799 })
800 }
801 _ => Err(PolicyValidationError {
802 policy_id: policy.id.clone(),
803 policy_name: policy.name.clone(),
804 reason: format!("Unknown constraint operator '{op}'"),
805 }),
806 }
807 }
808
809 fn compile_context_condition(
811 policy: &Policy,
812 value: &serde_json::Value,
813 ) -> Result<CompiledContextCondition, PolicyValidationError> {
814 let obj = value.as_object().ok_or_else(|| PolicyValidationError {
815 policy_id: policy.id.clone(),
816 policy_name: policy.name.clone(),
817 reason: "Each context condition must be a JSON object".to_string(),
818 })?;
819
820 let kind =
821 obj.get("type")
822 .and_then(|v| v.as_str())
823 .ok_or_else(|| PolicyValidationError {
824 policy_id: policy.id.clone(),
825 policy_name: policy.name.clone(),
826 reason: "Context condition missing required 'type' string field".to_string(),
827 })?;
828
829 match kind {
830 "time_window" => {
831 let start_hour_u64 =
836 obj.get("start_hour")
837 .and_then(|v| v.as_u64())
838 .ok_or_else(|| PolicyValidationError {
839 policy_id: policy.id.clone(),
840 policy_name: policy.name.clone(),
841 reason: "time_window missing 'start_hour' integer".to_string(),
842 })?;
843 let end_hour_u64 =
844 obj.get("end_hour")
845 .and_then(|v| v.as_u64())
846 .ok_or_else(|| PolicyValidationError {
847 policy_id: policy.id.clone(),
848 policy_name: policy.name.clone(),
849 reason: "time_window missing 'end_hour' integer".to_string(),
850 })?;
851 if start_hour_u64 > 23 || end_hour_u64 > 23 {
852 return Err(PolicyValidationError {
853 policy_id: policy.id.clone(),
854 policy_name: policy.name.clone(),
855 reason: format!(
856 "time_window hours must be 0-23, got start={start_hour_u64} end={end_hour_u64}"
857 ),
858 });
859 }
860 let start_hour = start_hour_u64 as u8;
861 let end_hour = end_hour_u64 as u8;
862 let days_u64: Vec<u64> = obj
865 .get("days")
866 .and_then(|v| v.as_array())
867 .map(|arr| arr.iter().filter_map(|v| v.as_u64()).collect())
868 .unwrap_or_default();
869 for &day in &days_u64 {
870 if !(1..=7).contains(&day) {
871 return Err(PolicyValidationError {
872 policy_id: policy.id.clone(),
873 policy_name: policy.name.clone(),
874 reason: format!(
875 "time_window day value must be 1-7 (Mon-Sun), got {day}"
876 ),
877 });
878 }
879 }
880 let days: Vec<u8> = days_u64.iter().map(|&d| d as u8).collect();
881 if start_hour == end_hour {
886 return Err(PolicyValidationError {
887 policy_id: policy.id.clone(),
888 policy_name: policy.name.clone(),
889 reason: format!(
890 "time_window start_hour and end_hour must differ (both are {start_hour}); \
891 a zero-width window permanently denies all requests"
892 ),
893 });
894 }
895 let deny_reason = format!(
896 "Outside allowed time window ({:02}:00-{:02}:00) for policy '{}'",
897 start_hour, end_hour, policy.name
898 );
899 Ok(CompiledContextCondition::TimeWindow {
900 start_hour,
901 end_hour,
902 days,
903 deny_reason,
904 })
905 }
906 "max_calls" => {
907 let tool_pattern = obj
911 .get("tool_pattern")
912 .and_then(|v| v.as_str())
913 .unwrap_or("*")
914 .to_ascii_lowercase();
915 let max = obj.get("max").and_then(|v| v.as_u64()).ok_or_else(|| {
916 PolicyValidationError {
917 policy_id: policy.id.clone(),
918 policy_name: policy.name.clone(),
919 reason: "max_calls missing 'max' integer".to_string(),
920 }
921 })?;
922 let deny_reason = format!(
923 "Tool call limit ({}) exceeded for pattern '{}' in policy '{}'",
924 max, tool_pattern, policy.name
925 );
926 Ok(CompiledContextCondition::MaxCalls {
927 tool_pattern: PatternMatcher::compile(&tool_pattern),
928 max,
929 deny_reason,
930 })
931 }
932 "agent_id" => {
933 const MAX_AGENT_ID_LIST: usize = 1000;
935
936 let allowed_raw = obj
939 .get("allowed")
940 .and_then(|v| v.as_array())
941 .map(|arr| arr.as_slice())
942 .unwrap_or_default();
943 if allowed_raw.len() > MAX_AGENT_ID_LIST {
944 return Err(PolicyValidationError {
945 policy_id: policy.id.clone(),
946 policy_name: policy.name.clone(),
947 reason: format!(
948 "agent_id allowed list count {} exceeds max {}",
949 allowed_raw.len(),
950 MAX_AGENT_ID_LIST
951 ),
952 });
953 }
954 let allowed: Vec<String> = allowed_raw
958 .iter()
959 .filter_map(|v| v.as_str().map(normalize_full))
960 .collect();
961
962 let blocked_raw = obj
963 .get("blocked")
964 .and_then(|v| v.as_array())
965 .map(|arr| arr.as_slice())
966 .unwrap_or_default();
967 if blocked_raw.len() > MAX_AGENT_ID_LIST {
968 return Err(PolicyValidationError {
969 policy_id: policy.id.clone(),
970 policy_name: policy.name.clone(),
971 reason: format!(
972 "agent_id blocked list count {} exceeds max {}",
973 blocked_raw.len(),
974 MAX_AGENT_ID_LIST
975 ),
976 });
977 }
978 let blocked: Vec<String> = blocked_raw
980 .iter()
981 .filter_map(|v| v.as_str().map(normalize_full))
982 .collect();
983
984 let deny_reason =
985 format!("Agent identity not authorized by policy '{}'", policy.name);
986 Ok(CompiledContextCondition::AgentId {
987 allowed,
988 blocked,
989 deny_reason,
990 })
991 }
992 "require_previous_action" => {
993 let required_tool = normalize_full(
997 obj.get("required_tool")
998 .and_then(|v| v.as_str())
999 .ok_or_else(|| PolicyValidationError {
1000 policy_id: policy.id.clone(),
1001 policy_name: policy.name.clone(),
1002 reason: "require_previous_action missing 'required_tool' string"
1003 .to_string(),
1004 })?,
1005 );
1006 let deny_reason = format!(
1007 "Required previous action '{}' not found in session history (policy '{}')",
1008 required_tool, policy.name
1009 );
1010 Ok(CompiledContextCondition::RequirePreviousAction {
1011 required_tool,
1012 deny_reason,
1013 })
1014 }
1015 "forbidden_previous_action" => {
1016 let forbidden_tool = normalize_full(
1020 obj.get("forbidden_tool")
1021 .and_then(|v| v.as_str())
1022 .ok_or_else(|| PolicyValidationError {
1023 policy_id: policy.id.clone(),
1024 policy_name: policy.name.clone(),
1025 reason: "forbidden_previous_action missing 'forbidden_tool' string"
1026 .to_string(),
1027 })?,
1028 );
1029 let deny_reason = format!(
1030 "Forbidden previous action '{}' detected in session history (policy '{}')",
1031 forbidden_tool, policy.name
1032 );
1033 Ok(CompiledContextCondition::ForbiddenPreviousAction {
1034 forbidden_tool,
1035 deny_reason,
1036 })
1037 }
1038 "max_calls_in_window" => {
1039 let tool_pattern = obj
1043 .get("tool_pattern")
1044 .and_then(|v| v.as_str())
1045 .unwrap_or("*")
1046 .to_ascii_lowercase();
1047 let max = obj.get("max").and_then(|v| v.as_u64()).ok_or_else(|| {
1048 PolicyValidationError {
1049 policy_id: policy.id.clone(),
1050 policy_name: policy.name.clone(),
1051 reason: "max_calls_in_window missing 'max' integer".to_string(),
1052 }
1053 })?;
1054 let window_raw = obj.get("window").and_then(|v| v.as_u64()).unwrap_or(0);
1058 let window = usize::try_from(window_raw).map_err(|_| PolicyValidationError {
1059 policy_id: policy.id.clone(),
1060 policy_name: policy.name.clone(),
1061 reason: format!(
1062 "max_calls_in_window 'window' value {} exceeds platform maximum ({})",
1063 window_raw,
1064 usize::MAX
1065 ),
1066 })?;
1067 let deny_reason = format!(
1068 "Tool '{}' called more than {} times in last {} actions (policy '{}')",
1069 tool_pattern,
1070 max,
1071 if window == 0 {
1072 "all".to_string()
1073 } else {
1074 window.to_string()
1075 },
1076 policy.name
1077 );
1078 Ok(CompiledContextCondition::MaxCallsInWindow {
1079 tool_pattern: PatternMatcher::compile(&tool_pattern),
1080 max,
1081 window,
1082 deny_reason,
1083 })
1084 }
1085 "max_chain_depth" => {
1086 let raw_depth = obj
1090 .get("max_depth")
1091 .and_then(|v| v.as_u64())
1092 .ok_or_else(|| PolicyValidationError {
1093 policy_id: policy.id.clone(),
1094 policy_name: policy.name.clone(),
1095 reason: "max_chain_depth missing 'max_depth' integer".to_string(),
1096 })?;
1097 let max_depth = usize::try_from(raw_depth).map_err(|_| PolicyValidationError {
1098 policy_id: policy.id.clone(),
1099 policy_name: policy.name.clone(),
1100 reason: format!("max_chain_depth value {raw_depth} exceeds platform maximum"),
1101 })?;
1102 let deny_reason = format!(
1103 "Call chain depth exceeds maximum of {} (policy '{}')",
1104 max_depth, policy.name
1105 );
1106 Ok(CompiledContextCondition::MaxChainDepth {
1107 max_depth,
1108 deny_reason,
1109 })
1110 }
1111 "agent_identity" => {
1112 let required_issuer = obj
1118 .get("issuer")
1119 .and_then(|v| v.as_str())
1120 .map(normalize_full);
1121 let required_subject = obj
1122 .get("subject")
1123 .and_then(|v| v.as_str())
1124 .map(normalize_full);
1125 let required_audience = obj
1126 .get("audience")
1127 .and_then(|v| v.as_str())
1128 .map(normalize_full);
1129
1130 const MAX_REQUIRED_CLAIMS: usize = 64;
1132 const MAX_CLAIM_KEY_LEN: usize = 256;
1133 const MAX_CLAIM_VALUE_LEN: usize = 512;
1134
1135 let required_claims = if let Some(m) = obj.get("claims").and_then(|v| v.as_object())
1139 {
1140 if m.len() > MAX_REQUIRED_CLAIMS {
1141 return Err(PolicyValidationError {
1142 policy_id: policy.id.clone(),
1143 policy_name: policy.name.clone(),
1144 reason: format!(
1145 "agent_identity claims count {} exceeds max {}",
1146 m.len(),
1147 MAX_REQUIRED_CLAIMS
1148 ),
1149 });
1150 }
1151 let mut map = std::collections::HashMap::new();
1152 for (k, v) in m {
1153 if k.len() > MAX_CLAIM_KEY_LEN {
1154 return Err(PolicyValidationError {
1155 policy_id: policy.id.clone(),
1156 policy_name: policy.name.clone(),
1157 reason: format!(
1158 "agent_identity claim key length {} exceeds max {}",
1159 k.len(),
1160 MAX_CLAIM_KEY_LEN
1161 ),
1162 });
1163 }
1164 if let Some(s) = v.as_str() {
1165 if s.len() > MAX_CLAIM_VALUE_LEN {
1166 return Err(PolicyValidationError {
1167 policy_id: policy.id.clone(),
1168 policy_name: policy.name.clone(),
1169 reason: format!(
1170 "agent_identity claim value for key '{}' length {} exceeds max {}",
1171 k,
1172 s.len(),
1173 MAX_CLAIM_VALUE_LEN
1174 ),
1175 });
1176 }
1177 map.insert(k.clone(), normalize_full(s));
1178 }
1179 }
1180 map
1181 } else {
1182 std::collections::HashMap::new()
1183 };
1184
1185 const MAX_ISSUER_LIST: usize = 256;
1187 const MAX_SUBJECT_LIST: usize = 256;
1188
1189 let blocked_issuers_raw = obj
1193 .get("blocked_issuers")
1194 .and_then(|v| v.as_array())
1195 .map(|arr| arr.as_slice())
1196 .unwrap_or_default();
1197 if blocked_issuers_raw.len() > MAX_ISSUER_LIST {
1198 return Err(PolicyValidationError {
1199 policy_id: policy.id.clone(),
1200 policy_name: policy.name.clone(),
1201 reason: format!(
1202 "agent_identity blocked_issuers count {} exceeds max {}",
1203 blocked_issuers_raw.len(),
1204 MAX_ISSUER_LIST
1205 ),
1206 });
1207 }
1208 let blocked_issuers: Vec<String> = blocked_issuers_raw
1209 .iter()
1210 .filter_map(|v| v.as_str().map(normalize_full))
1211 .collect();
1212
1213 let blocked_subjects_raw = obj
1214 .get("blocked_subjects")
1215 .and_then(|v| v.as_array())
1216 .map(|arr| arr.as_slice())
1217 .unwrap_or_default();
1218 if blocked_subjects_raw.len() > MAX_SUBJECT_LIST {
1219 return Err(PolicyValidationError {
1220 policy_id: policy.id.clone(),
1221 policy_name: policy.name.clone(),
1222 reason: format!(
1223 "agent_identity blocked_subjects count {} exceeds max {}",
1224 blocked_subjects_raw.len(),
1225 MAX_SUBJECT_LIST
1226 ),
1227 });
1228 }
1229 let blocked_subjects: Vec<String> = blocked_subjects_raw
1230 .iter()
1231 .filter_map(|v| v.as_str().map(normalize_full))
1232 .collect();
1233
1234 let require_attestation = obj
1236 .get("require_attestation")
1237 .and_then(|v| v.as_bool())
1238 .unwrap_or(true); let deny_reason = format!(
1241 "Agent identity attestation failed for policy '{}'",
1242 policy.name
1243 );
1244
1245 Ok(CompiledContextCondition::AgentIdentityMatch {
1246 required_issuer,
1247 required_subject,
1248 required_audience,
1249 required_claims,
1250 blocked_issuers,
1251 blocked_subjects,
1252 require_attestation,
1253 deny_reason,
1254 })
1255 }
1256
1257 "async_task_policy" => {
1261 tracing::warn!(
1264 policy_id = %policy.id,
1265 "Policy condition 'async_task_policy' is not enforced at the engine level; \
1266 it requires the MCP proxy layer for enforcement"
1267 );
1268 let max_concurrent_raw = obj
1271 .get("max_concurrent")
1272 .and_then(|v| v.as_u64())
1273 .unwrap_or(0); let max_concurrent = if max_concurrent_raw == 0 {
1275 0
1276 } else {
1277 usize::try_from(max_concurrent_raw).map_err(|_| PolicyValidationError {
1278 policy_id: policy.id.clone(),
1279 policy_name: policy.name.clone(),
1280 reason: format!(
1281 "async_task_policy 'max_concurrent' value {} exceeds platform maximum ({})",
1282 max_concurrent_raw,
1283 usize::MAX
1284 ),
1285 })?
1286 };
1287
1288 let max_duration_secs = obj
1289 .get("max_duration_secs")
1290 .and_then(|v| v.as_u64())
1291 .unwrap_or(0); let require_self_cancel = obj
1294 .get("require_self_cancel")
1295 .and_then(|v| v.as_bool())
1296 .unwrap_or(true); let deny_reason =
1299 format!("Async task policy violated for policy '{}'", policy.name);
1300
1301 Ok(CompiledContextCondition::AsyncTaskPolicy {
1302 max_concurrent,
1303 max_duration_secs,
1304 require_self_cancel,
1305 deny_reason,
1306 })
1307 }
1308
1309 "resource_indicator" => {
1310 const MAX_RESOURCE_PATTERNS: usize = 256;
1313
1314 let allowed_resources_raw = obj
1315 .get("allowed_resources")
1316 .and_then(|v| v.as_array())
1317 .map(|arr| arr.as_slice())
1318 .unwrap_or_default();
1319 if allowed_resources_raw.len() > MAX_RESOURCE_PATTERNS {
1320 return Err(PolicyValidationError {
1321 policy_id: policy.id.clone(),
1322 policy_name: policy.name.clone(),
1323 reason: format!(
1324 "resource_indicator allowed_resources count {} exceeds max {}",
1325 allowed_resources_raw.len(),
1326 MAX_RESOURCE_PATTERNS
1327 ),
1328 });
1329 }
1330 let allowed_resources: Vec<PatternMatcher> = allowed_resources_raw
1331 .iter()
1332 .filter_map(|v| v.as_str())
1333 .map(PatternMatcher::compile)
1334 .collect();
1335
1336 let require_resource = obj
1337 .get("require_resource")
1338 .and_then(|v| v.as_bool())
1339 .unwrap_or(false);
1340
1341 let deny_reason = format!(
1342 "Resource indicator validation failed for policy '{}'",
1343 policy.name
1344 );
1345
1346 Ok(CompiledContextCondition::ResourceIndicator {
1347 allowed_resources,
1348 require_resource,
1349 deny_reason,
1350 })
1351 }
1352
1353 "capability_required" => {
1354 const MAX_CAPABILITY_LIST: usize = 256;
1360 const MAX_CAPABILITY_NAME_LEN: usize = 256;
1361
1362 let required_capabilities: Vec<String> = obj
1366 .get("required_capabilities")
1367 .and_then(|v| v.as_array())
1368 .map(|arr| {
1369 arr.iter()
1370 .filter_map(|v| v.as_str().map(normalize_full))
1371 .collect()
1372 })
1373 .unwrap_or_default();
1374
1375 if required_capabilities.len() > MAX_CAPABILITY_LIST {
1376 return Err(PolicyValidationError {
1377 policy_id: policy.id.clone(),
1378 policy_name: policy.name.clone(),
1379 reason: format!(
1380 "capability_required has {} required_capabilities (max {MAX_CAPABILITY_LIST})",
1381 required_capabilities.len()
1382 ),
1383 });
1384 }
1385 for (i, cap) in required_capabilities.iter().enumerate() {
1386 if cap.len() > MAX_CAPABILITY_NAME_LEN {
1387 return Err(PolicyValidationError {
1388 policy_id: policy.id.clone(),
1389 policy_name: policy.name.clone(),
1390 reason: format!(
1391 "capability_required required_capabilities[{i}] length {} exceeds max {MAX_CAPABILITY_NAME_LEN}",
1392 cap.len()
1393 ),
1394 });
1395 }
1396 }
1397
1398 let blocked_capabilities: Vec<String> = obj
1399 .get("blocked_capabilities")
1400 .and_then(|v| v.as_array())
1401 .map(|arr| {
1402 arr.iter()
1403 .filter_map(|v| v.as_str().map(normalize_full))
1404 .collect()
1405 })
1406 .unwrap_or_default();
1407
1408 if blocked_capabilities.len() > MAX_CAPABILITY_LIST {
1409 return Err(PolicyValidationError {
1410 policy_id: policy.id.clone(),
1411 policy_name: policy.name.clone(),
1412 reason: format!(
1413 "capability_required has {} blocked_capabilities (max {MAX_CAPABILITY_LIST})",
1414 blocked_capabilities.len()
1415 ),
1416 });
1417 }
1418 for (i, cap) in blocked_capabilities.iter().enumerate() {
1419 if cap.len() > MAX_CAPABILITY_NAME_LEN {
1420 return Err(PolicyValidationError {
1421 policy_id: policy.id.clone(),
1422 policy_name: policy.name.clone(),
1423 reason: format!(
1424 "capability_required blocked_capabilities[{i}] length {} exceeds max {MAX_CAPABILITY_NAME_LEN}",
1425 cap.len()
1426 ),
1427 });
1428 }
1429 }
1430
1431 let deny_reason = format!(
1432 "Capability requirement not met for policy '{}'",
1433 policy.name
1434 );
1435
1436 Ok(CompiledContextCondition::CapabilityRequired {
1437 required_capabilities,
1438 blocked_capabilities,
1439 deny_reason,
1440 })
1441 }
1442
1443 "step_up_auth" => {
1444 let required_level_u64 = obj
1446 .get("required_level")
1447 .and_then(|v| v.as_u64())
1448 .ok_or_else(|| PolicyValidationError {
1449 policy_id: policy.id.clone(),
1450 policy_name: policy.name.clone(),
1451 reason: "step_up_auth missing 'required_level' integer".to_string(),
1452 })?;
1453
1454 if required_level_u64 > 4 {
1456 return Err(PolicyValidationError {
1457 policy_id: policy.id.clone(),
1458 policy_name: policy.name.clone(),
1459 reason: format!(
1460 "step_up_auth required_level must be 0-4, got {required_level_u64}"
1461 ),
1462 });
1463 }
1464
1465 let required_level = required_level_u64 as u8;
1466
1467 let deny_reason = format!(
1468 "Step-up authentication required (level {}) for policy '{}'",
1469 required_level, policy.name
1470 );
1471
1472 Ok(CompiledContextCondition::StepUpAuth {
1473 required_level,
1474 deny_reason,
1475 })
1476 }
1477
1478 "circuit_breaker" => {
1482 tracing::warn!(
1485 policy_id = %policy.id,
1486 "Policy condition 'circuit_breaker' is not enforced at the engine level; \
1487 it requires the MCP proxy layer for enforcement"
1488 );
1489 let tool_pattern = obj
1490 .get("tool_pattern")
1491 .and_then(|v| v.as_str())
1492 .unwrap_or("*")
1493 .to_ascii_lowercase();
1494
1495 let deny_reason = format!("Circuit breaker open (policy '{}')", policy.name);
1499
1500 Ok(CompiledContextCondition::CircuitBreaker {
1501 tool_pattern: PatternMatcher::compile(&tool_pattern),
1502 deny_reason,
1503 })
1504 }
1505
1506 "deputy_validation" => {
1507 let require_principal = obj
1509 .get("require_principal")
1510 .and_then(|v| v.as_bool())
1511 .unwrap_or(true);
1512
1513 let max_delegation_depth_u64 = obj
1514 .get("max_delegation_depth")
1515 .and_then(|v| v.as_u64())
1516 .unwrap_or(3);
1517
1518 if max_delegation_depth_u64 > 255 {
1520 return Err(PolicyValidationError {
1521 policy_id: policy.id.clone(),
1522 policy_name: policy.name.clone(),
1523 reason: format!(
1524 "deputy_validation max_delegation_depth must be 0-255, got {max_delegation_depth_u64}"
1525 ),
1526 });
1527 }
1528
1529 let max_delegation_depth = max_delegation_depth_u64 as u8;
1530
1531 let deny_reason = format!("Deputy validation failed for policy '{}'", policy.name);
1532
1533 Ok(CompiledContextCondition::DeputyValidation {
1534 require_principal,
1535 max_delegation_depth,
1536 deny_reason,
1537 })
1538 }
1539
1540 "shadow_agent_check" => {
1541 tracing::warn!(
1544 policy_id = %policy.id,
1545 "Policy condition 'shadow_agent_check' is not enforced at the engine level; \
1546 it requires the MCP proxy layer for enforcement"
1547 );
1548 let require_known_fingerprint = obj
1549 .get("require_known_fingerprint")
1550 .and_then(|v| v.as_bool())
1551 .unwrap_or(false);
1552
1553 let min_trust_level_u64 = obj
1554 .get("min_trust_level")
1555 .and_then(|v| v.as_u64())
1556 .unwrap_or(1); if min_trust_level_u64 > 4 {
1560 return Err(PolicyValidationError {
1561 policy_id: policy.id.clone(),
1562 policy_name: policy.name.clone(),
1563 reason: format!(
1564 "shadow_agent_check min_trust_level must be 0-4, got {min_trust_level_u64}"
1565 ),
1566 });
1567 }
1568
1569 let min_trust_level = min_trust_level_u64 as u8;
1570
1571 let deny_reason = format!("Shadow agent check failed for policy '{}'", policy.name);
1572
1573 Ok(CompiledContextCondition::ShadowAgentCheck {
1574 require_known_fingerprint,
1575 min_trust_level,
1576 deny_reason,
1577 })
1578 }
1579
1580 "schema_poisoning_check" => {
1581 tracing::warn!(
1584 policy_id = %policy.id,
1585 "Policy condition 'schema_poisoning_check' is not enforced at the engine level; \
1586 it requires the MCP proxy layer for enforcement"
1587 );
1588 let mutation_threshold = obj
1589 .get("mutation_threshold")
1590 .and_then(|v| v.as_f64())
1591 .map(|v| v as f32)
1592 .unwrap_or(0.1); if !mutation_threshold.is_finite() || !(0.0..=1.0).contains(&mutation_threshold) {
1596 return Err(PolicyValidationError {
1597 policy_id: policy.id.clone(),
1598 policy_name: policy.name.clone(),
1599 reason: format!(
1600 "schema_poisoning_check mutation_threshold must be in [0.0, 1.0], got {mutation_threshold}"
1601 ),
1602 });
1603 }
1604
1605 let deny_reason = format!("Schema poisoning detected for policy '{}'", policy.name);
1606
1607 Ok(CompiledContextCondition::SchemaPoisoningCheck {
1608 mutation_threshold,
1609 deny_reason,
1610 })
1611 }
1612
1613 "require_capability_token" => {
1614 const MAX_ISSUER_LIST_CAP: usize = 256;
1616
1617 let required_issuers_raw = obj
1619 .get("required_issuers")
1620 .and_then(|v| v.as_array())
1621 .map(|arr| arr.as_slice())
1622 .unwrap_or_default();
1623 if required_issuers_raw.len() > MAX_ISSUER_LIST_CAP {
1624 return Err(PolicyValidationError {
1625 policy_id: policy.id.clone(),
1626 policy_name: policy.name.clone(),
1627 reason: format!(
1628 "require_capability_token required_issuers count {} exceeds max {}",
1629 required_issuers_raw.len(),
1630 MAX_ISSUER_LIST_CAP
1631 ),
1632 });
1633 }
1634 let required_issuers: Vec<String> = required_issuers_raw
1637 .iter()
1638 .filter_map(|v| v.as_str().map(normalize_full))
1639 .collect();
1640
1641 let min_remaining_depth = obj
1643 .get("min_remaining_depth")
1644 .and_then(|v| v.as_u64())
1645 .unwrap_or(0);
1646 if min_remaining_depth > 16 {
1647 return Err(PolicyValidationError {
1648 policy_id: policy.id.clone(),
1649 policy_name: policy.name.clone(),
1650 reason: format!(
1651 "require_capability_token min_remaining_depth must be 0-16, got {min_remaining_depth}"
1652 ),
1653 });
1654 }
1655
1656 let deny_reason = format!("Capability token required for policy '{}'", policy.name);
1657
1658 Ok(CompiledContextCondition::RequireCapabilityToken {
1659 required_issuers,
1660 min_remaining_depth: min_remaining_depth as u8,
1661 deny_reason,
1662 })
1663 }
1664
1665 "min_verification_tier" => {
1666 let required_tier = if let Some(level_val) = obj.get("required_tier") {
1668 if let Some(level_u64) = level_val.as_u64() {
1669 if level_u64 > 4 {
1670 return Err(PolicyValidationError {
1671 policy_id: policy.id.clone(),
1672 policy_name: policy.name.clone(),
1673 reason: format!(
1674 "min_verification_tier required_tier must be 0-4, got {level_u64}"
1675 ),
1676 });
1677 }
1678 level_u64 as u8
1679 } else if let Some(name) = level_val.as_str() {
1680 vellaveto_types::VerificationTier::from_name(name)
1681 .map(|t| t.level())
1682 .ok_or_else(|| PolicyValidationError {
1683 policy_id: policy.id.clone(),
1684 policy_name: policy.name.clone(),
1685 reason: format!("min_verification_tier unknown tier name '{name}'"),
1686 })?
1687 } else {
1688 return Err(PolicyValidationError {
1689 policy_id: policy.id.clone(),
1690 policy_name: policy.name.clone(),
1691 reason: "min_verification_tier required_tier must be an integer (0-4) or tier name string".to_string(),
1692 });
1693 }
1694 } else {
1695 return Err(PolicyValidationError {
1696 policy_id: policy.id.clone(),
1697 policy_name: policy.name.clone(),
1698 reason: "min_verification_tier missing 'required_tier' field".to_string(),
1699 });
1700 };
1701
1702 let deny_reason = format!(
1703 "Verification tier below minimum (required level {}) for policy '{}'",
1704 required_tier, policy.name
1705 );
1706
1707 Ok(CompiledContextCondition::MinVerificationTier {
1708 required_tier,
1709 deny_reason,
1710 })
1711 }
1712
1713 "session_state_required" => {
1714 const MAX_ALLOWED_STATES: usize = 1000;
1717 const MAX_STATE_NAME_LEN: usize = 256;
1718
1719 let raw_arr = obj
1721 .get("allowed_states")
1722 .and_then(|v| v.as_array())
1723 .cloned()
1724 .unwrap_or_default();
1725
1726 if raw_arr.len() > MAX_ALLOWED_STATES {
1727 return Err(PolicyValidationError {
1728 policy_id: policy.id.clone(),
1729 policy_name: policy.name.clone(),
1730 reason: format!(
1731 "session_state_required allowed_states has {} entries, max {}",
1732 raw_arr.len(),
1733 MAX_ALLOWED_STATES,
1734 ),
1735 });
1736 }
1737
1738 let mut allowed_states = Vec::with_capacity(raw_arr.len());
1739 for entry in &raw_arr {
1740 if let Some(s) = entry.as_str() {
1741 if s.len() > MAX_STATE_NAME_LEN {
1742 return Err(PolicyValidationError {
1743 policy_id: policy.id.clone(),
1744 policy_name: policy.name.clone(),
1745 reason: format!(
1746 "session_state_required allowed_states entry length {} exceeds max {}",
1747 s.len(),
1748 MAX_STATE_NAME_LEN,
1749 ),
1750 });
1751 }
1752 allowed_states.push(normalize_full(s));
1755 }
1756 }
1757
1758 if allowed_states.is_empty() {
1759 return Err(PolicyValidationError {
1760 policy_id: policy.id.clone(),
1761 policy_name: policy.name.clone(),
1762 reason:
1763 "session_state_required must have at least one allowed_states entry"
1764 .to_string(),
1765 });
1766 }
1767
1768 let deny_reason = format!(
1769 "Session state not in allowed states for policy '{}'",
1770 policy.name
1771 );
1772
1773 Ok(CompiledContextCondition::SessionStateRequired {
1774 allowed_states,
1775 deny_reason,
1776 })
1777 }
1778
1779 "required_action_sequence" => Self::compile_action_sequence(obj, policy, true),
1783
1784 "forbidden_action_sequence" => Self::compile_action_sequence(obj, policy, false),
1785
1786 "workflow_template" => Self::compile_workflow_template(obj, policy),
1787
1788 _ => Err(PolicyValidationError {
1789 policy_id: policy.id.clone(),
1790 policy_name: policy.name.clone(),
1791 reason: format!("Unknown context condition type '{kind}'"),
1792 }),
1793 }
1794 }
1795
1796 fn compile_action_sequence(
1800 obj: &serde_json::Map<String, serde_json::Value>,
1801 policy: &vellaveto_types::Policy,
1802 is_required: bool,
1803 ) -> Result<CompiledContextCondition, PolicyValidationError> {
1804 const MAX_SEQUENCE_STEPS: usize = 20;
1805 const MAX_TOOL_NAME_LEN: usize = 256;
1807
1808 let kind = if is_required {
1809 "required_action_sequence"
1810 } else {
1811 "forbidden_action_sequence"
1812 };
1813
1814 let arr = obj
1815 .get("sequence")
1816 .and_then(|v| v.as_array())
1817 .ok_or_else(|| PolicyValidationError {
1818 policy_id: policy.id.clone(),
1819 policy_name: policy.name.clone(),
1820 reason: format!("{kind} requires a 'sequence' array"),
1821 })?;
1822
1823 if arr.is_empty() {
1824 return Err(PolicyValidationError {
1825 policy_id: policy.id.clone(),
1826 policy_name: policy.name.clone(),
1827 reason: format!("{kind} sequence must not be empty"),
1828 });
1829 }
1830
1831 if arr.len() > MAX_SEQUENCE_STEPS {
1832 return Err(PolicyValidationError {
1833 policy_id: policy.id.clone(),
1834 policy_name: policy.name.clone(),
1835 reason: format!(
1836 "{kind} sequence has {} steps (max {MAX_SEQUENCE_STEPS})",
1837 arr.len()
1838 ),
1839 });
1840 }
1841
1842 let mut sequence = Vec::with_capacity(arr.len());
1843 for (i, val) in arr.iter().enumerate() {
1844 let s = val.as_str().ok_or_else(|| PolicyValidationError {
1845 policy_id: policy.id.clone(),
1846 policy_name: policy.name.clone(),
1847 reason: format!("{kind} sequence[{i}] must be a string"),
1848 })?;
1849
1850 if s.is_empty() {
1851 return Err(PolicyValidationError {
1852 policy_id: policy.id.clone(),
1853 policy_name: policy.name.clone(),
1854 reason: format!("{kind} sequence[{i}] must not be empty"),
1855 });
1856 }
1857
1858 if s.chars()
1860 .any(|c| c.is_control() || vellaveto_types::is_unicode_format_char(c))
1861 {
1862 return Err(PolicyValidationError {
1863 policy_id: policy.id.clone(),
1864 policy_name: policy.name.clone(),
1865 reason: format!("{kind} sequence[{i}] contains control or format characters"),
1866 });
1867 }
1868
1869 if s.len() > MAX_TOOL_NAME_LEN {
1871 return Err(PolicyValidationError {
1872 policy_id: policy.id.clone(),
1873 policy_name: policy.name.clone(),
1874 reason: format!(
1875 "{kind} sequence[{i}] tool name is {} bytes, max is {MAX_TOOL_NAME_LEN}",
1876 s.len()
1877 ),
1878 });
1879 }
1880
1881 sequence.push(normalize_full(s));
1884 }
1885
1886 let ordered = obj.get("ordered").and_then(|v| v.as_bool()).unwrap_or(true);
1887
1888 if is_required {
1889 let deny_reason = format!(
1890 "Required action sequence not satisfied for policy '{}'",
1891 policy.name
1892 );
1893 Ok(CompiledContextCondition::RequiredActionSequence {
1894 sequence,
1895 ordered,
1896 deny_reason,
1897 })
1898 } else {
1899 let deny_reason = format!(
1900 "Forbidden action sequence detected for policy '{}'",
1901 policy.name
1902 );
1903 Ok(CompiledContextCondition::ForbiddenActionSequence {
1904 sequence,
1905 ordered,
1906 deny_reason,
1907 })
1908 }
1909 }
1910
1911 fn compile_workflow_template(
1916 obj: &serde_json::Map<String, serde_json::Value>,
1917 policy: &vellaveto_types::Policy,
1918 ) -> Result<CompiledContextCondition, PolicyValidationError> {
1919 use std::collections::{HashMap, HashSet, VecDeque};
1920
1921 const MAX_WORKFLOW_STEPS: usize = 50;
1922 const MAX_SUCCESSORS_PER_STEP: usize = 50;
1924 const MAX_TOOL_NAME_LEN: usize = 256;
1926
1927 let steps =
1928 obj.get("steps")
1929 .and_then(|v| v.as_array())
1930 .ok_or_else(|| PolicyValidationError {
1931 policy_id: policy.id.clone(),
1932 policy_name: policy.name.clone(),
1933 reason: "workflow_template requires a 'steps' array".to_string(),
1934 })?;
1935
1936 if steps.is_empty() {
1937 return Err(PolicyValidationError {
1938 policy_id: policy.id.clone(),
1939 policy_name: policy.name.clone(),
1940 reason: "workflow_template steps must not be empty".to_string(),
1941 });
1942 }
1943
1944 if steps.len() > MAX_WORKFLOW_STEPS {
1945 return Err(PolicyValidationError {
1946 policy_id: policy.id.clone(),
1947 policy_name: policy.name.clone(),
1948 reason: format!(
1949 "workflow_template has {} steps (max {MAX_WORKFLOW_STEPS})",
1950 steps.len()
1951 ),
1952 });
1953 }
1954
1955 let enforce = obj
1956 .get("enforce")
1957 .and_then(|v| v.as_str())
1958 .unwrap_or("strict");
1959
1960 let strict = match enforce {
1961 "strict" => true,
1962 "warn" => false,
1963 other => {
1964 return Err(PolicyValidationError {
1965 policy_id: policy.id.clone(),
1966 policy_name: policy.name.clone(),
1967 reason: format!(
1968 "workflow_template enforce must be 'strict' or 'warn', got '{other}'"
1969 ),
1970 });
1971 }
1972 };
1973
1974 let mut adjacency: HashMap<String, Vec<String>> = HashMap::new();
1975 let mut governed_tools: HashSet<String> = HashSet::new();
1976 let mut seen_tools: HashSet<String> = HashSet::new();
1977
1978 for (i, step) in steps.iter().enumerate() {
1979 let step_obj = step.as_object().ok_or_else(|| PolicyValidationError {
1980 policy_id: policy.id.clone(),
1981 policy_name: policy.name.clone(),
1982 reason: format!("workflow_template steps[{i}] must be an object"),
1983 })?;
1984
1985 let tool = step_obj
1986 .get("tool")
1987 .and_then(|v| v.as_str())
1988 .ok_or_else(|| PolicyValidationError {
1989 policy_id: policy.id.clone(),
1990 policy_name: policy.name.clone(),
1991 reason: format!("workflow_template steps[{i}] requires a 'tool' string"),
1992 })?;
1993
1994 if tool.is_empty() {
1995 return Err(PolicyValidationError {
1996 policy_id: policy.id.clone(),
1997 policy_name: policy.name.clone(),
1998 reason: format!("workflow_template steps[{i}].tool must not be empty"),
1999 });
2000 }
2001
2002 if tool
2004 .chars()
2005 .any(|c| c.is_control() || vellaveto_types::is_unicode_format_char(c))
2006 {
2007 return Err(PolicyValidationError {
2008 policy_id: policy.id.clone(),
2009 policy_name: policy.name.clone(),
2010 reason: format!(
2011 "workflow_template steps[{i}].tool contains control or format characters"
2012 ),
2013 });
2014 }
2015
2016 if tool.len() > MAX_TOOL_NAME_LEN {
2018 return Err(PolicyValidationError {
2019 policy_id: policy.id.clone(),
2020 policy_name: policy.name.clone(),
2021 reason: format!(
2022 "workflow_template steps[{i}].tool is {} bytes, max is {MAX_TOOL_NAME_LEN}",
2023 tool.len()
2024 ),
2025 });
2026 }
2027
2028 let tool_lower = normalize_full(tool);
2030
2031 if !seen_tools.insert(tool_lower.clone()) {
2032 return Err(PolicyValidationError {
2033 policy_id: policy.id.clone(),
2034 policy_name: policy.name.clone(),
2035 reason: format!("workflow_template has duplicate step tool '{tool}'"),
2036 });
2037 }
2038
2039 let then_arr = step_obj
2040 .get("then")
2041 .and_then(|v| v.as_array())
2042 .ok_or_else(|| PolicyValidationError {
2043 policy_id: policy.id.clone(),
2044 policy_name: policy.name.clone(),
2045 reason: format!("workflow_template steps[{i}] requires a 'then' array"),
2046 })?;
2047
2048 if then_arr.len() > MAX_SUCCESSORS_PER_STEP {
2051 return Err(PolicyValidationError {
2052 policy_id: policy.id.clone(),
2053 policy_name: policy.name.clone(),
2054 reason: format!(
2055 "workflow_template steps[{i}].then has {} entries, max is {MAX_SUCCESSORS_PER_STEP}",
2056 then_arr.len()
2057 ),
2058 });
2059 }
2060
2061 let mut successors = Vec::with_capacity(then_arr.len());
2062 let mut seen_successors: HashSet<String> = HashSet::new();
2064 for (j, v) in then_arr.iter().enumerate() {
2065 let s = v.as_str().ok_or_else(|| PolicyValidationError {
2066 policy_id: policy.id.clone(),
2067 policy_name: policy.name.clone(),
2068 reason: format!("workflow_template steps[{i}].then[{j}] must be a string"),
2069 })?;
2070
2071 if s.is_empty() {
2072 return Err(PolicyValidationError {
2073 policy_id: policy.id.clone(),
2074 policy_name: policy.name.clone(),
2075 reason: format!("workflow_template steps[{i}].then[{j}] must not be empty"),
2076 });
2077 }
2078
2079 if s.chars()
2081 .any(|c| c.is_control() || vellaveto_types::is_unicode_format_char(c))
2082 {
2083 return Err(PolicyValidationError {
2084 policy_id: policy.id.clone(),
2085 policy_name: policy.name.clone(),
2086 reason: format!(
2087 "workflow_template steps[{i}].then[{j}] contains control or format characters"
2088 ),
2089 });
2090 }
2091
2092 if s.len() > MAX_TOOL_NAME_LEN {
2094 return Err(PolicyValidationError {
2095 policy_id: policy.id.clone(),
2096 policy_name: policy.name.clone(),
2097 reason: format!(
2098 "workflow_template steps[{i}].then[{j}] is {} bytes, max is {MAX_TOOL_NAME_LEN}",
2099 s.len()
2100 ),
2101 });
2102 }
2103
2104 let lowered = normalize_full(s);
2106 if seen_successors.insert(lowered.clone()) {
2109 successors.push(lowered);
2110 }
2111 }
2112
2113 governed_tools.insert(tool_lower.clone());
2114 for succ in &successors {
2115 governed_tools.insert(succ.clone());
2116 }
2117
2118 adjacency.insert(tool_lower, successors);
2119 }
2120
2121 let mut has_predecessor: HashSet<&str> = HashSet::new();
2123 for successors in adjacency.values() {
2124 for succ in successors {
2125 has_predecessor.insert(succ.as_str());
2126 }
2127 }
2128 let mut entry_points: Vec<String> = governed_tools
2131 .iter()
2132 .filter(|t| !has_predecessor.contains(t.as_str()))
2133 .cloned()
2134 .collect();
2135 entry_points.sort();
2136
2137 if entry_points.is_empty() {
2138 return Err(PolicyValidationError {
2139 policy_id: policy.id.clone(),
2140 policy_name: policy.name.clone(),
2141 reason: "workflow_template has no entry points (implies cycle)".to_string(),
2142 });
2143 }
2144
2145 let mut in_degree: HashMap<&str, usize> = HashMap::new();
2147 for tool in &governed_tools {
2148 in_degree.insert(tool.as_str(), 0);
2149 }
2150 for successors in adjacency.values() {
2151 for succ in successors {
2152 if let Some(deg) = in_degree.get_mut(succ.as_str()) {
2153 *deg = deg.saturating_add(1);
2154 }
2155 }
2156 }
2157
2158 let mut queue: VecDeque<&str> = VecDeque::new();
2159 for (tool, deg) in &in_degree {
2160 if *deg == 0 {
2161 queue.push_back(tool);
2162 }
2163 }
2164
2165 let mut visited_count: usize = 0;
2166 while let Some(node) = queue.pop_front() {
2167 visited_count += 1;
2168 if let Some(successors) = adjacency.get(node) {
2169 for succ in successors {
2170 if let Some(deg) = in_degree.get_mut(succ.as_str()) {
2171 *deg = deg.saturating_sub(1);
2172 if *deg == 0 {
2173 queue.push_back(succ.as_str());
2174 }
2175 }
2176 }
2177 }
2178 }
2179
2180 if visited_count < governed_tools.len() {
2181 return Err(PolicyValidationError {
2182 policy_id: policy.id.clone(),
2183 policy_name: policy.name.clone(),
2184 reason: "workflow_template contains a cycle".to_string(),
2185 });
2186 }
2187
2188 let deny_reason = format!("Workflow template violation for policy '{}'", policy.name);
2189
2190 Ok(CompiledContextCondition::WorkflowTemplate {
2191 adjacency,
2192 governed_tools,
2193 entry_points,
2194 strict,
2195 deny_reason,
2196 })
2197 }
2198}
2199
2200#[cfg(test)]
2201mod tests {
2202 use super::*;
2203 use serde_json::json;
2204 use vellaveto_types::{IpRules, NetworkRules, PathRules, Policy, PolicyType};
2205
2206 fn allow_policy(id: &str, name: &str, priority: i32) -> Policy {
2208 Policy {
2209 id: id.to_string(),
2210 name: name.to_string(),
2211 policy_type: PolicyType::Allow,
2212 priority,
2213 path_rules: None,
2214 network_rules: None,
2215 }
2216 }
2217
2218 fn deny_policy(id: &str, name: &str, priority: i32) -> Policy {
2220 Policy {
2221 id: id.to_string(),
2222 name: name.to_string(),
2223 policy_type: PolicyType::Deny,
2224 priority,
2225 path_rules: None,
2226 network_rules: None,
2227 }
2228 }
2229
2230 fn conditional_policy(
2232 id: &str,
2233 name: &str,
2234 priority: i32,
2235 conditions: serde_json::Value,
2236 ) -> Policy {
2237 Policy {
2238 id: id.to_string(),
2239 name: name.to_string(),
2240 policy_type: PolicyType::Conditional { conditions },
2241 priority,
2242 path_rules: None,
2243 network_rules: None,
2244 }
2245 }
2246
2247 #[test]
2252 fn test_compile_policies_empty_list_succeeds() {
2253 let result = PolicyEngine::compile_policies(&[], false);
2254 assert!(result.is_ok());
2255 assert!(result.unwrap().is_empty());
2256 }
2257
2258 #[test]
2259 fn test_compile_policies_single_allow_policy_succeeds() {
2260 let policies = vec![allow_policy("tool:read", "Read Only", 10)];
2261 let result = PolicyEngine::compile_policies(&policies, false);
2262 assert!(result.is_ok());
2263 let compiled = result.unwrap();
2264 assert_eq!(compiled.len(), 1);
2265 assert_eq!(compiled[0].policy.id, "tool:read");
2266 assert_eq!(compiled[0].deny_reason, "Denied by policy 'Read Only'");
2267 }
2268
2269 #[test]
2270 fn test_compile_policies_strict_mode_rejects_unknown_condition_keys() {
2271 let conditions = json!({
2272 "unknown_key": true,
2273 "require_approval": false
2274 });
2275 let policies = vec![conditional_policy("p1", "Strict Test", 10, conditions)];
2276 let result = PolicyEngine::compile_policies(&policies, true);
2277 assert!(result.is_err());
2278 let errors = result.unwrap_err();
2279 assert_eq!(errors.len(), 1);
2280 assert!(errors[0].reason.contains("Unknown condition key"));
2281 assert!(errors[0].reason.contains("strict mode"));
2282 }
2283
2284 #[test]
2285 fn test_compile_policies_non_strict_allows_unknown_condition_keys() {
2286 let conditions = json!({
2287 "unknown_key": true,
2288 "require_approval": false
2289 });
2290 let policies = vec![conditional_policy("p1", "Non-strict Test", 10, conditions)];
2291 let result = PolicyEngine::compile_policies(&policies, false);
2292 assert!(result.is_ok());
2293 }
2294
2295 #[test]
2300 fn test_compile_policies_sorted_by_priority_descending() {
2301 let policies = vec![
2302 allow_policy("low:*", "Low", 1),
2303 allow_policy("high:*", "High", 100),
2304 allow_policy("mid:*", "Mid", 50),
2305 ];
2306 let compiled = PolicyEngine::compile_policies(&policies, false).unwrap();
2307 assert_eq!(compiled[0].policy.priority, 100);
2308 assert_eq!(compiled[1].policy.priority, 50);
2309 assert_eq!(compiled[2].policy.priority, 1);
2310 }
2311
2312 #[test]
2313 fn test_compile_policies_deny_before_allow_at_same_priority() {
2314 let policies = vec![
2315 allow_policy("allow:*", "Allow All", 10),
2316 deny_policy("deny:*", "Deny All", 10),
2317 ];
2318 let compiled = PolicyEngine::compile_policies(&policies, false).unwrap();
2319 assert!(matches!(compiled[0].policy.policy_type, PolicyType::Deny));
2321 assert!(matches!(compiled[1].policy.policy_type, PolicyType::Allow));
2322 }
2323
2324 #[test]
2325 fn test_compile_policies_stable_sort_by_id_at_same_priority_and_type() {
2326 let policies = vec![allow_policy("b:*", "B", 10), allow_policy("a:*", "A", 10)];
2327 let compiled = PolicyEngine::compile_policies(&policies, false).unwrap();
2328 assert_eq!(compiled[0].policy.id, "a:*");
2330 assert_eq!(compiled[1].policy.id, "b:*");
2331 }
2332
2333 #[test]
2338 fn test_compile_policies_valid_path_globs_compile() {
2339 let mut policy = allow_policy("tool:fs", "FS Policy", 10);
2340 policy.path_rules = Some(PathRules {
2341 allowed: vec!["/home/**".to_string(), "/tmp/*".to_string()],
2342 blocked: vec!["/etc/shadow".to_string()],
2343 });
2344 let result = PolicyEngine::compile_policies(&[policy], false);
2345 assert!(result.is_ok());
2346 let compiled = result.unwrap();
2347 let path_rules = compiled[0].compiled_path_rules.as_ref().unwrap();
2348 assert_eq!(path_rules.allowed.len(), 2);
2349 assert_eq!(path_rules.blocked.len(), 1);
2350 }
2351
2352 #[test]
2353 fn test_compile_policies_invalid_path_glob_fails() {
2354 let mut policy = allow_policy("tool:fs", "FS Policy", 10);
2355 policy.path_rules = Some(PathRules {
2356 allowed: vec!["[invalid".to_string()],
2357 blocked: vec![],
2358 });
2359 let result = PolicyEngine::compile_policies(&[policy], false);
2360 assert!(result.is_err());
2361 let errors = result.unwrap_err();
2362 assert!(errors[0].reason.contains("Invalid allowed path glob"));
2363 }
2364
2365 #[test]
2366 fn test_compile_policies_invalid_blocked_path_glob_fails() {
2367 let mut policy = allow_policy("tool:fs", "FS Policy", 10);
2368 policy.path_rules = Some(PathRules {
2369 allowed: vec![],
2370 blocked: vec!["[bad-glob".to_string()],
2371 });
2372 let result = PolicyEngine::compile_policies(&[policy], false);
2373 assert!(result.is_err());
2374 let errors = result.unwrap_err();
2375 assert!(errors[0].reason.contains("Invalid blocked path glob"));
2376 }
2377
2378 #[test]
2383 fn test_compile_constraint_valid_regex_succeeds() {
2384 let conditions = json!({
2385 "parameter_constraints": [{
2386 "param": "url",
2387 "op": "regex",
2388 "pattern": "^https://.*\\.example\\.com$"
2389 }]
2390 });
2391 let policy = conditional_policy("p1", "Regex Test", 10, conditions);
2392 let result = PolicyEngine::compile_policies(&[policy], false);
2393 assert!(result.is_ok());
2394 }
2395
2396 #[test]
2397 fn test_compile_constraint_invalid_regex_fails() {
2398 let conditions = json!({
2399 "parameter_constraints": [{
2400 "param": "url",
2401 "op": "regex",
2402 "pattern": "(unclosed"
2403 }]
2404 });
2405 let policy = conditional_policy("p1", "Bad Regex", 10, conditions);
2406 let result = PolicyEngine::compile_policies(&[policy], false);
2407 assert!(result.is_err());
2408 let errors = result.unwrap_err();
2409 assert!(errors[0].reason.contains("Invalid regex pattern"));
2410 }
2411
2412 #[test]
2413 fn test_compile_constraint_redos_regex_rejected() {
2414 let conditions = json!({
2416 "parameter_constraints": [{
2417 "param": "input",
2418 "op": "regex",
2419 "pattern": "(a+)+"
2420 }]
2421 });
2422 let policy = conditional_policy("p1", "ReDoS", 10, conditions);
2423 let result = PolicyEngine::compile_policies(&[policy], false);
2424 assert!(result.is_err());
2425 }
2427
2428 #[test]
2433 fn test_compile_policies_valid_network_domains() {
2434 let mut policy = allow_policy("tool:http", "HTTP Policy", 10);
2435 policy.network_rules = Some(NetworkRules {
2436 allowed_domains: vec!["example.com".to_string(), "*.example.org".to_string()],
2437 blocked_domains: vec!["evil.com".to_string()],
2438 ip_rules: None,
2439 });
2440 let result = PolicyEngine::compile_policies(&[policy], false);
2441 assert!(result.is_ok());
2442 let compiled = result.unwrap();
2443 let net_rules = compiled[0].compiled_network_rules.as_ref().unwrap();
2444 assert_eq!(net_rules.allowed_domains.len(), 2);
2445 assert_eq!(net_rules.blocked_domains.len(), 1);
2446 }
2447
2448 #[test]
2449 fn test_compile_policies_invalid_domain_pattern_fails() {
2450 let mut policy = allow_policy("tool:http", "HTTP Policy", 10);
2451 policy.network_rules = Some(NetworkRules {
2452 allowed_domains: vec!["".to_string()], blocked_domains: vec![],
2454 ip_rules: None,
2455 });
2456 let result = PolicyEngine::compile_policies(&[policy], false);
2457 assert!(result.is_err());
2458 let errors = result.unwrap_err();
2459 assert!(errors[0].reason.contains("Invalid domain pattern"));
2460 }
2461
2462 #[test]
2463 fn test_compile_constraint_domain_match_valid() {
2464 let conditions = json!({
2465 "parameter_constraints": [{
2466 "param": "host",
2467 "op": "domain_match",
2468 "pattern": "*.example.com"
2469 }]
2470 });
2471 let policy = conditional_policy("p1", "Domain Match", 10, conditions);
2472 let result = PolicyEngine::compile_policies(&[policy], false);
2473 assert!(result.is_ok());
2474 }
2475
2476 #[test]
2481 fn test_compile_policies_valid_cidr_rules() {
2482 let mut policy = allow_policy("tool:net", "Net Policy", 10);
2483 policy.network_rules = Some(NetworkRules {
2484 allowed_domains: vec![],
2485 blocked_domains: vec![],
2486 ip_rules: Some(IpRules {
2487 block_private: true,
2488 blocked_cidrs: vec!["10.0.0.0/8".to_string()],
2489 allowed_cidrs: vec!["192.168.1.0/24".to_string()],
2490 }),
2491 });
2492 let result = PolicyEngine::compile_policies(&[policy], false);
2493 assert!(result.is_ok());
2494 let compiled = result.unwrap();
2495 let ip_rules = compiled[0].compiled_ip_rules.as_ref().unwrap();
2496 assert!(ip_rules.block_private);
2497 assert_eq!(ip_rules.blocked_cidrs.len(), 1);
2498 assert_eq!(ip_rules.allowed_cidrs.len(), 1);
2499 }
2500
2501 #[test]
2502 fn test_compile_policies_invalid_blocked_cidr_fails() {
2503 let mut policy = allow_policy("tool:net", "Net Policy", 10);
2504 policy.network_rules = Some(NetworkRules {
2505 allowed_domains: vec![],
2506 blocked_domains: vec![],
2507 ip_rules: Some(IpRules {
2508 block_private: false,
2509 blocked_cidrs: vec!["not-a-cidr".to_string()],
2510 allowed_cidrs: vec![],
2511 }),
2512 });
2513 let result = PolicyEngine::compile_policies(&[policy], false);
2514 assert!(result.is_err());
2515 let errors = result.unwrap_err();
2516 assert!(errors[0].reason.contains("Invalid blocked CIDR"));
2517 }
2518
2519 #[test]
2520 fn test_compile_policies_invalid_allowed_cidr_fails() {
2521 let mut policy = allow_policy("tool:net", "Net Policy", 10);
2522 policy.network_rules = Some(NetworkRules {
2523 allowed_domains: vec![],
2524 blocked_domains: vec![],
2525 ip_rules: Some(IpRules {
2526 block_private: false,
2527 blocked_cidrs: vec![],
2528 allowed_cidrs: vec!["999.999.999.999/32".to_string()],
2529 }),
2530 });
2531 let result = PolicyEngine::compile_policies(&[policy], false);
2532 assert!(result.is_err());
2533 let errors = result.unwrap_err();
2534 assert!(errors[0].reason.contains("Invalid allowed CIDR"));
2535 }
2536
2537 #[test]
2538 fn test_compile_policies_ipv6_cidr_succeeds() {
2539 let mut policy = allow_policy("tool:net", "Net Policy", 10);
2540 policy.network_rules = Some(NetworkRules {
2541 allowed_domains: vec![],
2542 blocked_domains: vec![],
2543 ip_rules: Some(IpRules {
2544 block_private: false,
2545 blocked_cidrs: vec!["2001:db8::/32".to_string()],
2546 allowed_cidrs: vec!["fd00::/8".to_string()],
2547 }),
2548 });
2549 let result = PolicyEngine::compile_policies(&[policy], false);
2550 assert!(result.is_ok());
2551 }
2552
2553 #[test]
2558 fn test_compile_context_condition_time_window_valid() {
2559 let conditions = json!({
2560 "context_conditions": [{
2561 "type": "time_window",
2562 "start_hour": 9,
2563 "end_hour": 17,
2564 "days": [1, 2, 3, 4, 5]
2565 }]
2566 });
2567 let policy = conditional_policy("p1", "Business Hours", 10, conditions);
2568 let result = PolicyEngine::compile_policies(&[policy], false);
2569 assert!(result.is_ok());
2570 }
2571
2572 #[test]
2573 fn test_compile_context_condition_time_window_invalid_hour_over_23() {
2574 let conditions = json!({
2575 "context_conditions": [{
2576 "type": "time_window",
2577 "start_hour": 25,
2578 "end_hour": 17
2579 }]
2580 });
2581 let policy = conditional_policy("p1", "Bad Hours", 10, conditions);
2582 let result = PolicyEngine::compile_policies(&[policy], false);
2583 assert!(result.is_err());
2584 let errors = result.unwrap_err();
2585 assert!(errors[0].reason.contains("hours must be 0-23"));
2586 }
2587
2588 #[test]
2589 fn test_compile_context_condition_time_window_equal_hours_rejected() {
2590 let conditions = json!({
2592 "context_conditions": [{
2593 "type": "time_window",
2594 "start_hour": 10,
2595 "end_hour": 10
2596 }]
2597 });
2598 let policy = conditional_policy("p1", "Zero Window", 10, conditions);
2599 let result = PolicyEngine::compile_policies(&[policy], false);
2600 assert!(result.is_err());
2601 let errors = result.unwrap_err();
2602 assert!(errors[0]
2603 .reason
2604 .contains("start_hour and end_hour must differ"));
2605 }
2606
2607 #[test]
2608 fn test_compile_context_condition_time_window_invalid_day_rejected() {
2609 let conditions = json!({
2610 "context_conditions": [{
2611 "type": "time_window",
2612 "start_hour": 9,
2613 "end_hour": 17,
2614 "days": [0]
2615 }]
2616 });
2617 let policy = conditional_policy("p1", "Bad Day", 10, conditions);
2618 let result = PolicyEngine::compile_policies(&[policy], false);
2619 assert!(result.is_err());
2620 let errors = result.unwrap_err();
2621 assert!(errors[0].reason.contains("day value must be 1-7"));
2622 }
2623
2624 #[test]
2629 fn test_compile_constraint_invalid_on_match_rejected() {
2630 let conditions = json!({
2631 "parameter_constraints": [{
2632 "param": "path",
2633 "op": "glob",
2634 "pattern": "/safe/*",
2635 "on_match": "alow"
2636 }]
2637 });
2638 let policy = conditional_policy("p1", "Typo on_match", 10, conditions);
2639 let result = PolicyEngine::compile_policies(&[policy], false);
2640 assert!(result.is_err());
2641 let errors = result.unwrap_err();
2642 assert!(errors[0].reason.contains("on_match"));
2643 assert!(errors[0].reason.contains("alow"));
2644 }
2645
2646 #[test]
2647 fn test_compile_constraint_invalid_on_missing_rejected() {
2648 let conditions = json!({
2649 "parameter_constraints": [{
2650 "param": "path",
2651 "op": "glob",
2652 "pattern": "/safe/*",
2653 "on_missing": "ignore"
2654 }]
2655 });
2656 let policy = conditional_policy("p1", "Bad on_missing", 10, conditions);
2657 let result = PolicyEngine::compile_policies(&[policy], false);
2658 assert!(result.is_err());
2659 let errors = result.unwrap_err();
2660 assert!(errors[0].reason.contains("on_missing"));
2661 assert!(errors[0].reason.contains("ignore"));
2662 }
2663
2664 #[test]
2665 fn test_compile_constraint_valid_on_match_require_approval() {
2666 let conditions = json!({
2667 "parameter_constraints": [{
2668 "param": "path",
2669 "op": "glob",
2670 "pattern": "/safe/*",
2671 "on_match": "require_approval",
2672 "on_missing": "skip"
2673 }]
2674 });
2675 let policy = conditional_policy("p1", "Approval", 10, conditions);
2676 let result = PolicyEngine::compile_policies(&[policy], false);
2677 assert!(result.is_ok());
2678 }
2679
2680 #[test]
2685 fn test_compile_policies_accumulates_errors_across_policies() {
2686 let mut bad_glob = allow_policy("p1", "Bad Glob", 10);
2687 bad_glob.path_rules = Some(PathRules {
2688 allowed: vec!["[invalid".to_string()],
2689 blocked: vec![],
2690 });
2691 let mut bad_cidr = allow_policy("p2", "Bad CIDR", 10);
2692 bad_cidr.network_rules = Some(NetworkRules {
2693 allowed_domains: vec![],
2694 blocked_domains: vec![],
2695 ip_rules: Some(IpRules {
2696 block_private: false,
2697 blocked_cidrs: vec!["not-valid".to_string()],
2698 allowed_cidrs: vec![],
2699 }),
2700 });
2701 let result = PolicyEngine::compile_policies(&[bad_glob, bad_cidr], false);
2702 assert!(result.is_err());
2703 let errors = result.unwrap_err();
2704 assert_eq!(errors.len(), 2);
2706 assert_eq!(errors[0].policy_id, "p1");
2707 assert_eq!(errors[1].policy_id, "p2");
2708 }
2709
2710 #[test]
2715 fn test_compile_policies_no_path_or_network_rules() {
2716 let policy = allow_policy("tool:*", "Minimal", 10);
2717 let result = PolicyEngine::compile_policies(&[policy], false);
2718 assert!(result.is_ok());
2719 let compiled = result.unwrap();
2720 assert!(compiled[0].compiled_path_rules.is_none());
2721 assert!(compiled[0].compiled_network_rules.is_none());
2722 assert!(compiled[0].compiled_ip_rules.is_none());
2723 }
2724
2725 #[test]
2726 fn test_compile_policies_policy_name_too_long_rejected() {
2727 let long_name = "x".repeat(513);
2728 let policy = allow_policy("p1", &long_name, 10);
2729 let result = PolicyEngine::compile_policies(&[policy], false);
2730 assert!(result.is_err());
2731 let errors = result.unwrap_err();
2732 assert!(errors[0].reason.contains("Policy name is 513 bytes"));
2733 }
2734
2735 #[test]
2736 fn test_compile_constraint_unknown_operator_rejected() {
2737 let conditions = json!({
2738 "parameter_constraints": [{
2739 "param": "foo",
2740 "op": "fuzzy_match"
2741 }]
2742 });
2743 let policy = conditional_policy("p1", "Unknown Op", 10, conditions);
2744 let result = PolicyEngine::compile_policies(&[policy], false);
2745 assert!(result.is_err());
2746 let errors = result.unwrap_err();
2747 assert!(errors[0].reason.contains("Unknown constraint operator"));
2748 assert!(errors[0].reason.contains("fuzzy_match"));
2749 }
2750
2751 #[test]
2752 fn test_compile_context_condition_unknown_type_rejected() {
2753 let conditions = json!({
2754 "context_conditions": [{
2755 "type": "moon_phase"
2756 }]
2757 });
2758 let policy = conditional_policy("p1", "Unknown Ctx", 10, conditions);
2759 let result = PolicyEngine::compile_policies(&[policy], false);
2760 assert!(result.is_err());
2761 let errors = result.unwrap_err();
2762 assert!(errors[0].reason.contains("Unknown context condition type"));
2763 }
2764
2765 #[test]
2766 fn test_compile_conditions_json_depth_exceeded() {
2767 let mut val = json!("leaf");
2769 for _ in 0..12 {
2770 val = json!({ "nested": val });
2771 }
2772 let conditions = json!({
2773 "parameter_constraints": [val]
2774 });
2775 let policy = conditional_policy("p1", "Deep JSON", 10, conditions);
2776 let result = PolicyEngine::compile_policies(&[policy], false);
2777 assert!(result.is_err());
2778 let errors = result.unwrap_err();
2779 assert!(errors[0].reason.contains("maximum nesting depth"));
2780 }
2781
2782 #[test]
2783 fn test_compile_constraint_glob_in_constraint_invalid() {
2784 let conditions = json!({
2785 "parameter_constraints": [{
2786 "param": "path",
2787 "op": "glob",
2788 "pattern": "[invalid-glob"
2789 }]
2790 });
2791 let policy = conditional_policy("p1", "Bad Constraint Glob", 10, conditions);
2792 let result = PolicyEngine::compile_policies(&[policy], false);
2793 assert!(result.is_err());
2794 let errors = result.unwrap_err();
2795 assert!(errors[0].reason.contains("Invalid glob pattern"));
2796 }
2797
2798 #[test]
2799 fn test_compile_constraint_eq_compiles_successfully() {
2800 let conditions = json!({
2801 "parameter_constraints": [{
2802 "param": "mode",
2803 "op": "eq",
2804 "value": "read-only"
2805 }]
2806 });
2807 let policy = conditional_policy("p1", "Eq Test", 10, conditions);
2808 let result = PolicyEngine::compile_policies(&[policy], false);
2809 assert!(result.is_ok());
2810 }
2811
2812 #[test]
2813 fn test_compile_context_condition_max_calls_compiles() {
2814 let conditions = json!({
2815 "context_conditions": [{
2816 "type": "max_calls",
2817 "tool_pattern": "file:*",
2818 "max": 10
2819 }]
2820 });
2821 let policy = conditional_policy("p1", "Rate Limit", 10, conditions);
2822 let result = PolicyEngine::compile_policies(&[policy], false);
2823 assert!(result.is_ok());
2824 }
2825
2826 #[test]
2827 fn test_compile_context_condition_agent_id_compiles() {
2828 let conditions = json!({
2829 "context_conditions": [{
2830 "type": "agent_id",
2831 "allowed": ["agent-a", "agent-b"],
2832 "blocked": ["rogue-agent"]
2833 }]
2834 });
2835 let policy = conditional_policy("p1", "Agent Restrict", 10, conditions);
2836 let result = PolicyEngine::compile_policies(&[policy], false);
2837 assert!(result.is_ok());
2838 }
2839
2840 #[test]
2841 fn test_compile_conditional_require_approval_flag() {
2842 let conditions = json!({
2843 "require_approval": true
2844 });
2845 let policy = conditional_policy("p1", "Approval Policy", 10, conditions);
2846 let compiled = PolicyEngine::compile_policies(&[policy], false).unwrap();
2847 assert!(compiled[0].require_approval);
2848 }
2849
2850 #[test]
2851 fn test_compile_conditional_require_approval_non_bool_defaults_true() {
2852 let conditions = json!({
2854 "require_approval": "yes"
2855 });
2856 let policy = conditional_policy("p1", "Fail Closed", 10, conditions);
2857 let compiled = PolicyEngine::compile_policies(&[policy], false).unwrap();
2858 assert!(compiled[0].require_approval);
2859 }
2860
2861 #[test]
2862 fn test_compile_constraint_parameter_constraints_not_array_rejected() {
2863 let conditions = json!({
2864 "parameter_constraints": "not-an-array"
2865 });
2866 let policy = conditional_policy("p1", "Bad Constraints", 10, conditions);
2867 let result = PolicyEngine::compile_policies(&[policy], false);
2868 assert!(result.is_err());
2869 let errors = result.unwrap_err();
2870 assert!(errors[0]
2871 .reason
2872 .contains("parameter_constraints must be an array"));
2873 }
2874
2875 #[test]
2876 fn test_compile_constraint_missing_param_field_rejected() {
2877 let conditions = json!({
2878 "parameter_constraints": [{
2879 "op": "eq",
2880 "value": "test"
2881 }]
2882 });
2883 let policy = conditional_policy("p1", "No Param", 10, conditions);
2884 let result = PolicyEngine::compile_policies(&[policy], false);
2885 assert!(result.is_err());
2886 let errors = result.unwrap_err();
2887 assert!(errors[0].reason.contains("missing required 'param'"));
2888 }
2889
2890 #[test]
2891 fn test_compile_constraint_missing_op_field_rejected() {
2892 let conditions = json!({
2893 "parameter_constraints": [{
2894 "param": "path"
2895 }]
2896 });
2897 let policy = conditional_policy("p1", "No Op", 10, conditions);
2898 let result = PolicyEngine::compile_policies(&[policy], false);
2899 assert!(result.is_err());
2900 let errors = result.unwrap_err();
2901 assert!(errors[0].reason.contains("missing required 'op'"));
2902 }
2903}