Skip to main content

kvlar_core/
engine.rs

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