Skip to main content

punch_types/
tool_policy.rs

1//! Tool Policy Engine — the ring regulations for move control.
2//!
3//! A deny-wins, multi-layer policy engine that controls which moves (tools)
4//! fighters can throw during a bout. Policies are evaluated by priority, and
5//! if ANY matching rule denies a move, the overall decision is denial —
6//! the strictest referee always wins.
7//!
8//! ## Architecture
9//!
10//! - [`PolicyEffect`] determines whether a rule allows or denies a move
11//! - [`PolicyScope`] sets the blast radius of a rule (global, fighter, etc.)
12//! - [`PolicyCondition`] adds time, rate-limit, or capability constraints
13//! - [`PolicyRule`] combines patterns, effects, and conditions into a fight rule
14//! - [`ToolPolicyEngine`] evaluates all matching rules with deny-wins semantics
15//! - [`PolicyDecision`] reports the outcome and which rules matched
16
17use chrono::{DateTime, Utc};
18use dashmap::DashMap;
19use serde::{Deserialize, Serialize};
20
21// ---------------------------------------------------------------------------
22// Policy effect
23// ---------------------------------------------------------------------------
24
25/// The effect of a policy rule — does it let the move through or block it?
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum PolicyEffect {
29    /// The move is allowed — fight on.
30    Allow,
31    /// The move is denied — stand down, fighter.
32    Deny,
33}
34
35// ---------------------------------------------------------------------------
36// Policy scope
37// ---------------------------------------------------------------------------
38
39/// What level a policy rule applies at — from ring-wide regulations down to
40/// individual move restrictions.
41#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case", tag = "type", content = "value")]
43pub enum PolicyScope {
44    /// Applies to every fighter in the ring.
45    Global,
46    /// Applies to a specific fighter by name.
47    Fighter(String),
48    /// Applies to fighters in a specific weight class.
49    WeightClass(String),
50    /// Applies to a specific tool (move).
51    Tool(String),
52}
53
54// ---------------------------------------------------------------------------
55// Policy condition
56// ---------------------------------------------------------------------------
57
58/// Additional conditions that must be met for a policy rule to be active.
59/// These are the fine print in the fight contract.
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case", tag = "type")]
62pub enum PolicyCondition {
63    /// Rule is only active during certain hours (UTC). The fighter can only
64    /// throw this move during the scheduled bout window.
65    TimeWindow {
66        /// Start hour (0-23, UTC).
67        start_hour: u8,
68        /// End hour (0-23, UTC). If end < start, wraps past midnight.
69        end_hour: u8,
70    },
71    /// Rate limit — maximum invocations within a rolling time window.
72    /// Prevents a fighter from spamming the same move.
73    MaxInvocations {
74        /// Maximum number of invocations allowed.
75        count: u32,
76        /// Rolling window duration in seconds.
77        window_secs: u64,
78    },
79    /// The fighter must possess this capability to match this rule.
80    /// Like requiring a certain belt rank to enter the ring.
81    RequireCapability {
82        /// The capability name the fighter must hold.
83        capability: String,
84    },
85}
86
87// ---------------------------------------------------------------------------
88// Policy rule
89// ---------------------------------------------------------------------------
90
91/// A single fight rule that controls which moves fighters can throw.
92///
93/// Rules are matched by glob patterns against tool names and fighter names,
94/// and can carry additional conditions (time windows, rate limits, capabilities).
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct PolicyRule {
97    /// Human-readable name for this ring regulation.
98    pub name: String,
99    /// Whether this rule allows or denies the move.
100    pub effect: PolicyEffect,
101    /// Glob patterns matching tool (move) names. E.g. `"shell_*"`, `"file_write"`, `"*"`.
102    pub tool_patterns: Vec<String>,
103    /// Glob patterns matching fighter names. E.g. `"*"`, `"worker-*"`.
104    pub fighter_patterns: Vec<String>,
105    /// What level this rule applies at.
106    pub scope: PolicyScope,
107    /// Priority — higher priority rules are evaluated first. Default is 0.
108    pub priority: i32,
109    /// Additional conditions that must be met for this rule to be active.
110    pub conditions: Vec<PolicyCondition>,
111    /// Why this rule exists — the referee's rationale.
112    pub description: String,
113}
114
115// ---------------------------------------------------------------------------
116// Policy decision
117// ---------------------------------------------------------------------------
118
119/// The outcome of evaluating all ring regulations for a move.
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct PolicyDecision {
122    /// Whether the move is allowed.
123    pub allowed: bool,
124    /// Names of all rules that matched.
125    pub matching_rules: Vec<String>,
126    /// If denied, the reason the referee blocked the move.
127    pub denial_reason: Option<String>,
128}
129
130// ---------------------------------------------------------------------------
131// Glob helper
132// ---------------------------------------------------------------------------
133
134/// Check if a value matches any glob pattern in the list.
135/// Returns `true` if at least one pattern matches, like checking if a move
136/// is in the fighter's approved moveset.
137pub 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// ---------------------------------------------------------------------------
152// Tool Policy Engine
153// ---------------------------------------------------------------------------
154
155/// The ring regulation engine — evaluates fight rules to decide whether a
156/// fighter can throw a given move.
157///
158/// Uses deny-wins semantics: if ANY matching rule denies the move, the
159/// overall decision is denial. The strictest referee always prevails.
160///
161/// Thread-safe invocation tracking via `DashMap` supports concurrent bouts.
162#[derive(Debug)]
163pub struct ToolPolicyEngine {
164    /// All registered fight rules.
165    rules: Vec<PolicyRule>,
166    /// Per-key invocation timestamps for rate-limit conditions.
167    /// Key format: `"{rule_name}:{fighter_name}:{tool_name}"`.
168    invocation_counts: DashMap<String, Vec<DateTime<Utc>>>,
169}
170
171impl ToolPolicyEngine {
172    /// Create a new engine with no rules — an anything-goes ring.
173    pub fn new() -> Self {
174        Self {
175            rules: Vec::new(),
176            invocation_counts: DashMap::new(),
177        }
178    }
179
180    /// Add a fight rule to the ring regulations.
181    pub fn add_rule(&mut self, rule: PolicyRule) {
182        self.rules.push(rule);
183    }
184
185    /// Remove a fight rule by name. Returns `true` if a rule was removed.
186    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    /// Evaluate all matching rules for a tool invocation by a fighter.
193    ///
194    /// The referee checks every applicable rule, sorted by priority (highest
195    /// first). **Deny wins**: if any matching rule denies the move, the
196    /// overall result is denial regardless of allow rules.
197    ///
198    /// If no rules match at all, the default is to allow — permissive by
199    /// default, like an unsanctioned bout.
200    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        // Sort by priority descending (higher priority first).
217        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            // Check conditions — all must pass for the rule to be active.
228            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        // Record invocation for rate-limiting (only if allowed).
246        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    /// Get all currently registered fight rules.
265    pub fn rules(&self) -> &[PolicyRule] {
266        &self.rules
267    }
268
269    /// Get all rules whose tool and fighter patterns match the given names.
270    /// Does NOT check conditions — just pattern matching.
271    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    /// Check whether all conditions on a rule are satisfied.
282    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                        // Normal range, e.g. 9..17
300                        hour >= *start_hour && hour < *end_hour
301                    } else {
302                        // Wraps past midnight, e.g. 22..6
303                        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                            // Under the limit — condition not met (rule doesn't fire).
317                            // For a Deny rule with MaxInvocations, the deny only kicks
318                            // in when the limit is reached.
319                            return false;
320                        }
321                    } else {
322                        // No invocations recorded — under the limit.
323                        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
343// ---------------------------------------------------------------------------
344// Default safety rules
345// ---------------------------------------------------------------------------
346
347/// Returns a set of common-sense ring regulations — the standard safety
348/// rulebook that every well-run bout should start with.
349///
350/// - Deny `shell_exec` for readonly fighters
351/// - Deny `file_write` for readonly fighters
352/// - Allow all moves for admin fighters
353/// - Deny `agent_spawn` by default (low priority, overridable)
354pub 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// ---------------------------------------------------------------------------
402// Tests
403// ---------------------------------------------------------------------------
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    // -- Basic allow: no rules = allow all --
410
411    #[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    // -- Basic deny: deny rule blocks tool --
421
422    #[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    // -- Deny wins: allow + deny rules, deny takes precedence --
442
443    #[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    // -- Priority ordering: higher priority evaluated first --
477
478    #[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        // High-priority rule should be listed first in matching_rules.
505        assert_eq!(decision.matching_rules[0], "high-priority");
506        assert_eq!(decision.matching_rules[1], "low-priority");
507    }
508
509    // -- Glob matching: "shell_*" matches "shell_exec" but not "file_read" --
510
511    #[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    // -- Glob matching: "*" matches everything --
536
537    #[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    // -- Fighter pattern matching: "worker-*" matches "worker-1" but not "admin-1" --
558
559    #[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    // -- TimeWindow condition: inside window = active, outside = inactive --
581
582    #[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        // Create a window that includes the current hour.
592        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        // Current time is inside the window, so the deny rule should be active.
614        let decision = engine.evaluate("file_read", "worker-1", &[]);
615        assert!(!decision.allowed, "should be denied inside time window");
616
617        // Now create an engine with a window that excludes the current hour.
618        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    // -- MaxInvocations condition: under limit = allow, over limit = deny --
641
642    #[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        // First two calls: under the limit, deny condition not met, so allowed.
660        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        // Third call: at the limit, deny condition now met.
673        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    // -- RequireCapability condition: fighter with capability passes, without fails --
681
682    #[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        // Fighter WITH the capability: the condition is met, so the deny fires.
699        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        // Fighter WITHOUT the capability: the condition fails, rule doesn't fire.
710        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    // -- Multiple rules with different scopes --
718
719    #[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        // restricted-fighter + shell_exec = denied (fighter-specific rule).
754        let d1 = engine.evaluate("shell_exec", "restricted-fighter", &[]);
755        assert!(!d1.allowed);
756
757        // restricted-fighter + file_read = allowed (only global-allow matches).
758        let d2 = engine.evaluate("file_read", "restricted-fighter", &[]);
759        assert!(d2.allowed);
760
761        // any fighter + file_delete = denied (tool-specific rule).
762        let d3 = engine.evaluate("file_delete", "worker-1", &[]);
763        assert!(!d3.allowed);
764
765        // any fighter + file_read = allowed.
766        let d4 = engine.evaluate("file_read", "worker-1", &[]);
767        assert!(d4.allowed);
768    }
769
770    // -- Remove rule by name --
771
772    #[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        // Removing a non-existent rule returns false.
793        let removed_again = engine.remove_rule("deny-shell");
794        assert!(!removed_again);
795    }
796
797    // -- default_safety_rules returns expected rules --
798
799    #[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        // Verify the default rules work correctly in an engine.
811        let mut engine = ToolPolicyEngine::new();
812        for rule in rules {
813            engine.add_rule(rule);
814        }
815
816        // Readonly fighters should be denied shell and file_write.
817        assert!(!engine.evaluate("shell_exec", "bot-readonly", &[]).allowed);
818        assert!(!engine.evaluate("file_write", "bot-readonly", &[]).allowed);
819
820        // Admin fighters can use agent_spawn (allow-all-admin matches,
821        // but deny-agent-spawn-default also matches — deny wins).
822        let admin_spawn = engine.evaluate("agent_spawn", "super-admin", &[]);
823        assert!(!admin_spawn.allowed, "deny wins even for admin");
824
825        // agent_spawn is denied for everyone by default.
826        assert!(!engine.evaluate("agent_spawn", "worker-1", &[]).allowed);
827    }
828
829    // -- PolicyDecision includes matching rule names --
830
831    #[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    // -- Serialization round-trip of PolicyRule --
862
863    #[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}