Skip to main content

agent_governance/
policy.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4//! YAML-based policy evaluation engine with four-way decisions:
5//! allow, deny, requires-approval, and rate-limit.
6
7use crate::types::{
8    CandidateDecision, ConflictResolutionStrategy, PolicyDecision, PolicyScope, ResolutionResult,
9};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::sync::{Mutex, RwLock};
13use std::time::Instant;
14
15/// A single rule inside a policy profile.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct PolicyRule {
18    pub name: String,
19    #[serde(rename = "type")]
20    pub rule_type: String,
21    #[serde(default)]
22    pub allowed_actions: Vec<String>,
23    #[serde(default)]
24    pub denied_actions: Vec<String>,
25    #[serde(default)]
26    pub actions: Vec<String>,
27    #[serde(default)]
28    pub min_approvals: u32,
29    #[serde(default)]
30    pub max_calls: u32,
31    #[serde(default)]
32    pub window: String,
33    #[serde(default)]
34    pub conditions: HashMap<String, serde_yaml::Value>,
35    /// Rule priority — higher values are evaluated first.
36    #[serde(default)]
37    pub priority: u32,
38    /// The scope at which this rule applies.
39    #[serde(default)]
40    pub scope: PolicyScope,
41}
42
43/// A loaded policy profile parsed from YAML.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct PolicyProfile {
46    pub version: String,
47    pub agent: String,
48    pub policies: Vec<PolicyRule>,
49}
50
51/// Policy evaluation engine.
52///
53/// Rules are evaluated in order; first match wins.
54/// When no profile is loaded, all actions are allowed.
55pub struct PolicyEngine {
56    profile: RwLock<Option<PolicyProfile>>,
57    rate_counters: Mutex<HashMap<String, (u64, Instant)>>,
58    conflict_strategy: ConflictResolutionStrategy,
59}
60
61impl PolicyEngine {
62    /// Create an empty policy engine (allows everything) with default
63    /// [`ConflictResolutionStrategy::PriorityFirstMatch`].
64    pub fn new() -> Self {
65        Self {
66            profile: RwLock::new(None),
67            rate_counters: Mutex::new(HashMap::new()),
68            conflict_strategy: ConflictResolutionStrategy::PriorityFirstMatch,
69        }
70    }
71
72    /// Create a policy engine with a specific conflict resolution strategy.
73    pub fn with_strategy(strategy: ConflictResolutionStrategy) -> Self {
74        Self {
75            profile: RwLock::new(None),
76            rate_counters: Mutex::new(HashMap::new()),
77            conflict_strategy: strategy,
78        }
79    }
80
81    /// Return the active conflict resolution strategy.
82    pub fn strategy(&self) -> ConflictResolutionStrategy {
83        self.conflict_strategy
84    }
85
86    /// Resolve conflicts among multiple candidate decisions using the
87    /// configured strategy.
88    ///
89    /// Returns a [`ResolutionResult`] describing which decision won,
90    /// whether a conflict was detected, and how many candidates were
91    /// evaluated.
92    pub fn resolve_conflicts(&self, candidates: &[CandidateDecision]) -> ResolutionResult {
93        if candidates.is_empty() {
94            return ResolutionResult {
95                winning_decision: PolicyDecision::Allow,
96                strategy_used: self.conflict_strategy,
97                conflict_detected: false,
98                candidates_evaluated: 0,
99            };
100        }
101
102        if candidates.len() == 1 {
103            return ResolutionResult {
104                winning_decision: candidates[0].decision.clone(),
105                strategy_used: self.conflict_strategy,
106                conflict_detected: false,
107                candidates_evaluated: 1,
108            };
109        }
110
111        let has_allow = candidates.iter().any(|c| c.decision.is_allowed());
112        let has_deny = candidates
113            .iter()
114            .any(|c| matches!(c.decision, PolicyDecision::Deny(_)));
115        let conflict_detected = has_allow && has_deny;
116
117        let mut sorted = candidates.to_vec();
118
119        let winning = match self.conflict_strategy {
120            ConflictResolutionStrategy::DenyOverrides => {
121                sorted.sort_by(|a, b| b.priority.cmp(&a.priority));
122                match sorted
123                    .iter()
124                    .find(|c| matches!(c.decision, PolicyDecision::Deny(_)))
125                {
126                    Some(d) => d.clone(),
127                    None => sorted[0].clone(),
128                }
129            }
130            ConflictResolutionStrategy::AllowOverrides => {
131                sorted.sort_by(|a, b| b.priority.cmp(&a.priority));
132                match sorted.iter().find(|c| c.decision.is_allowed()) {
133                    Some(a) => a.clone(),
134                    None => sorted[0].clone(),
135                }
136            }
137            ConflictResolutionStrategy::PriorityFirstMatch => {
138                sorted.sort_by(|a, b| b.priority.cmp(&a.priority));
139                sorted[0].clone()
140            }
141            ConflictResolutionStrategy::MostSpecificWins => {
142                sorted.sort_by(|a, b| {
143                    b.scope
144                        .specificity()
145                        .cmp(&a.scope.specificity())
146                        .then(b.priority.cmp(&a.priority))
147                });
148                sorted[0].clone()
149            }
150        };
151
152        ResolutionResult {
153            winning_decision: winning.decision,
154            strategy_used: self.conflict_strategy,
155            conflict_detected,
156            candidates_evaluated: candidates.len(),
157        }
158    }
159
160    /// Whether a policy profile is loaded.
161    pub fn is_loaded(&self) -> bool {
162        self.profile
163            .read()
164            .expect("policy profile lock poisoned")
165            .is_some()
166    }
167
168    /// Load a policy profile from a YAML string.
169    pub fn load_from_yaml(&self, yaml: &str) -> Result<(), PolicyError> {
170        let profile: PolicyProfile =
171            serde_yaml::from_str(yaml).map_err(PolicyError::InvalidYaml)?;
172        *self.profile.write().expect("policy profile lock poisoned") = Some(profile);
173        Ok(())
174    }
175
176    /// Load a policy profile from a YAML file on disk.
177    pub fn load_from_file(&self, path: &str) -> Result<(), PolicyError> {
178        let yaml = std::fs::read_to_string(path).map_err(PolicyError::Io)?;
179        self.load_from_yaml(&yaml)
180    }
181
182    /// Evaluate an action against the loaded policy.
183    ///
184    /// If no profile is loaded, returns [`PolicyDecision::Allow`].
185    /// An optional `context` map is matched against rule conditions.
186    pub fn evaluate(
187        &self,
188        action: &str,
189        context: Option<&HashMap<String, serde_yaml::Value>>,
190    ) -> PolicyDecision {
191        let guard = self.profile.read().expect("policy profile lock poisoned");
192        let profile = match guard.as_ref() {
193            Some(p) => p,
194            None => return PolicyDecision::Allow,
195        };
196
197        for rule in &profile.policies {
198            if !conditions_match(&rule.conditions, context) {
199                continue;
200            }
201
202            match rule.rule_type.as_str() {
203                "capability" => {
204                    // Deny list takes precedence
205                    for denied in &rule.denied_actions {
206                        if action_matches(action, denied) {
207                            return PolicyDecision::Deny(format!(
208                                "Blocked by policy '{}': action '{}' is denied",
209                                rule.name, action
210                            ));
211                        }
212                    }
213                    // Allow list: if the action matches an allow pattern, permit it.
214                    // If the list is non-empty but no pattern matches, deny only
215                    // when the action matches a deny-list prefix (scoped deny).
216                    // Actions outside the rule's scope fall through to later rules.
217                    if !rule.allowed_actions.is_empty() {
218                        if rule
219                            .allowed_actions
220                            .iter()
221                            .any(|a| action_matches(action, a))
222                        {
223                            return PolicyDecision::Allow;
224                        }
225                        // Only deny if action is in scope (matches a denied prefix
226                        // or shares a namespace with an allowed action)
227                        let in_scope = rule.denied_actions.iter().any(|d| {
228                            let ns = d.trim_end_matches('*').trim_end_matches(':');
229                            action.starts_with(ns)
230                        }) || rule.allowed_actions.iter().any(|a| {
231                            let ns = a.split('.').next().unwrap_or("");
232                            action.starts_with(ns)
233                        });
234                        if in_scope {
235                            return PolicyDecision::Deny(format!(
236                                "Blocked by policy '{}': action '{}' not in allowlist",
237                                rule.name, action
238                            ));
239                        }
240                    }
241                }
242                "approval" => {
243                    for pattern in &rule.actions {
244                        if action_matches(action, pattern) {
245                            return PolicyDecision::RequiresApproval(format!(
246                                "Policy '{}' requires {} approval(s) for '{}'",
247                                rule.name, rule.min_approvals, action
248                            ));
249                        }
250                    }
251                }
252                "rate_limit" => {
253                    if rule.max_calls > 0 {
254                        for pattern in &rule.actions {
255                            if action_matches(action, pattern) {
256                                return self.check_rate_limit(
257                                    &rule.name,
258                                    rule.max_calls,
259                                    &rule.window,
260                                );
261                            }
262                        }
263                    }
264                }
265                _ => {}
266            }
267        }
268
269        PolicyDecision::Allow
270    }
271
272    fn check_rate_limit(&self, name: &str, max_calls: u32, window: &str) -> PolicyDecision {
273        let window_secs = parse_duration(window);
274        let mut counters = self
275            .rate_counters
276            .lock()
277            .expect("rate counter lock poisoned");
278        let entry = counters
279            .entry(name.to_string())
280            .or_insert((0, Instant::now()));
281
282        if entry.1.elapsed().as_secs() > window_secs {
283            *entry = (1, Instant::now());
284            PolicyDecision::Allow
285        } else if entry.0 >= max_calls as u64 {
286            let retry_after = window_secs.saturating_sub(entry.1.elapsed().as_secs());
287            PolicyDecision::RateLimited {
288                retry_after_secs: retry_after,
289            }
290        } else {
291            entry.0 += 1;
292            PolicyDecision::Allow
293        }
294    }
295}
296
297impl Default for PolicyEngine {
298    fn default() -> Self {
299        Self::new()
300    }
301}
302
303/// Errors returned by policy operations.
304#[derive(Debug, thiserror::Error)]
305pub enum PolicyError {
306    #[error("invalid YAML: {0}")]
307    InvalidYaml(serde_yaml::Error),
308    #[error("I/O error: {0}")]
309    Io(std::io::Error),
310}
311
312/// Glob-style pattern matching: `shell:*` matches `shell:ls`.
313fn action_matches(action: &str, pattern: &str) -> bool {
314    if pattern == "*" {
315        return true;
316    }
317    if let Some(prefix) = pattern.strip_suffix(".*") {
318        return action.starts_with(&format!("{}.", prefix));
319    }
320    if let Some(prefix) = pattern.strip_suffix('*') {
321        return action.starts_with(prefix);
322    }
323    action == pattern
324}
325
326fn conditions_match(
327    conditions: &HashMap<String, serde_yaml::Value>,
328    context: Option<&HashMap<String, serde_yaml::Value>>,
329) -> bool {
330    if conditions.is_empty() {
331        return true;
332    }
333    let ctx = match context {
334        Some(c) => c,
335        None => return false,
336    };
337    for (key, expected) in conditions {
338        match ctx.get(key) {
339            Some(actual) if actual == expected => {}
340            _ => return false,
341        }
342    }
343    true
344}
345
346fn parse_duration(s: &str) -> u64 {
347    if let Some(val) = s.strip_suffix('m') {
348        val.parse::<u64>().unwrap_or(1) * 60
349    } else if let Some(val) = s.strip_suffix('s') {
350        val.parse::<u64>().unwrap_or(60)
351    } else if let Some(val) = s.strip_suffix('h') {
352        val.parse::<u64>().unwrap_or(1) * 3600
353    } else {
354        s.parse::<u64>().unwrap_or(60)
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    const POLICY_YAML: &str = r#"
363version: "1.0"
364agent: test-agent
365policies:
366  - name: capability-gate
367    type: capability
368    allowed_actions:
369      - "data.read"
370      - "data.write"
371    denied_actions:
372      - "shell:*"
373  - name: deploy-approval
374    type: approval
375    actions:
376      - "deploy.*"
377    min_approvals: 2
378  - name: api-rate-limit
379    type: rate_limit
380    actions:
381      - "api.call"
382    max_calls: 3
383    window: "60s"
384"#;
385
386    #[test]
387    fn test_allow_listed_action() {
388        let engine = PolicyEngine::new();
389        engine.load_from_yaml(POLICY_YAML).unwrap();
390        assert_eq!(engine.evaluate("data.read", None), PolicyDecision::Allow);
391    }
392
393    #[test]
394    fn test_deny_shell() {
395        let engine = PolicyEngine::new();
396        engine.load_from_yaml(POLICY_YAML).unwrap();
397        let decision = engine.evaluate("shell:rm", None);
398        assert!(matches!(decision, PolicyDecision::Deny(_)));
399    }
400
401    #[test]
402    fn test_not_in_allowlist_in_scope() {
403        let engine = PolicyEngine::new();
404        engine.load_from_yaml(POLICY_YAML).unwrap();
405        // "data.delete" shares the "data" namespace with allowed "data.read"/"data.write"
406        let decision = engine.evaluate("data.delete", None);
407        assert!(matches!(decision, PolicyDecision::Deny(_)));
408    }
409
410    #[test]
411    fn test_out_of_scope_falls_through() {
412        let engine = PolicyEngine::new();
413        engine.load_from_yaml(POLICY_YAML).unwrap();
414        // "admin.delete" is outside the capability rule's scope, falls through to Allow
415        let decision = engine.evaluate("admin.delete", None);
416        assert_eq!(decision, PolicyDecision::Allow);
417    }
418
419    #[test]
420    fn test_approval_required() {
421        let engine = PolicyEngine::new();
422        engine.load_from_yaml(POLICY_YAML).unwrap();
423        let decision = engine.evaluate("deploy.production", None);
424        assert!(matches!(decision, PolicyDecision::RequiresApproval(_)));
425    }
426
427    #[test]
428    fn test_rate_limiting() {
429        let engine = PolicyEngine::new();
430        engine.load_from_yaml(POLICY_YAML).unwrap();
431        // First 3 calls should be allowed
432        for _ in 0..3 {
433            assert_eq!(engine.evaluate("api.call", None), PolicyDecision::Allow);
434        }
435        // 4th call should be rate-limited
436        let decision = engine.evaluate("api.call", None);
437        assert!(matches!(decision, PolicyDecision::RateLimited { .. }));
438    }
439
440    #[test]
441    fn test_no_profile_allows_all() {
442        let engine = PolicyEngine::new();
443        assert_eq!(engine.evaluate("anything", None), PolicyDecision::Allow);
444    }
445
446    #[test]
447    fn test_action_matches() {
448        assert!(action_matches("shell:ls", "shell:*"));
449        assert!(action_matches("data.read", "data.*"));
450        assert!(action_matches("deploy.staging", "deploy.*"));
451        assert!(!action_matches("data.read", "shell:*"));
452        assert!(action_matches("anything", "*"));
453        assert!(action_matches("data.read", "data.read"));
454        assert!(!action_matches("data.write", "data.read"));
455    }
456
457    #[test]
458    fn test_with_strategy_constructor() {
459        let engine = PolicyEngine::with_strategy(ConflictResolutionStrategy::DenyOverrides);
460        assert_eq!(engine.strategy(), ConflictResolutionStrategy::DenyOverrides);
461    }
462
463    #[test]
464    fn test_default_strategy_is_priority_first_match() {
465        let engine = PolicyEngine::new();
466        assert_eq!(
467            engine.strategy(),
468            ConflictResolutionStrategy::PriorityFirstMatch
469        );
470    }
471
472    #[test]
473    fn test_resolve_conflicts_empty() {
474        let engine = PolicyEngine::new();
475        let result = engine.resolve_conflicts(&[]);
476        assert_eq!(result.winning_decision, PolicyDecision::Allow);
477        assert!(!result.conflict_detected);
478        assert_eq!(result.candidates_evaluated, 0);
479    }
480
481    #[test]
482    fn test_resolve_conflicts_single() {
483        let engine = PolicyEngine::new();
484        let candidates = vec![CandidateDecision {
485            decision: PolicyDecision::Deny("blocked".into()),
486            priority: 1,
487            scope: PolicyScope::Global,
488            rule_name: "rule-1".into(),
489        }];
490        let result = engine.resolve_conflicts(&candidates);
491        assert!(matches!(result.winning_decision, PolicyDecision::Deny(_)));
492        assert!(!result.conflict_detected);
493        assert_eq!(result.candidates_evaluated, 1);
494    }
495
496    #[test]
497    fn test_resolve_conflicts_deny_overrides() {
498        let engine = PolicyEngine::with_strategy(ConflictResolutionStrategy::DenyOverrides);
499        let candidates = vec![
500            CandidateDecision {
501                decision: PolicyDecision::Allow,
502                priority: 10,
503                scope: PolicyScope::Global,
504                rule_name: "allow-rule".into(),
505            },
506            CandidateDecision {
507                decision: PolicyDecision::Deny("no".into()),
508                priority: 5,
509                scope: PolicyScope::Global,
510                rule_name: "deny-rule".into(),
511            },
512        ];
513        let result = engine.resolve_conflicts(&candidates);
514        assert!(matches!(result.winning_decision, PolicyDecision::Deny(_)));
515        assert!(result.conflict_detected);
516    }
517
518    #[test]
519    fn test_resolve_conflicts_allow_overrides() {
520        let engine = PolicyEngine::with_strategy(ConflictResolutionStrategy::AllowOverrides);
521        let candidates = vec![
522            CandidateDecision {
523                decision: PolicyDecision::Deny("blocked".into()),
524                priority: 10,
525                scope: PolicyScope::Global,
526                rule_name: "deny-rule".into(),
527            },
528            CandidateDecision {
529                decision: PolicyDecision::Allow,
530                priority: 5,
531                scope: PolicyScope::Global,
532                rule_name: "allow-rule".into(),
533            },
534        ];
535        let result = engine.resolve_conflicts(&candidates);
536        assert_eq!(result.winning_decision, PolicyDecision::Allow);
537        assert!(result.conflict_detected);
538    }
539
540    #[test]
541    fn test_resolve_conflicts_priority_first_match() {
542        let engine = PolicyEngine::with_strategy(ConflictResolutionStrategy::PriorityFirstMatch);
543        let candidates = vec![
544            CandidateDecision {
545                decision: PolicyDecision::Deny("low".into()),
546                priority: 1,
547                scope: PolicyScope::Global,
548                rule_name: "low-rule".into(),
549            },
550            CandidateDecision {
551                decision: PolicyDecision::Allow,
552                priority: 10,
553                scope: PolicyScope::Global,
554                rule_name: "high-rule".into(),
555            },
556        ];
557        let result = engine.resolve_conflicts(&candidates);
558        assert_eq!(result.winning_decision, PolicyDecision::Allow);
559        assert!(result.conflict_detected);
560    }
561
562    #[test]
563    fn test_resolve_conflicts_most_specific_wins() {
564        let engine = PolicyEngine::with_strategy(ConflictResolutionStrategy::MostSpecificWins);
565        let candidates = vec![
566            CandidateDecision {
567                decision: PolicyDecision::Allow,
568                priority: 100,
569                scope: PolicyScope::Global,
570                rule_name: "global-allow".into(),
571            },
572            CandidateDecision {
573                decision: PolicyDecision::Deny("agent-deny".into()),
574                priority: 1,
575                scope: PolicyScope::Agent,
576                rule_name: "agent-deny".into(),
577            },
578        ];
579        let result = engine.resolve_conflicts(&candidates);
580        assert!(matches!(result.winning_decision, PolicyDecision::Deny(_)));
581        assert!(result.conflict_detected);
582    }
583
584    #[test]
585    fn test_resolve_conflicts_most_specific_tiebreaker() {
586        let engine = PolicyEngine::with_strategy(ConflictResolutionStrategy::MostSpecificWins);
587        let candidates = vec![
588            CandidateDecision {
589                decision: PolicyDecision::Deny("low".into()),
590                priority: 1,
591                scope: PolicyScope::Tenant,
592                rule_name: "tenant-low".into(),
593            },
594            CandidateDecision {
595                decision: PolicyDecision::Allow,
596                priority: 10,
597                scope: PolicyScope::Tenant,
598                rule_name: "tenant-high".into(),
599            },
600        ];
601        let result = engine.resolve_conflicts(&candidates);
602        assert_eq!(result.winning_decision, PolicyDecision::Allow);
603    }
604
605    #[test]
606    fn test_policy_rule_priority_and_scope_defaults() {
607        let yaml = r#"
608version: "1.0"
609agent: test
610policies:
611  - name: simple-rule
612    type: capability
613    allowed_actions:
614      - "data.read"
615"#;
616        let profile: PolicyProfile = serde_yaml::from_str(yaml).unwrap();
617        let rule = &profile.policies[0];
618        assert_eq!(rule.priority, 0);
619        assert_eq!(rule.scope, PolicyScope::Global);
620    }
621
622    #[test]
623    fn test_policy_rule_with_priority_and_scope() {
624        let yaml = r#"
625version: "1.0"
626agent: test
627policies:
628  - name: agent-rule
629    type: capability
630    allowed_actions:
631      - "data.read"
632    priority: 10
633    scope: agent
634"#;
635        let profile: PolicyProfile = serde_yaml::from_str(yaml).unwrap();
636        let rule = &profile.policies[0];
637        assert_eq!(rule.priority, 10);
638        assert_eq!(rule.scope, PolicyScope::Agent);
639    }
640
641    #[test]
642    fn test_no_conflict_when_all_same_decision() {
643        let engine = PolicyEngine::with_strategy(ConflictResolutionStrategy::DenyOverrides);
644        let candidates = vec![
645            CandidateDecision {
646                decision: PolicyDecision::Allow,
647                priority: 5,
648                scope: PolicyScope::Global,
649                rule_name: "r1".into(),
650            },
651            CandidateDecision {
652                decision: PolicyDecision::Allow,
653                priority: 10,
654                scope: PolicyScope::Tenant,
655                rule_name: "r2".into(),
656            },
657        ];
658        let result = engine.resolve_conflicts(&candidates);
659        assert!(!result.conflict_detected);
660        assert_eq!(result.winning_decision, PolicyDecision::Allow);
661    }
662
663    #[test]
664    fn test_multiple_capability_rules_first_match_wins() {
665        let yaml = r#"
666version: "1.0"
667agent: test
668policies:
669  - name: deny-first
670    type: capability
671    denied_actions:
672      - "data.read"
673  - name: allow-second
674    type: capability
675    allowed_actions:
676      - "data.read"
677"#;
678        let engine = PolicyEngine::new();
679        engine.load_from_yaml(yaml).unwrap();
680        // First rule denies data.read, so it should be denied
681        let decision = engine.evaluate("data.read", None);
682        assert!(matches!(decision, PolicyDecision::Deny(_)));
683    }
684
685    #[test]
686    fn test_policy_with_only_deny_rules() {
687        let yaml = r#"
688version: "1.0"
689agent: test
690policies:
691  - name: deny-only
692    type: capability
693    denied_actions:
694      - "shell:*"
695      - "admin.*"
696"#;
697        let engine = PolicyEngine::new();
698        engine.load_from_yaml(yaml).unwrap();
699        assert!(matches!(
700            engine.evaluate("shell:ls", None),
701            PolicyDecision::Deny(_)
702        ));
703        assert!(matches!(
704            engine.evaluate("admin.delete", None),
705            PolicyDecision::Deny(_)
706        ));
707        // Actions outside denied scope are allowed
708        assert_eq!(engine.evaluate("data.read", None), PolicyDecision::Allow);
709    }
710
711    #[test]
712    fn test_policy_with_only_allow_rules() {
713        let yaml = r#"
714version: "1.0"
715agent: test
716policies:
717  - name: allow-only
718    type: capability
719    allowed_actions:
720      - "data.read"
721      - "data.write"
722"#;
723        let engine = PolicyEngine::new();
724        engine.load_from_yaml(yaml).unwrap();
725        assert_eq!(engine.evaluate("data.read", None), PolicyDecision::Allow);
726        assert_eq!(engine.evaluate("data.write", None), PolicyDecision::Allow);
727        // In-scope but not in allowlist
728        assert!(matches!(
729            engine.evaluate("data.delete", None),
730            PolicyDecision::Deny(_)
731        ));
732    }
733
734    #[test]
735    fn test_conditions_matching() {
736        let yaml = r#"
737version: "1.0"
738agent: test
739policies:
740  - name: env-gate
741    type: capability
742    denied_actions:
743      - "deploy.*"
744    conditions:
745      environment: "production"
746"#;
747        let engine = PolicyEngine::new();
748        engine.load_from_yaml(yaml).unwrap();
749        let mut context = HashMap::new();
750        context.insert(
751            "environment".to_string(),
752            serde_yaml::Value::String("production".to_string()),
753        );
754        let decision = engine.evaluate("deploy.app", Some(&context));
755        assert!(matches!(decision, PolicyDecision::Deny(_)));
756    }
757
758    #[test]
759    fn test_conditions_not_matching() {
760        let yaml = r#"
761version: "1.0"
762agent: test
763policies:
764  - name: env-gate
765    type: capability
766    denied_actions:
767      - "deploy.*"
768    conditions:
769      environment: "production"
770"#;
771        let engine = PolicyEngine::new();
772        engine.load_from_yaml(yaml).unwrap();
773        let mut context = HashMap::new();
774        context.insert(
775            "environment".to_string(),
776            serde_yaml::Value::String("staging".to_string()),
777        );
778        // Conditions don't match, rule is skipped, falls through to Allow
779        let decision = engine.evaluate("deploy.app", Some(&context));
780        assert_eq!(decision, PolicyDecision::Allow);
781    }
782
783    #[test]
784    fn test_conditions_no_context_skips_rule() {
785        let yaml = r#"
786version: "1.0"
787agent: test
788policies:
789  - name: env-gate
790    type: capability
791    denied_actions:
792      - "deploy.*"
793    conditions:
794      environment: "production"
795"#;
796        let engine = PolicyEngine::new();
797        engine.load_from_yaml(yaml).unwrap();
798        // No context provided - conditions require it, rule is skipped
799        let decision = engine.evaluate("deploy.app", None);
800        assert_eq!(decision, PolicyDecision::Allow);
801    }
802
803    #[test]
804    fn test_loading_invalid_yaml_returns_error() {
805        let engine = PolicyEngine::new();
806        let result = engine.load_from_yaml("{{not valid yaml");
807        assert!(result.is_err());
808        assert!(matches!(result.unwrap_err(), PolicyError::InvalidYaml(_)));
809    }
810
811    #[test]
812    fn test_loading_from_temp_file() {
813        let dir = tempfile::tempdir().unwrap();
814        let path = dir.path().join("policy.yaml");
815        std::fs::write(&path, POLICY_YAML).unwrap();
816        let engine = PolicyEngine::new();
817        engine.load_from_file(path.to_str().unwrap()).unwrap();
818        assert!(engine.is_loaded());
819        assert_eq!(engine.evaluate("data.read", None), PolicyDecision::Allow);
820    }
821
822    #[test]
823    fn test_multiple_rate_limit_rules_for_different_actions() {
824        let yaml = r#"
825version: "1.0"
826agent: test
827policies:
828  - name: api-limit
829    type: rate_limit
830    actions:
831      - "api.call"
832    max_calls: 2
833    window: "60s"
834  - name: db-limit
835    type: rate_limit
836    actions:
837      - "db.query"
838    max_calls: 1
839    window: "60s"
840"#;
841        let engine = PolicyEngine::new();
842        engine.load_from_yaml(yaml).unwrap();
843        // api.call: 2 allowed
844        assert_eq!(engine.evaluate("api.call", None), PolicyDecision::Allow);
845        assert_eq!(engine.evaluate("api.call", None), PolicyDecision::Allow);
846        assert!(matches!(
847            engine.evaluate("api.call", None),
848            PolicyDecision::RateLimited { .. }
849        ));
850        // db.query: 1 allowed (independent counter)
851        assert_eq!(engine.evaluate("db.query", None), PolicyDecision::Allow);
852        assert!(matches!(
853            engine.evaluate("db.query", None),
854            PolicyDecision::RateLimited { .. }
855        ));
856    }
857
858    #[test]
859    fn test_rate_limit_resets_after_window() {
860        let yaml = r#"
861version: "1.0"
862agent: test
863policies:
864  - name: fast-limit
865    type: rate_limit
866    actions:
867      - "api.call"
868    max_calls: 1
869    window: "0s"
870"#;
871        let engine = PolicyEngine::new();
872        engine.load_from_yaml(yaml).unwrap();
873        // First call is allowed
874        assert_eq!(engine.evaluate("api.call", None), PolicyDecision::Allow);
875        // Second call hits rate limit (within the 0s window)
876        assert!(matches!(
877            engine.evaluate("api.call", None),
878            PolicyDecision::RateLimited { .. }
879        ));
880        // Wait for the window to expire (0s window → needs >0 elapsed seconds)
881        std::thread::sleep(std::time::Duration::from_millis(1100));
882        // After window reset, call should be allowed again
883        assert_eq!(engine.evaluate("api.call", None), PolicyDecision::Allow);
884    }
885
886    #[test]
887    fn test_wildcard_matches_everything() {
888        let yaml = r#"
889version: "1.0"
890agent: test
891policies:
892  - name: deny-all
893    type: capability
894    denied_actions:
895      - "*"
896"#;
897        let engine = PolicyEngine::new();
898        engine.load_from_yaml(yaml).unwrap();
899        assert!(matches!(
900            engine.evaluate("anything", None),
901            PolicyDecision::Deny(_)
902        ));
903        assert!(matches!(
904            engine.evaluate("data.read", None),
905            PolicyDecision::Deny(_)
906        ));
907        assert!(matches!(
908            engine.evaluate("shell:ls", None),
909            PolicyDecision::Deny(_)
910        ));
911    }
912
913    #[test]
914    fn test_parse_duration_minutes() {
915        assert_eq!(parse_duration("5m"), 300);
916    }
917
918    #[test]
919    fn test_parse_duration_seconds() {
920        assert_eq!(parse_duration("30s"), 30);
921    }
922
923    #[test]
924    fn test_parse_duration_hours() {
925        assert_eq!(parse_duration("2h"), 7200);
926    }
927
928    #[test]
929    fn test_parse_duration_bare_number() {
930        assert_eq!(parse_duration("120"), 120);
931    }
932
933    #[test]
934    fn test_is_loaded_false_initially() {
935        let engine = PolicyEngine::new();
936        assert!(!engine.is_loaded());
937    }
938
939    #[test]
940    fn test_is_loaded_true_after_load() {
941        let engine = PolicyEngine::new();
942        engine.load_from_yaml(POLICY_YAML).unwrap();
943        assert!(engine.is_loaded());
944    }
945
946    #[test]
947    fn test_rules_present_but_none_match_falls_through() {
948        let yaml = r#"
949version: "1.0"
950agent: test
951policies:
952  - name: gate
953    type: capability
954    denied_actions:
955      - "shell:*"
956"#;
957        let engine = PolicyEngine::new();
958        engine.load_from_yaml(yaml).unwrap();
959        // "data.read" doesn't match any denied actions — falls through to Allow
960        assert_eq!(engine.evaluate("data.read", None), PolicyDecision::Allow);
961    }
962
963    #[test]
964    fn test_action_matches_empty_strings() {
965        assert!(action_matches("", ""));
966        assert!(!action_matches("", "data.read"));
967        assert!(!action_matches("data.read", ""));
968    }
969
970    #[test]
971    fn test_action_matches_exact_match() {
972        assert!(action_matches("data.read", "data.read"));
973        assert!(!action_matches("data.read", "data.write"));
974    }
975
976    #[test]
977    fn test_action_matches_partial_non_match() {
978        // "data" does not match "data.read" (no wildcard)
979        assert!(!action_matches("data", "data.read"));
980        // "data.rea" does not match "data.read"
981        assert!(!action_matches("data.rea", "data.read"));
982    }
983}