1use regex::Regex;
7
8use crate::action::Action;
9use crate::decision::Decision;
10use crate::error::KvlarError;
11use crate::policy::{Condition, ConditionOperator, DefaultOutcome, Effect, MatchCriteria, Policy, Rule};
12
13const GLOB_META_CHARS: &[char] = &['*', '?', '['];
15
16fn glob_matches(pattern: &str, value: &str) -> bool {
20 if !pattern.contains(GLOB_META_CHARS) {
21 return pattern == value;
22 }
23 match glob_to_regex(pattern) {
24 Some(re) => re.is_match(value),
25 None => pattern == value,
26 }
27}
28
29fn glob_to_regex(pattern: &str) -> Option<Regex> {
32 let mut regex_str = String::with_capacity(pattern.len() + 4);
33 regex_str.push('^');
34
35 let mut chars = pattern.chars().peekable();
36 while let Some(c) = chars.next() {
37 match c {
38 '*' => regex_str.push_str(".*"),
39 '?' => regex_str.push('.'),
40 '[' => {
41 regex_str.push('[');
42 if chars.peek() == Some(&'!') {
43 chars.next();
44 regex_str.push('^');
45 }
46 let mut found_close = false;
47 for inner in chars.by_ref() {
48 regex_str.push(inner);
49 if inner == ']' {
50 found_close = true;
51 break;
52 }
53 }
54 if !found_close {
55 return None;
56 }
57 }
58 '.' | '+' | '^' | '$' | '|' | '\\' | '(' | ')' | '{' | '}' => {
59 regex_str.push('\\');
60 regex_str.push(c);
61 }
62 _ => regex_str.push(c),
63 }
64 }
65
66 regex_str.push('$');
67 Regex::new(®ex_str).ok()
68}
69
70fn any_pattern_matches(patterns: &[String], value: &str) -> bool {
72 patterns.iter().any(|p| glob_matches(p, value))
73}
74
75#[derive(Debug, Clone)]
81pub struct Engine {
82 policies: Vec<Policy>,
83}
84
85impl Engine {
86 pub fn new() -> Self {
88 Self {
89 policies: Vec::new(),
90 }
91 }
92
93 pub fn load_policy(&mut self, policy: Policy) {
95 self.policies.push(policy);
96 }
97
98 pub fn load_policy_yaml(&mut self, yaml: &str) -> Result<(), KvlarError> {
100 let policy = Policy::from_yaml(yaml)?;
101 self.load_policy(policy);
102 Ok(())
103 }
104
105 pub fn policy_count(&self) -> usize {
107 self.policies.len()
108 }
109
110 pub fn rule_count(&self) -> usize {
112 self.policies.iter().map(|p| p.rules.len()).sum()
113 }
114
115 pub fn evaluate(&self, action: &Action) -> Decision {
121 for policy in &self.policies {
122 for rule in &policy.rules {
123 if self.matches_rule(action, rule) {
124 return self.rule_to_decision(rule);
125 }
126 }
127 if !policy.rules.is_empty() {
130 match policy.default_outcome.as_ref().unwrap_or(&DefaultOutcome::Deny) {
131 DefaultOutcome::Allow => {
132 return Decision::Allow {
133 matched_rule: "_default_allow".into(),
134 };
135 }
136 DefaultOutcome::Deny => {
137 }
139 }
140 }
141 }
142
143 Decision::Deny {
145 reason: "no matching policy rule — denied by default (fail-closed)".into(),
146 matched_rule: "_default_deny".into(),
147 }
148 }
149
150 fn matches_rule(&self, action: &Action, rule: &Rule) -> bool {
152 self.matches_criteria(action, &rule.match_on)
153 }
154
155 fn matches_criteria(&self, action: &Action, criteria: &MatchCriteria) -> bool {
157 if !criteria.action_types.is_empty()
159 && !any_pattern_matches(&criteria.action_types, &action.action_type)
160 {
161 return false;
162 }
163
164 if !criteria.resources.is_empty()
166 && !any_pattern_matches(&criteria.resources, &action.resource)
167 {
168 return false;
169 }
170
171 if !criteria.agent_ids.is_empty()
173 && !any_pattern_matches(&criteria.agent_ids, &action.agent_id)
174 {
175 return false;
176 }
177
178 for (key, pattern) in &criteria.parameters {
180 match action.parameters.get(key) {
181 Some(value) => {
182 let value_str = match value {
183 serde_json::Value::String(s) => s.clone(),
184 other => other.to_string(),
185 };
186 match Regex::new(pattern) {
187 Ok(re) => {
188 if !re.is_match(&value_str) {
189 return false;
190 }
191 }
192 Err(_) => return false, }
194 }
195 None => return false, }
197 }
198
199 for condition in &criteria.conditions {
201 if !self.evaluate_condition(action, condition) {
202 return false;
203 }
204 }
205
206 true
207 }
208
209 fn evaluate_condition(&self, action: &Action, condition: &Condition) -> bool {
211 let field_value = self.resolve_field(action, &condition.field);
212
213 if condition.operator == ConditionOperator::NotIn {
215 return match field_value {
216 None => true, Some(ref fv) => {
218 if let Some(arr) = condition.value.as_array() {
219 !arr.contains(fv)
220 } else {
221 true
222 }
223 }
224 };
225 }
226
227 let Some(field_val) = field_value else {
228 return false;
229 };
230 self.compare_values(&field_val, &condition.operator, &condition.value)
231 }
232
233 fn resolve_field(&self, action: &Action, field: &str) -> Option<serde_json::Value> {
236 if let Some(value) = action.parameters.get(field) {
238 return Some(value.clone());
239 }
240
241 let parts: Vec<&str> = field.splitn(2, '.').collect();
243 if parts.len() == 2
244 && let Some(parent) = action.parameters.get(parts[0])
245 {
246 return Self::resolve_nested(parent, parts[1]);
247 }
248
249 None
250 }
251
252 fn resolve_nested(value: &serde_json::Value, path: &str) -> Option<serde_json::Value> {
254 let parts: Vec<&str> = path.splitn(2, '.').collect();
255 match value.get(parts[0]) {
256 Some(child) => {
257 if parts.len() == 1 {
258 Some(child.clone())
259 } else {
260 Self::resolve_nested(child, parts[1])
261 }
262 }
263 None => None,
264 }
265 }
266
267 fn compare_values(
269 &self,
270 field_val: &serde_json::Value,
271 operator: &ConditionOperator,
272 cond_val: &serde_json::Value,
273 ) -> bool {
274 match operator {
275 ConditionOperator::Eq => field_val == cond_val,
277 ConditionOperator::Neq => field_val != cond_val,
278
279 ConditionOperator::Contains => {
281 let field_str = field_val.as_str().unwrap_or("");
282 let cond_str = cond_val.as_str().unwrap_or("");
283 field_str.contains(cond_str)
284 }
285 ConditionOperator::StartsWith => {
286 let field_str = field_val.as_str().unwrap_or("");
287 let cond_str = cond_val.as_str().unwrap_or("");
288 field_str.starts_with(cond_str)
289 }
290 ConditionOperator::EndsWith => {
291 let field_str = field_val.as_str().unwrap_or("");
292 let cond_str = cond_val.as_str().unwrap_or("");
293 field_str.ends_with(cond_str)
294 }
295 ConditionOperator::Matches => {
296 let field_str = field_val.as_str().unwrap_or("");
297 let pattern = cond_val.as_str().unwrap_or("");
298 match Regex::new(pattern) {
299 Ok(re) => re.is_match(field_str),
300 Err(_) => false,
301 }
302 }
303
304 ConditionOperator::In => {
306 if let Some(arr) = cond_val.as_array() {
307 arr.contains(field_val)
308 } else {
309 false
310 }
311 }
312 ConditionOperator::NotIn => {
313 unreachable!("NotIn handled before compare_values")
315 }
316
317 ConditionOperator::InDomain => {
319 let field_str = field_val.as_str().unwrap_or("");
320 let host = extract_hostname(field_str);
321 match cond_val {
322 serde_json::Value::String(domain) => host_in_domain(&host, domain),
323 serde_json::Value::Array(domains) => domains.iter().any(|d| {
324 d.as_str().map(|domain| host_in_domain(&host, domain)).unwrap_or(false)
325 }),
326 _ => false,
327 }
328 }
329 ConditionOperator::NotInDomain => {
330 let field_str = field_val.as_str().unwrap_or("");
331 let host = extract_hostname(field_str);
332 match cond_val {
333 serde_json::Value::String(domain) => !host_in_domain(&host, domain),
334 serde_json::Value::Array(domains) => !domains.iter().any(|d| {
335 d.as_str().map(|domain| host_in_domain(&host, domain)).unwrap_or(false)
336 }),
337 _ => true,
338 }
339 }
340 }
341 }
342
343 fn rule_to_decision(&self, rule: &Rule) -> Decision {
345 match &rule.effect {
346 Effect::Allow => Decision::Allow {
347 matched_rule: rule.id.clone(),
348 },
349 Effect::Deny { reason } => Decision::Deny {
350 reason: reason.clone(),
351 matched_rule: rule.id.clone(),
352 },
353 Effect::RequireApproval { reason } => Decision::RequireApproval {
354 reason: reason.clone(),
355 matched_rule: rule.id.clone(),
356 },
357 }
358 }
359}
360
361fn extract_hostname(input: &str) -> String {
368 let without_scheme = if let Some(pos) = input.find("://") {
370 &input[pos + 3..]
371 } else {
372 input
373 };
374 let host_and_port = without_scheme.split('/').next().unwrap_or(without_scheme);
376 let host = if host_and_port.starts_with('[') {
378 host_and_port
380 .split(']')
381 .next()
382 .map(|s| s.trim_start_matches('['))
383 .unwrap_or(host_and_port)
384 } else {
385 host_and_port.split(':').next().unwrap_or(host_and_port)
386 };
387 host.to_lowercase()
388}
389
390fn host_in_domain(host: &str, domain: &str) -> bool {
397 let domain = domain.to_lowercase();
398 host == domain || host.ends_with(&format!(".{domain}"))
399}
400
401impl Default for Engine {
402 fn default() -> Self {
403 Self::new()
404 }
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410 use crate::action::Action;
411
412 fn test_policy_yaml() -> &'static str {
413 r#"
414name: test-policy
415description: Policy for unit tests
416version: "1.0"
417rules:
418 - id: deny-bash
419 description: Deny all bash commands
420 match_on:
421 action_types: ["tool_call"]
422 resources: ["bash"]
423 effect:
424 type: deny
425 reason: "Bash commands are not allowed"
426
427 - id: approve-email
428 description: Require approval for sending emails
429 match_on:
430 action_types: ["tool_call"]
431 resources: ["send_email"]
432 effect:
433 type: require_approval
434 reason: "Email sending requires human approval"
435
436 - id: allow-read
437 description: Allow file reads
438 match_on:
439 action_types: ["tool_call"]
440 resources: ["read_file"]
441 effect:
442 type: allow
443
444 - id: deny-rm-rf
445 description: Deny destructive rm commands
446 match_on:
447 action_types: ["tool_call"]
448 resources: ["bash"]
449 parameters:
450 command: "rm\\s+(-rf|--force)"
451 effect:
452 type: deny
453 reason: "Destructive rm commands are prohibited"
454"#
455 }
456
457 #[test]
458 fn test_engine_default_deny() {
459 let engine = Engine::new();
460 let action = Action::new("tool_call", "bash", "agent-1");
461 let decision = engine.evaluate(&action);
462 assert!(decision.is_denied());
463 }
464
465 #[test]
466 fn test_engine_deny_bash() {
467 let mut engine = Engine::new();
468 engine.load_policy_yaml(test_policy_yaml()).unwrap();
469
470 let action = Action::new("tool_call", "bash", "agent-1");
471 let decision = engine.evaluate(&action);
472
473 assert!(decision.is_denied());
474 if let Decision::Deny { matched_rule, .. } = &decision {
475 assert_eq!(matched_rule, "deny-bash");
476 }
477 }
478
479 #[test]
480 fn test_engine_require_approval_email() {
481 let mut engine = Engine::new();
482 engine.load_policy_yaml(test_policy_yaml()).unwrap();
483
484 let action = Action::new("tool_call", "send_email", "agent-1");
485 let decision = engine.evaluate(&action);
486
487 assert!(decision.requires_approval());
488 if let Decision::RequireApproval { matched_rule, .. } = &decision {
489 assert_eq!(matched_rule, "approve-email");
490 }
491 }
492
493 #[test]
494 fn test_engine_allow_read() {
495 let mut engine = Engine::new();
496 engine.load_policy_yaml(test_policy_yaml()).unwrap();
497
498 let action = Action::new("tool_call", "read_file", "agent-1");
499 let decision = engine.evaluate(&action);
500
501 assert!(decision.is_allowed());
502 if let Decision::Allow { matched_rule } = &decision {
503 assert_eq!(matched_rule, "allow-read");
504 }
505 }
506
507 #[test]
508 fn test_engine_unmatched_action_denied() {
509 let mut engine = Engine::new();
510 engine.load_policy_yaml(test_policy_yaml()).unwrap();
511
512 let action = Action::new("data_access", "database", "agent-1");
513 let decision = engine.evaluate(&action);
514
515 assert!(decision.is_denied());
516 if let Decision::Deny { matched_rule, .. } = &decision {
517 assert_eq!(matched_rule, "_default_deny");
518 }
519 }
520
521 #[test]
522 fn test_engine_parameter_matching() {
523 let mut engine = Engine::new();
524 engine.load_policy_yaml(test_policy_yaml()).unwrap();
525
526 let action = Action::new("tool_call", "bash", "agent-1")
528 .with_param("command", serde_json::Value::String("rm -rf /".into()));
529 let decision = engine.evaluate(&action);
530 assert!(decision.is_denied());
531 }
532
533 #[test]
534 fn test_engine_policy_count() {
535 let mut engine = Engine::new();
536 assert_eq!(engine.policy_count(), 0);
537 assert_eq!(engine.rule_count(), 0);
538
539 engine.load_policy_yaml(test_policy_yaml()).unwrap();
540 assert_eq!(engine.policy_count(), 1);
541 assert_eq!(engine.rule_count(), 4);
542 }
543
544 #[test]
545 fn test_engine_multiple_policies() {
546 let mut engine = Engine::new();
547
548 engine
550 .load_policy_yaml(
551 r#"
552name: policy-1
553description: First policy
554version: "1.0"
555rules:
556 - id: deny-bash
557 description: Deny bash
558 match_on:
559 resources: ["bash"]
560 effect:
561 type: deny
562 reason: "No bash"
563"#,
564 )
565 .unwrap();
566
567 engine
569 .load_policy_yaml(
570 r#"
571name: policy-2
572description: Second policy
573version: "1.0"
574rules:
575 - id: allow-all
576 description: Allow everything
577 match_on: {}
578 effect:
579 type: allow
580"#,
581 )
582 .unwrap();
583
584 assert_eq!(engine.policy_count(), 2);
585
586 let bash_action = Action::new("tool_call", "bash", "agent-1");
588 assert!(engine.evaluate(&bash_action).is_denied());
589
590 let read_action = Action::new("tool_call", "read_file", "agent-1");
592 assert!(engine.evaluate(&read_action).is_allowed());
593 }
594
595 #[test]
596 fn test_condition_equals() {
597 let mut engine = Engine::new();
598 engine
599 .load_policy_yaml(
600 r#"
601name: cond-test
602description: Condition test
603version: "1"
604rules:
605 - id: deny-sensitive-path
606 description: Deny access to /etc/passwd
607 match_on:
608 resources: ["read_file"]
609 conditions:
610 - field: path
611 operator: eq
612 value: "/etc/passwd"
613 effect:
614 type: deny
615 reason: "Sensitive file"
616 - id: allow-all
617 description: Allow everything else
618 match_on: {}
619 effect:
620 type: allow
621"#,
622 )
623 .unwrap();
624
625 let action = Action::new("tool_call", "read_file", "agent-1")
627 .with_param("path", serde_json::json!("/etc/passwd"));
628 assert!(engine.evaluate(&action).is_denied());
629
630 let action2 = Action::new("tool_call", "read_file", "agent-1")
632 .with_param("path", serde_json::json!("/tmp/safe.txt"));
633 assert!(engine.evaluate(&action2).is_allowed());
634 }
635
636 #[test]
637 fn test_condition_contains() {
638 let mut engine = Engine::new();
639 engine
640 .load_policy_yaml(
641 r#"
642name: cond-contains
643description: test
644version: "1"
645rules:
646 - id: deny-secret
647 description: Deny commands containing 'secret'
648 match_on:
649 conditions:
650 - field: command
651 operator: contains
652 value: "secret"
653 effect:
654 type: deny
655 reason: "Contains secret"
656 - id: allow-all
657 description: allow
658 match_on: {}
659 effect:
660 type: allow
661"#,
662 )
663 .unwrap();
664
665 let action = Action::new("tool_call", "bash", "a")
666 .with_param("command", serde_json::json!("cat /tmp/secret.txt"));
667 assert!(engine.evaluate(&action).is_denied());
668
669 let action2 = Action::new("tool_call", "bash", "a")
670 .with_param("command", serde_json::json!("ls /tmp"));
671 assert!(engine.evaluate(&action2).is_allowed());
672 }
673
674 #[test]
675 fn test_condition_matches() {
676 let mut engine = Engine::new();
677 engine
678 .load_policy_yaml(
679 r#"
680name: cond-matches
681description: test
682version: "1"
683rules:
684 - id: deny-sensitive-paths
685 description: Deny access to sensitive system paths via regex
686 match_on:
687 conditions:
688 - field: path
689 operator: matches
690 value: "^/(etc|root|proc)/"
691 effect:
692 type: deny
693 reason: "Sensitive system path"
694 - id: allow-all
695 description: allow
696 match_on: {}
697 effect:
698 type: allow
699"#,
700 )
701 .unwrap();
702
703 let action =
704 Action::new("tool_call", "read_file", "a").with_param("path", serde_json::json!("/etc/shadow"));
705 assert!(engine.evaluate(&action).is_denied());
706
707 let action2 =
708 Action::new("tool_call", "read_file", "a").with_param("path", serde_json::json!("/tmp/safe.txt"));
709 assert!(engine.evaluate(&action2).is_allowed());
710 }
711
712 #[test]
713 fn test_condition_not_in() {
714 let mut engine = Engine::new();
715 engine
716 .load_policy_yaml(
717 r#"
718name: cond-not-in
719description: test
720version: "1"
721rules:
722 - id: deny-non-approved-methods
723 description: Deny HTTP methods not in the approved list
724 match_on:
725 conditions:
726 - field: method
727 operator: not_in
728 value: ["GET", "POST"]
729 effect:
730 type: deny
731 reason: "HTTP method not approved"
732 - id: allow-all
733 description: allow
734 match_on: {}
735 effect:
736 type: allow
737"#,
738 )
739 .unwrap();
740
741 let action = Action::new("tool_call", "api_call", "a")
743 .with_param("method", serde_json::json!("DELETE"));
744 assert!(engine.evaluate(&action).is_denied());
745
746 let action2 = Action::new("tool_call", "api_call", "a")
748 .with_param("method", serde_json::json!("GET"));
749 assert!(engine.evaluate(&action2).is_allowed());
750 }
751
752 #[test]
753 fn test_condition_in() {
754 let mut engine = Engine::new();
755 engine
756 .load_policy_yaml(
757 r#"
758name: cond-in
759description: test
760version: "1"
761rules:
762 - id: deny-unsafe-methods
763 description: Deny unsafe HTTP methods
764 match_on:
765 conditions:
766 - field: method
767 operator: in
768 value: ["DELETE", "PUT", "PATCH"]
769 effect:
770 type: deny
771 reason: "Unsafe HTTP method"
772 - id: allow-all
773 description: allow
774 match_on: {}
775 effect:
776 type: allow
777"#,
778 )
779 .unwrap();
780
781 let action =
782 Action::new("tool_call", "http", "a").with_param("method", serde_json::json!("DELETE"));
783 assert!(engine.evaluate(&action).is_denied());
784
785 let action2 =
786 Action::new("tool_call", "http", "a").with_param("method", serde_json::json!("GET"));
787 assert!(engine.evaluate(&action2).is_allowed());
788 }
789
790 #[test]
791 fn test_condition_nested_field() {
792 let mut engine = Engine::new();
793 engine
794 .load_policy_yaml(
795 r#"
796name: cond-nested
797description: test
798version: "1"
799rules:
800 - id: deny-admin
801 description: Deny admin role
802 match_on:
803 conditions:
804 - field: user.role
805 operator: eq
806 value: "admin"
807 effect:
808 type: deny
809 reason: "Admin access denied"
810 - id: allow-all
811 description: allow
812 match_on: {}
813 effect:
814 type: allow
815"#,
816 )
817 .unwrap();
818
819 let action = Action::new("tool_call", "api", "a")
820 .with_param("user", serde_json::json!({"name": "root", "role": "admin"}));
821 assert!(engine.evaluate(&action).is_denied());
822
823 let action2 = Action::new("tool_call", "api", "a")
824 .with_param("user", serde_json::json!({"name": "bob", "role": "viewer"}));
825 assert!(engine.evaluate(&action2).is_allowed());
826 }
827
828 #[test]
831 fn test_glob_matches_helper() {
832 assert!(glob_matches("read_*", "read_file"));
834 assert!(glob_matches("read_*", "read_"));
835 assert!(!glob_matches("read_*", "write_file"));
836
837 assert!(glob_matches("?ead", "read"));
839 assert!(!glob_matches("?ead", "bread"));
840
841 assert!(glob_matches("[abc]_file", "a_file"));
843 assert!(!glob_matches("[abc]_file", "d_file"));
844
845 assert!(glob_matches("exact", "exact"));
847 assert!(!glob_matches("exact", "not_exact"));
848
849 assert!(glob_matches("file.txt", "file.txt"));
851 assert!(!glob_matches("file.txt", "filextxt"));
852 }
853
854 #[test]
855 fn test_glob_wildcard_star() {
856 let mut engine = Engine::new();
857 engine
858 .load_policy_yaml(
859 r#"
860name: glob-test
861description: test
862version: "1"
863rules:
864 - id: allow-reads
865 description: Allow all read operations
866 match_on:
867 resources: ["read_*"]
868 effect:
869 type: allow
870"#,
871 )
872 .unwrap();
873
874 assert!(
875 engine
876 .evaluate(&Action::new("t", "read_file", "a"))
877 .is_allowed()
878 );
879 assert!(
880 engine
881 .evaluate(&Action::new("t", "read_text_file", "a"))
882 .is_allowed()
883 );
884 assert!(
885 engine
886 .evaluate(&Action::new("t", "read_media_file", "a"))
887 .is_allowed()
888 );
889
890 assert!(
892 engine
893 .evaluate(&Action::new("t", "write_file", "a"))
894 .is_denied()
895 );
896 assert!(
897 engine
898 .evaluate(&Action::new("t", "pre_read_file", "a"))
899 .is_denied()
900 );
901 }
902
903 #[test]
904 fn test_glob_question_mark() {
905 let mut engine = Engine::new();
906 engine
907 .load_policy_yaml(
908 r#"
909name: glob-qmark
910description: test
911version: "1"
912rules:
913 - id: deny-db-x
914 description: Deny db single-char suffix
915 match_on:
916 resources: ["db_?"]
917 effect:
918 type: deny
919 reason: "denied"
920 - id: allow-all
921 match_on: {}
922 description: allow
923 effect:
924 type: allow
925"#,
926 )
927 .unwrap();
928
929 assert!(engine.evaluate(&Action::new("t", "db_x", "a")).is_denied());
930 assert!(
931 engine
932 .evaluate(&Action::new("t", "db_xy", "a"))
933 .is_allowed()
934 );
935 }
936
937 #[test]
938 fn test_glob_char_class() {
939 let mut engine = Engine::new();
940 engine
941 .load_policy_yaml(
942 r#"
943name: glob-class
944description: test
945version: "1"
946rules:
947 - id: deny-levels
948 description: Deny log levels
949 match_on:
950 resources: ["log_[abc]"]
951 effect:
952 type: deny
953 reason: "denied"
954 - id: allow-all
955 match_on: {}
956 description: allow
957 effect:
958 type: allow
959"#,
960 )
961 .unwrap();
962
963 assert!(engine.evaluate(&Action::new("t", "log_a", "a")).is_denied());
964 assert!(engine.evaluate(&Action::new("t", "log_b", "a")).is_denied());
965 assert!(
966 engine
967 .evaluate(&Action::new("t", "log_d", "a"))
968 .is_allowed()
969 );
970 }
971
972 #[test]
973 fn test_glob_exact_match_fast_path() {
974 let mut engine = Engine::new();
975 engine
976 .load_policy_yaml(
977 r#"
978name: exact
979description: test
980version: "1"
981rules:
982 - id: deny-bash
983 match_on:
984 resources: ["bash"]
985 description: deny
986 effect:
987 type: deny
988 reason: "no"
989"#,
990 )
991 .unwrap();
992
993 assert!(engine.evaluate(&Action::new("t", "bash", "a")).is_denied());
994 let decision = engine.evaluate(&Action::new("t", "basher", "a"));
996 assert!(decision.is_denied());
997 assert_eq!(decision.matched_rule(), "_default_deny");
998 }
999
1000 #[test]
1001 fn test_glob_on_agent_ids() {
1002 let mut engine = Engine::new();
1003 engine
1004 .load_policy_yaml(
1005 r#"
1006name: agent-glob
1007description: test
1008version: "1"
1009rules:
1010 - id: allow-trusted
1011 description: Allow trusted agents
1012 match_on:
1013 agent_ids: ["trusted-*"]
1014 effect:
1015 type: allow
1016"#,
1017 )
1018 .unwrap();
1019
1020 assert!(
1021 engine
1022 .evaluate(&Action::new("t", "x", "trusted-agent-1"))
1023 .is_allowed()
1024 );
1025 assert!(
1026 engine
1027 .evaluate(&Action::new("t", "x", "trusted-bot"))
1028 .is_allowed()
1029 );
1030 assert!(
1031 engine
1032 .evaluate(&Action::new("t", "x", "untrusted"))
1033 .is_denied()
1034 );
1035 }
1036
1037 #[test]
1038 fn test_glob_on_action_types() {
1039 let mut engine = Engine::new();
1040 engine
1041 .load_policy_yaml(
1042 r#"
1043name: action-glob
1044description: test
1045version: "1"
1046rules:
1047 - id: deny-file-ops
1048 description: Deny file operations
1049 match_on:
1050 action_types: ["file_*"]
1051 effect:
1052 type: deny
1053 reason: "no file ops"
1054 - id: allow-all
1055 match_on: {}
1056 description: allow
1057 effect:
1058 type: allow
1059"#,
1060 )
1061 .unwrap();
1062
1063 assert!(
1064 engine
1065 .evaluate(&Action::new("file_read", "x", "a"))
1066 .is_denied()
1067 );
1068 assert!(
1069 engine
1070 .evaluate(&Action::new("file_write", "x", "a"))
1071 .is_denied()
1072 );
1073 assert!(
1074 engine
1075 .evaluate(&Action::new("tool_call", "x", "a"))
1076 .is_allowed()
1077 );
1078 }
1079
1080 #[test]
1081 fn test_condition_in_domain() {
1082 let mut engine = Engine::new();
1083 engine
1084 .load_policy_yaml(
1085 r#"
1086name: cond-domain
1087description: test
1088version: "1"
1089rules:
1090 - id: allow-internal
1091 description: Allow requests to internal domains
1092 match_on:
1093 conditions:
1094 - field: url
1095 operator: in_domain
1096 value: "company.internal"
1097 effect:
1098 type: allow
1099 - id: deny-all
1100 description: Deny everything else
1101 match_on: {}
1102 effect:
1103 type: deny
1104 reason: "External domain"
1105"#,
1106 )
1107 .unwrap();
1108
1109 let action = Action::new("tool_call", "http", "a")
1111 .with_param("url", serde_json::json!("https://company.internal/api"));
1112 assert!(engine.evaluate(&action).is_allowed());
1113
1114 let action2 = Action::new("tool_call", "http", "a")
1116 .with_param("url", serde_json::json!("https://api.company.internal/v1/users"));
1117 assert!(engine.evaluate(&action2).is_allowed());
1118
1119 let action3 = Action::new("tool_call", "http", "a")
1121 .with_param("url", serde_json::json!("https://evil.com/steal"));
1122 assert!(engine.evaluate(&action3).is_denied());
1123
1124 let action4 = Action::new("tool_call", "http", "a")
1126 .with_param("url", serde_json::json!("https://notcompany.internal/"));
1127 assert!(engine.evaluate(&action4).is_denied());
1128 }
1129
1130 #[test]
1131 fn test_condition_not_in_domain() {
1132 let mut engine = Engine::new();
1133 engine
1134 .load_policy_yaml(
1135 r#"
1136name: cond-not-domain
1137description: test
1138version: "1"
1139rules:
1140 - id: deny-external
1141 description: Deny requests outside trusted domains
1142 match_on:
1143 conditions:
1144 - field: url
1145 operator: not_in_domain
1146 value: ["trusted.com", "safe.io"]
1147 effect:
1148 type: deny
1149 reason: "Untrusted external domain"
1150 - id: allow-all
1151 description: Allow trusted domains
1152 match_on: {}
1153 effect:
1154 type: allow
1155"#,
1156 )
1157 .unwrap();
1158
1159 let action = Action::new("tool_call", "http", "a")
1161 .with_param("url", serde_json::json!("https://api.trusted.com/data"));
1162 assert!(engine.evaluate(&action).is_allowed());
1163
1164 let action2 = Action::new("tool_call", "http", "a")
1166 .with_param("url", serde_json::json!("https://safe.io/endpoint"));
1167 assert!(engine.evaluate(&action2).is_allowed());
1168
1169 let action3 = Action::new("tool_call", "http", "a")
1171 .with_param("url", serde_json::json!("https://malicious.xyz/exfil"));
1172 assert!(engine.evaluate(&action3).is_denied());
1173 }
1174
1175 #[test]
1176 fn test_default_outcome_allow() {
1177 let mut engine = Engine::new();
1178 engine
1179 .load_policy_yaml(
1180 r#"
1181name: permissive
1182description: test
1183version: "1"
1184default_outcome: allow
1185rules:
1186 - id: deny-bash
1187 description: Deny bash
1188 match_on:
1189 resources: ["bash"]
1190 effect:
1191 type: deny
1192 reason: "No bash"
1193"#,
1194 )
1195 .unwrap();
1196
1197 assert!(engine.evaluate(&Action::new("t", "bash", "a")).is_denied());
1199
1200 assert!(engine.evaluate(&Action::new("t", "read_file", "a")).is_allowed());
1202 }
1203
1204 #[test]
1205 fn test_hostname_extraction() {
1206 assert_eq!(extract_hostname("https://api.example.com/path"), "api.example.com");
1207 assert_eq!(extract_hostname("http://example.com:8080/"), "example.com");
1208 assert_eq!(extract_hostname("example.com"), "example.com");
1209 assert_eq!(extract_hostname("https://UPPER.CASE.COM/"), "upper.case.com");
1210 }
1211
1212 #[test]
1213 fn test_host_in_domain_helper() {
1214 assert!(host_in_domain("example.com", "example.com"));
1215 assert!(host_in_domain("api.example.com", "example.com"));
1216 assert!(host_in_domain("deep.sub.example.com", "example.com"));
1217 assert!(!host_in_domain("notexample.com", "example.com"));
1218 assert!(!host_in_domain("evil.com", "example.com"));
1219 }
1220}