1use chrono::{DateTime, Utc};
18use dashmap::DashMap;
19use serde::{Deserialize, Serialize};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum PolicyEffect {
29 Allow,
31 Deny,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case", tag = "type", content = "value")]
43pub enum PolicyScope {
44 Global,
46 Fighter(String),
48 WeightClass(String),
50 Tool(String),
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case", tag = "type")]
62pub enum PolicyCondition {
63 TimeWindow {
66 start_hour: u8,
68 end_hour: u8,
70 },
71 MaxInvocations {
74 count: u32,
76 window_secs: u64,
78 },
79 RequireCapability {
82 capability: String,
84 },
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct PolicyRule {
97 pub name: String,
99 pub effect: PolicyEffect,
101 pub tool_patterns: Vec<String>,
103 pub fighter_patterns: Vec<String>,
105 pub scope: PolicyScope,
107 pub priority: i32,
109 pub conditions: Vec<PolicyCondition>,
111 pub description: String,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct PolicyDecision {
122 pub allowed: bool,
124 pub matching_rules: Vec<String>,
126 pub denial_reason: Option<String>,
128}
129
130pub fn glob_list_matches(patterns: &[String], value: &str) -> bool {
138 for pattern_str in patterns {
139 if pattern_str == "*" || pattern_str == "**" {
140 return true;
141 }
142 if let Ok(pattern) = glob::Pattern::new(pattern_str)
143 && pattern.matches(value)
144 {
145 return true;
146 }
147 }
148 false
149}
150
151#[derive(Debug)]
163pub struct ToolPolicyEngine {
164 rules: Vec<PolicyRule>,
166 invocation_counts: DashMap<String, Vec<DateTime<Utc>>>,
169}
170
171impl ToolPolicyEngine {
172 pub fn new() -> Self {
174 Self {
175 rules: Vec::new(),
176 invocation_counts: DashMap::new(),
177 }
178 }
179
180 pub fn add_rule(&mut self, rule: PolicyRule) {
182 self.rules.push(rule);
183 }
184
185 pub fn remove_rule(&mut self, name: &str) -> bool {
187 let before = self.rules.len();
188 self.rules.retain(|r| r.name != name);
189 self.rules.len() < before
190 }
191
192 pub fn evaluate(
201 &self,
202 tool_name: &str,
203 fighter_name: &str,
204 capabilities: &[String],
205 ) -> PolicyDecision {
206 let matching = self.matching_rules(tool_name, fighter_name);
207
208 if matching.is_empty() {
209 return PolicyDecision {
210 allowed: true,
211 matching_rules: Vec::new(),
212 denial_reason: None,
213 };
214 }
215
216 let mut sorted: Vec<&PolicyRule> = matching;
218 sorted.sort_by(|a, b| b.priority.cmp(&a.priority));
219
220 let mut matched_names = Vec::new();
221 let mut denied = false;
222 let mut denial_reason: Option<String> = None;
223
224 let now = Utc::now();
225
226 for rule in &sorted {
227 if !self.check_conditions(rule, tool_name, fighter_name, capabilities, now) {
229 continue;
230 }
231
232 matched_names.push(rule.name.clone());
233
234 if rule.effect == PolicyEffect::Deny {
235 denied = true;
236 if denial_reason.is_none() {
237 denial_reason = Some(format!(
238 "denied by rule '{}': {}",
239 rule.name, rule.description
240 ));
241 }
242 }
243 }
244
245 if !denied {
247 for rule in &sorted {
248 for cond in &rule.conditions {
249 if let PolicyCondition::MaxInvocations { .. } = cond {
250 let key = format!("{}:{}:{}", rule.name, fighter_name, tool_name);
251 self.invocation_counts.entry(key).or_default().push(now);
252 }
253 }
254 }
255 }
256
257 PolicyDecision {
258 allowed: !denied,
259 matching_rules: matched_names,
260 denial_reason,
261 }
262 }
263
264 pub fn rules(&self) -> &[PolicyRule] {
266 &self.rules
267 }
268
269 pub fn matching_rules(&self, tool_name: &str, fighter_name: &str) -> Vec<&PolicyRule> {
272 self.rules
273 .iter()
274 .filter(|rule| {
275 glob_list_matches(&rule.tool_patterns, tool_name)
276 && glob_list_matches(&rule.fighter_patterns, fighter_name)
277 })
278 .collect()
279 }
280
281 fn check_conditions(
283 &self,
284 rule: &PolicyRule,
285 tool_name: &str,
286 fighter_name: &str,
287 capabilities: &[String],
288 now: DateTime<Utc>,
289 ) -> bool {
290 for condition in &rule.conditions {
291 match condition {
292 PolicyCondition::TimeWindow {
293 start_hour,
294 end_hour,
295 } => {
296 let current_hour = now.format("%H").to_string();
297 let hour: u8 = current_hour.parse().unwrap_or(0);
298 let in_window = if start_hour <= end_hour {
299 hour >= *start_hour && hour < *end_hour
301 } else {
302 hour >= *start_hour || hour < *end_hour
304 };
305 if !in_window {
306 return false;
307 }
308 }
309 PolicyCondition::MaxInvocations { count, window_secs } => {
310 let key = format!("{}:{}:{}", rule.name, fighter_name, tool_name);
311 let cutoff = now - chrono::Duration::seconds(*window_secs as i64);
312 if let Some(timestamps) = self.invocation_counts.get(&key) {
313 let recent_count =
314 timestamps.iter().filter(|t| **t >= cutoff).count() as u32;
315 if recent_count < *count {
316 return false;
320 }
321 } else {
322 return false;
324 }
325 }
326 PolicyCondition::RequireCapability { capability } => {
327 if !capabilities.contains(capability) {
328 return false;
329 }
330 }
331 }
332 }
333 true
334 }
335}
336
337impl Default for ToolPolicyEngine {
338 fn default() -> Self {
339 Self::new()
340 }
341}
342
343pub fn default_safety_rules() -> Vec<PolicyRule> {
355 vec![
356 PolicyRule {
357 name: "deny-shell-readonly".to_string(),
358 effect: PolicyEffect::Deny,
359 tool_patterns: vec!["shell_exec".to_string()],
360 fighter_patterns: vec!["*-readonly".to_string()],
361 scope: PolicyScope::Global,
362 priority: 0,
363 conditions: Vec::new(),
364 description: "Readonly fighters cannot execute shell commands".to_string(),
365 },
366 PolicyRule {
367 name: "deny-filewrite-readonly".to_string(),
368 effect: PolicyEffect::Deny,
369 tool_patterns: vec!["file_write".to_string()],
370 fighter_patterns: vec!["*-readonly".to_string()],
371 scope: PolicyScope::Global,
372 priority: 0,
373 conditions: Vec::new(),
374 description: "Readonly fighters cannot write files".to_string(),
375 },
376 PolicyRule {
377 name: "allow-all-admin".to_string(),
378 effect: PolicyEffect::Allow,
379 tool_patterns: vec!["*".to_string()],
380 fighter_patterns: vec!["*-admin".to_string()],
381 scope: PolicyScope::Global,
382 priority: 0,
383 conditions: Vec::new(),
384 description: "Admin fighters have unrestricted access to all moves".to_string(),
385 },
386 PolicyRule {
387 name: "deny-agent-spawn-default".to_string(),
388 effect: PolicyEffect::Deny,
389 tool_patterns: vec!["agent_spawn".to_string()],
390 fighter_patterns: vec!["*".to_string()],
391 scope: PolicyScope::Global,
392 priority: -10,
393 conditions: Vec::new(),
394 description:
395 "Agent spawning is denied by default — override with a higher-priority allow rule"
396 .to_string(),
397 },
398 ]
399}
400
401#[cfg(test)]
406mod tests {
407 use super::*;
408
409 #[test]
412 fn test_no_rules_allows_all() {
413 let engine = ToolPolicyEngine::new();
414 let decision = engine.evaluate("shell_exec", "worker-1", &[]);
415 assert!(decision.allowed);
416 assert!(decision.matching_rules.is_empty());
417 assert!(decision.denial_reason.is_none());
418 }
419
420 #[test]
423 fn test_deny_rule_blocks_tool() {
424 let mut engine = ToolPolicyEngine::new();
425 engine.add_rule(PolicyRule {
426 name: "block-shell".to_string(),
427 effect: PolicyEffect::Deny,
428 tool_patterns: vec!["shell_exec".to_string()],
429 fighter_patterns: vec!["*".to_string()],
430 scope: PolicyScope::Global,
431 priority: 0,
432 conditions: Vec::new(),
433 description: "No shell access in the ring".to_string(),
434 });
435
436 let decision = engine.evaluate("shell_exec", "worker-1", &[]);
437 assert!(!decision.allowed);
438 assert!(decision.denial_reason.is_some());
439 }
440
441 #[test]
444 fn test_deny_wins_over_allow() {
445 let mut engine = ToolPolicyEngine::new();
446 engine.add_rule(PolicyRule {
447 name: "allow-all".to_string(),
448 effect: PolicyEffect::Allow,
449 tool_patterns: vec!["*".to_string()],
450 fighter_patterns: vec!["*".to_string()],
451 scope: PolicyScope::Global,
452 priority: 100,
453 conditions: Vec::new(),
454 description: "Allow everything".to_string(),
455 });
456 engine.add_rule(PolicyRule {
457 name: "deny-shell".to_string(),
458 effect: PolicyEffect::Deny,
459 tool_patterns: vec!["shell_exec".to_string()],
460 fighter_patterns: vec!["*".to_string()],
461 scope: PolicyScope::Global,
462 priority: 0,
463 conditions: Vec::new(),
464 description: "But not shell".to_string(),
465 });
466
467 let decision = engine.evaluate("shell_exec", "worker-1", &[]);
468 assert!(
469 !decision.allowed,
470 "deny must win even when allow has higher priority"
471 );
472 assert!(decision.matching_rules.contains(&"allow-all".to_string()));
473 assert!(decision.matching_rules.contains(&"deny-shell".to_string()));
474 }
475
476 #[test]
479 fn test_priority_ordering() {
480 let mut engine = ToolPolicyEngine::new();
481 engine.add_rule(PolicyRule {
482 name: "low-priority".to_string(),
483 effect: PolicyEffect::Deny,
484 tool_patterns: vec!["*".to_string()],
485 fighter_patterns: vec!["*".to_string()],
486 scope: PolicyScope::Global,
487 priority: -10,
488 conditions: Vec::new(),
489 description: "Low priority deny".to_string(),
490 });
491 engine.add_rule(PolicyRule {
492 name: "high-priority".to_string(),
493 effect: PolicyEffect::Deny,
494 tool_patterns: vec!["*".to_string()],
495 fighter_patterns: vec!["*".to_string()],
496 scope: PolicyScope::Global,
497 priority: 100,
498 conditions: Vec::new(),
499 description: "High priority deny".to_string(),
500 });
501
502 let decision = engine.evaluate("file_read", "worker-1", &[]);
503 assert!(!decision.allowed);
504 assert_eq!(decision.matching_rules[0], "high-priority");
506 assert_eq!(decision.matching_rules[1], "low-priority");
507 }
508
509 #[test]
512 fn test_glob_tool_pattern_matching() {
513 let mut engine = ToolPolicyEngine::new();
514 engine.add_rule(PolicyRule {
515 name: "deny-shell-star".to_string(),
516 effect: PolicyEffect::Deny,
517 tool_patterns: vec!["shell_*".to_string()],
518 fighter_patterns: vec!["*".to_string()],
519 scope: PolicyScope::Global,
520 priority: 0,
521 conditions: Vec::new(),
522 description: "Deny all shell moves".to_string(),
523 });
524
525 let shell_decision = engine.evaluate("shell_exec", "worker-1", &[]);
526 assert!(
527 !shell_decision.allowed,
528 "shell_exec should be denied by shell_*"
529 );
530
531 let file_decision = engine.evaluate("file_read", "worker-1", &[]);
532 assert!(file_decision.allowed, "file_read should not match shell_*");
533 }
534
535 #[test]
538 fn test_glob_wildcard_matches_everything() {
539 let mut engine = ToolPolicyEngine::new();
540 engine.add_rule(PolicyRule {
541 name: "deny-all".to_string(),
542 effect: PolicyEffect::Deny,
543 tool_patterns: vec!["*".to_string()],
544 fighter_patterns: vec!["*".to_string()],
545 scope: PolicyScope::Global,
546 priority: 0,
547 conditions: Vec::new(),
548 description: "Lockdown".to_string(),
549 });
550
551 for tool in &["file_read", "shell_exec", "web_fetch", "agent_spawn"] {
552 let decision = engine.evaluate(tool, "any-fighter", &[]);
553 assert!(!decision.allowed, "{} should be denied by wildcard", tool);
554 }
555 }
556
557 #[test]
560 fn test_fighter_pattern_matching() {
561 let mut engine = ToolPolicyEngine::new();
562 engine.add_rule(PolicyRule {
563 name: "deny-workers".to_string(),
564 effect: PolicyEffect::Deny,
565 tool_patterns: vec!["*".to_string()],
566 fighter_patterns: vec!["worker-*".to_string()],
567 scope: PolicyScope::Global,
568 priority: 0,
569 conditions: Vec::new(),
570 description: "Workers cannot use any tools".to_string(),
571 });
572
573 let worker_decision = engine.evaluate("file_read", "worker-1", &[]);
574 assert!(!worker_decision.allowed, "worker-1 should match worker-*");
575
576 let admin_decision = engine.evaluate("file_read", "admin-1", &[]);
577 assert!(admin_decision.allowed, "admin-1 should not match worker-*");
578 }
579
580 #[test]
583 fn test_time_window_condition() {
584 let mut engine = ToolPolicyEngine::new();
585 let current_hour = Utc::now()
586 .format("%H")
587 .to_string()
588 .parse::<u8>()
589 .unwrap_or(0);
590
591 let start = current_hour;
593 let end = if current_hour < 23 {
594 current_hour + 2
595 } else {
596 1
597 };
598
599 engine.add_rule(PolicyRule {
600 name: "deny-in-window".to_string(),
601 effect: PolicyEffect::Deny,
602 tool_patterns: vec!["*".to_string()],
603 fighter_patterns: vec!["*".to_string()],
604 scope: PolicyScope::Global,
605 priority: 0,
606 conditions: vec![PolicyCondition::TimeWindow {
607 start_hour: start,
608 end_hour: end,
609 }],
610 description: "Deny during active window".to_string(),
611 });
612
613 let decision = engine.evaluate("file_read", "worker-1", &[]);
615 assert!(!decision.allowed, "should be denied inside time window");
616
617 let mut engine2 = ToolPolicyEngine::new();
619 let outside_start = (current_hour + 3) % 24;
620 let outside_end = (current_hour + 5) % 24;
621
622 engine2.add_rule(PolicyRule {
623 name: "deny-outside-window".to_string(),
624 effect: PolicyEffect::Deny,
625 tool_patterns: vec!["*".to_string()],
626 fighter_patterns: vec!["*".to_string()],
627 scope: PolicyScope::Global,
628 priority: 0,
629 conditions: vec![PolicyCondition::TimeWindow {
630 start_hour: outside_start,
631 end_hour: outside_end,
632 }],
633 description: "Deny during a future window".to_string(),
634 });
635
636 let decision2 = engine2.evaluate("file_read", "worker-1", &[]);
637 assert!(decision2.allowed, "should be allowed outside time window");
638 }
639
640 #[test]
643 fn test_max_invocations_condition() {
644 let mut engine = ToolPolicyEngine::new();
645 engine.add_rule(PolicyRule {
646 name: "rate-limit-shell".to_string(),
647 effect: PolicyEffect::Deny,
648 tool_patterns: vec!["shell_exec".to_string()],
649 fighter_patterns: vec!["*".to_string()],
650 scope: PolicyScope::Global,
651 priority: 0,
652 conditions: vec![PolicyCondition::MaxInvocations {
653 count: 2,
654 window_secs: 3600,
655 }],
656 description: "Rate limit shell to 2 per hour".to_string(),
657 });
658
659 let d1 = engine.evaluate("shell_exec", "worker-1", &[]);
661 assert!(
662 d1.allowed,
663 "first call should be allowed (under rate limit)"
664 );
665
666 let d2 = engine.evaluate("shell_exec", "worker-1", &[]);
667 assert!(
668 d2.allowed,
669 "second call should be allowed (under rate limit)"
670 );
671
672 let d3 = engine.evaluate("shell_exec", "worker-1", &[]);
674 assert!(
675 !d3.allowed,
676 "third call should be denied (rate limit reached)"
677 );
678 }
679
680 #[test]
683 fn test_require_capability_condition() {
684 let mut engine = ToolPolicyEngine::new();
685 engine.add_rule(PolicyRule {
686 name: "deny-without-cap".to_string(),
687 effect: PolicyEffect::Deny,
688 tool_patterns: vec!["dangerous_tool".to_string()],
689 fighter_patterns: vec!["*".to_string()],
690 scope: PolicyScope::Global,
691 priority: 0,
692 conditions: vec![PolicyCondition::RequireCapability {
693 capability: "elevated_access".to_string(),
694 }],
695 description: "Deny dangerous_tool unless fighter has elevated_access".to_string(),
696 });
697
698 let with_cap = engine.evaluate(
700 "dangerous_tool",
701 "worker-1",
702 &["elevated_access".to_string()],
703 );
704 assert!(
705 !with_cap.allowed,
706 "fighter with capability matches the deny rule's condition"
707 );
708
709 let without_cap = engine.evaluate("dangerous_tool", "worker-1", &[]);
711 assert!(
712 without_cap.allowed,
713 "fighter without capability doesn't match the deny rule's condition"
714 );
715 }
716
717 #[test]
720 fn test_multiple_rules_different_scopes() {
721 let mut engine = ToolPolicyEngine::new();
722 engine.add_rule(PolicyRule {
723 name: "global-allow".to_string(),
724 effect: PolicyEffect::Allow,
725 tool_patterns: vec!["*".to_string()],
726 fighter_patterns: vec!["*".to_string()],
727 scope: PolicyScope::Global,
728 priority: 0,
729 conditions: Vec::new(),
730 description: "Global allow".to_string(),
731 });
732 engine.add_rule(PolicyRule {
733 name: "fighter-deny".to_string(),
734 effect: PolicyEffect::Deny,
735 tool_patterns: vec!["shell_exec".to_string()],
736 fighter_patterns: vec!["restricted-fighter".to_string()],
737 scope: PolicyScope::Fighter("restricted-fighter".to_string()),
738 priority: 10,
739 conditions: Vec::new(),
740 description: "Fighter-specific deny".to_string(),
741 });
742 engine.add_rule(PolicyRule {
743 name: "tool-deny".to_string(),
744 effect: PolicyEffect::Deny,
745 tool_patterns: vec!["file_delete".to_string()],
746 fighter_patterns: vec!["*".to_string()],
747 scope: PolicyScope::Tool("file_delete".to_string()),
748 priority: 5,
749 conditions: Vec::new(),
750 description: "Tool-specific deny".to_string(),
751 });
752
753 let d1 = engine.evaluate("shell_exec", "restricted-fighter", &[]);
755 assert!(!d1.allowed);
756
757 let d2 = engine.evaluate("file_read", "restricted-fighter", &[]);
759 assert!(d2.allowed);
760
761 let d3 = engine.evaluate("file_delete", "worker-1", &[]);
763 assert!(!d3.allowed);
764
765 let d4 = engine.evaluate("file_read", "worker-1", &[]);
767 assert!(d4.allowed);
768 }
769
770 #[test]
773 fn test_remove_rule() {
774 let mut engine = ToolPolicyEngine::new();
775 engine.add_rule(PolicyRule {
776 name: "deny-shell".to_string(),
777 effect: PolicyEffect::Deny,
778 tool_patterns: vec!["shell_exec".to_string()],
779 fighter_patterns: vec!["*".to_string()],
780 scope: PolicyScope::Global,
781 priority: 0,
782 conditions: Vec::new(),
783 description: "No shell".to_string(),
784 });
785
786 assert!(!engine.evaluate("shell_exec", "worker-1", &[]).allowed);
787
788 let removed = engine.remove_rule("deny-shell");
789 assert!(removed);
790 assert!(engine.evaluate("shell_exec", "worker-1", &[]).allowed);
791
792 let removed_again = engine.remove_rule("deny-shell");
794 assert!(!removed_again);
795 }
796
797 #[test]
800 fn test_default_safety_rules() {
801 let rules = default_safety_rules();
802 assert_eq!(rules.len(), 4);
803
804 let names: Vec<&str> = rules.iter().map(|r| r.name.as_str()).collect();
805 assert!(names.contains(&"deny-shell-readonly"));
806 assert!(names.contains(&"deny-filewrite-readonly"));
807 assert!(names.contains(&"allow-all-admin"));
808 assert!(names.contains(&"deny-agent-spawn-default"));
809
810 let mut engine = ToolPolicyEngine::new();
812 for rule in rules {
813 engine.add_rule(rule);
814 }
815
816 assert!(!engine.evaluate("shell_exec", "bot-readonly", &[]).allowed);
818 assert!(!engine.evaluate("file_write", "bot-readonly", &[]).allowed);
819
820 let admin_spawn = engine.evaluate("agent_spawn", "super-admin", &[]);
823 assert!(!admin_spawn.allowed, "deny wins even for admin");
824
825 assert!(!engine.evaluate("agent_spawn", "worker-1", &[]).allowed);
827 }
828
829 #[test]
832 fn test_policy_decision_includes_matching_rule_names() {
833 let mut engine = ToolPolicyEngine::new();
834 engine.add_rule(PolicyRule {
835 name: "rule-alpha".to_string(),
836 effect: PolicyEffect::Allow,
837 tool_patterns: vec!["file_*".to_string()],
838 fighter_patterns: vec!["*".to_string()],
839 scope: PolicyScope::Global,
840 priority: 10,
841 conditions: Vec::new(),
842 description: "Alpha rule".to_string(),
843 });
844 engine.add_rule(PolicyRule {
845 name: "rule-beta".to_string(),
846 effect: PolicyEffect::Allow,
847 tool_patterns: vec!["*".to_string()],
848 fighter_patterns: vec!["*".to_string()],
849 scope: PolicyScope::Global,
850 priority: 0,
851 conditions: Vec::new(),
852 description: "Beta rule".to_string(),
853 });
854
855 let decision = engine.evaluate("file_read", "worker-1", &[]);
856 assert!(decision.allowed);
857 assert!(decision.matching_rules.contains(&"rule-alpha".to_string()));
858 assert!(decision.matching_rules.contains(&"rule-beta".to_string()));
859 }
860
861 #[test]
864 fn test_policy_rule_serialization_roundtrip() {
865 let rule = PolicyRule {
866 name: "test-rule".to_string(),
867 effect: PolicyEffect::Deny,
868 tool_patterns: vec!["shell_*".to_string(), "file_write".to_string()],
869 fighter_patterns: vec!["worker-*".to_string()],
870 scope: PolicyScope::Fighter("worker-1".to_string()),
871 priority: 42,
872 conditions: vec![
873 PolicyCondition::TimeWindow {
874 start_hour: 9,
875 end_hour: 17,
876 },
877 PolicyCondition::MaxInvocations {
878 count: 10,
879 window_secs: 3600,
880 },
881 PolicyCondition::RequireCapability {
882 capability: "admin".to_string(),
883 },
884 ],
885 description: "A test fight rule".to_string(),
886 };
887
888 let json = serde_json::to_string(&rule).expect("serialization failed");
889 let deserialized: PolicyRule = serde_json::from_str(&json).expect("deserialization failed");
890
891 assert_eq!(deserialized.name, rule.name);
892 assert_eq!(deserialized.effect, rule.effect);
893 assert_eq!(deserialized.tool_patterns, rule.tool_patterns);
894 assert_eq!(deserialized.fighter_patterns, rule.fighter_patterns);
895 assert_eq!(deserialized.scope, rule.scope);
896 assert_eq!(deserialized.priority, rule.priority);
897 assert_eq!(deserialized.conditions.len(), 3);
898 assert_eq!(deserialized.description, rule.description);
899 }
900}