spec_ai_policy/policy/
mod.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3
4use spec_ai_config::persistence::Persistence;
5
6/// Represents the effect of a policy rule
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "lowercase")]
9pub enum PolicyEffect {
10    Allow,
11    Deny,
12}
13
14/// A single policy rule matching (agent, action, resource) tuples
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct PolicyRule {
17    /// Agent name pattern (supports wildcards: "*")
18    pub agent: String,
19    /// Action pattern (e.g., "tool_call", "file_write", "bash")
20    pub action: String,
21    /// Resource pattern (e.g., tool name, file path - supports wildcards: "*")
22    pub resource: String,
23    /// Effect to apply when rule matches
24    pub effect: PolicyEffect,
25}
26
27impl PolicyRule {
28    /// Check if this rule matches the given agent, action, and resource
29    pub fn matches(&self, agent: &str, action: &str, resource: &str) -> bool {
30        wildcard_match(&self.agent, agent)
31            && wildcard_match(&self.action, action)
32            && wildcard_match(&self.resource, resource)
33    }
34}
35
36/// Container for all policy rules
37#[derive(Debug, Clone, Serialize, Deserialize, Default)]
38pub struct PolicySet {
39    pub rules: Vec<PolicyRule>,
40}
41
42/// Result of policy evaluation
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum PolicyDecision {
45    /// Action is allowed
46    Allow,
47    /// Action is denied with a reason
48    Deny(String),
49}
50
51/// Policy engine that evaluates actions against stored rules
52#[derive(Debug, Clone)]
53pub struct PolicyEngine {
54    policy_set: PolicySet,
55}
56
57impl PolicyEngine {
58    /// Create a new policy engine with an empty policy set
59    pub fn new() -> Self {
60        Self {
61            policy_set: PolicySet::default(),
62        }
63    }
64
65    /// Create a policy engine with the given policy set
66    pub fn with_policy_set(policy_set: PolicySet) -> Self {
67        Self { policy_set }
68    }
69
70    /// Load policies from persistence layer
71    /// Policies are stored in the policy_cache table with key "policies"
72    pub fn load_from_persistence(persistence: &Persistence) -> Result<Self> {
73        match persistence.policy_get("policies")? {
74            Some(entry) => {
75                let policy_set: PolicySet = serde_json::from_value(entry.value)
76                    .context("deserializing policy set from cache")?;
77                Ok(Self::with_policy_set(policy_set))
78            }
79            None => {
80                // No policies stored yet, return empty engine
81                Ok(Self::new())
82            }
83        }
84    }
85
86    /// Save current policy set to persistence
87    pub fn save_to_persistence(&self, persistence: &Persistence) -> Result<()> {
88        let value = serde_json::to_value(&self.policy_set).context("serializing policy set")?;
89        persistence.policy_upsert("policies", &value)?;
90        Ok(())
91    }
92
93    /// Reload policies from persistence
94    pub fn reload(&mut self, persistence: &Persistence) -> Result<()> {
95        let engine = Self::load_from_persistence(persistence)?;
96        self.policy_set = engine.policy_set;
97        Ok(())
98    }
99
100    /// Evaluate a policy decision for the given agent, action, and resource
101    /// Rules are evaluated in order, and the first matching rule determines the decision
102    /// If no rules match, the default is to deny with a reason
103    pub fn check(&self, agent: &str, action: &str, resource: &str) -> PolicyDecision {
104        for rule in &self.policy_set.rules {
105            if rule.matches(agent, action, resource) {
106                return match rule.effect {
107                    PolicyEffect::Allow => PolicyDecision::Allow,
108                    PolicyEffect::Deny => PolicyDecision::Deny(format!(
109                        "Policy denies {} action {} on resource {}",
110                        agent, action, resource
111                    )),
112                };
113            }
114        }
115
116        // Default: deny if no rule matches
117        PolicyDecision::Deny(format!(
118            "No policy rule matches agent '{}', action '{}', resource '{}' (default deny)",
119            agent, action, resource
120        ))
121    }
122
123    /// Get the number of rules in the policy set
124    pub fn rule_count(&self) -> usize {
125        self.policy_set.rules.len()
126    }
127
128    /// Add a rule to the policy set
129    pub fn add_rule(&mut self, rule: PolicyRule) {
130        self.policy_set.rules.push(rule);
131    }
132
133    /// Get a reference to the policy set
134    pub fn policy_set(&self) -> &PolicySet {
135        &self.policy_set
136    }
137}
138
139impl Default for PolicyEngine {
140    fn default() -> Self {
141        Self::new()
142    }
143}
144
145/// Simple wildcard matching
146/// Supports "*" as a wildcard that matches any string
147fn wildcard_match(pattern: &str, text: &str) -> bool {
148    if pattern == "*" {
149        return true;
150    }
151
152    // If the pattern contains wildcards, do more complex matching
153    if pattern.contains('*') {
154        // Split the pattern by '*' and check if the text contains all parts in order
155        let parts: Vec<&str> = pattern.split('*').collect();
156        let mut text_pos = 0;
157
158        for (i, part) in parts.iter().enumerate() {
159            if part.is_empty() {
160                continue;
161            }
162
163            if let Some(pos) = text[text_pos..].find(part) {
164                text_pos += pos + part.len();
165            } else {
166                return false;
167            }
168
169            // If this is not the last part and not followed by a wildcard at the pattern end,
170            // ensure part is found
171            if i == parts.len() - 1 && !pattern.ends_with('*') {
172                // The last part must be at the end
173                return text.ends_with(part);
174            }
175        }
176
177        // If a pattern starts with '*', the first part can be anywhere
178        // If a pattern ends with '*', the last part can be anywhere (already handled)
179        if !pattern.starts_with('*') && !parts.is_empty() && !parts[0].is_empty() {
180            return text.starts_with(parts[0]);
181        }
182
183        true
184    } else {
185        // No wildcards, exact match
186        pattern == text
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn test_wildcard_match_exact() {
196        assert!(wildcard_match("hello", "hello"));
197        assert!(!wildcard_match("hello", "world"));
198        assert!(!wildcard_match("hello", "hello_world"));
199    }
200
201    #[test]
202    fn test_wildcard_match_star() {
203        assert!(wildcard_match("*", "anything"));
204        assert!(wildcard_match("*", ""));
205        assert!(wildcard_match("*", "foo/bar/baz"));
206    }
207
208    #[test]
209    fn test_wildcard_match_prefix() {
210        assert!(wildcard_match("hello*", "hello"));
211        assert!(wildcard_match("hello*", "hello_world"));
212        assert!(wildcard_match("hello*", "hello123"));
213        assert!(!wildcard_match("hello*", "hi_world"));
214    }
215
216    #[test]
217    fn test_wildcard_match_suffix() {
218        assert!(wildcard_match("*world", "world"));
219        assert!(wildcard_match("*world", "hello_world"));
220        assert!(!wildcard_match("*world", "world_hello"));
221    }
222
223    #[test]
224    fn test_wildcard_match_middle() {
225        assert!(wildcard_match("hello*world", "helloworld"));
226        assert!(wildcard_match("hello*world", "hello_beautiful_world"));
227        assert!(!wildcard_match("hello*world", "hello"));
228        assert!(!wildcard_match("hello*world", "world"));
229    }
230
231    #[test]
232    fn test_wildcard_match_multiple() {
233        assert!(wildcard_match("/etc/*/*.conf", "/etc/nginx/nginx.conf"));
234        assert!(wildcard_match("/etc/*/*.conf", "/etc/apache2/apache2.conf"));
235        assert!(!wildcard_match(
236            "/etc/*/*.conf",
237            "/etc/nginx/sites-available/default"
238        ));
239    }
240
241    #[test]
242    fn test_policy_rule_matches() {
243        let rule = PolicyRule {
244            agent: "coder".to_string(),
245            action: "tool_call".to_string(),
246            resource: "echo".to_string(),
247            effect: PolicyEffect::Allow,
248        };
249
250        assert!(rule.matches("coder", "tool_call", "echo"));
251        assert!(!rule.matches("assistant", "tool_call", "echo"));
252        assert!(!rule.matches("coder", "file_write", "echo"));
253        assert!(!rule.matches("coder", "tool_call", "calculator"));
254    }
255
256    #[test]
257    fn test_policy_rule_wildcard_agent() {
258        let rule = PolicyRule {
259            agent: "*".to_string(),
260            action: "tool_call".to_string(),
261            resource: "echo".to_string(),
262            effect: PolicyEffect::Allow,
263        };
264
265        assert!(rule.matches("coder", "tool_call", "echo"));
266        assert!(rule.matches("assistant", "tool_call", "echo"));
267        assert!(rule.matches("any_agent", "tool_call", "echo"));
268    }
269
270    #[test]
271    fn test_policy_rule_wildcard_resource() {
272        let rule = PolicyRule {
273            agent: "coder".to_string(),
274            action: "tool_call".to_string(),
275            resource: "*".to_string(),
276            effect: PolicyEffect::Allow,
277        };
278
279        assert!(rule.matches("coder", "tool_call", "echo"));
280        assert!(rule.matches("coder", "tool_call", "calculator"));
281        assert!(rule.matches("coder", "tool_call", "any_tool"));
282    }
283
284    #[test]
285    fn test_policy_engine_allow() {
286        let mut engine = PolicyEngine::new();
287        engine.add_rule(PolicyRule {
288            agent: "coder".to_string(),
289            action: "tool_call".to_string(),
290            resource: "echo".to_string(),
291            effect: PolicyEffect::Allow,
292        });
293
294        assert_eq!(
295            engine.check("coder", "tool_call", "echo"),
296            PolicyDecision::Allow
297        );
298    }
299
300    #[test]
301    fn test_policy_engine_deny() {
302        let mut engine = PolicyEngine::new();
303        engine.add_rule(PolicyRule {
304            agent: "coder".to_string(),
305            action: "bash".to_string(),
306            resource: "/etc/*".to_string(),
307            effect: PolicyEffect::Deny,
308        });
309
310        match engine.check("coder", "bash", "/etc/passwd") {
311            PolicyDecision::Deny(_) => {}
312            _ => panic!("Expected deny decision"),
313        }
314    }
315
316    #[test]
317    fn test_policy_engine_first_match_wins() {
318        let mut engine = PolicyEngine::new();
319        // First rule: deny all bashes
320        engine.add_rule(PolicyRule {
321            agent: "*".to_string(),
322            action: "bash".to_string(),
323            resource: "*".to_string(),
324            effect: PolicyEffect::Deny,
325        });
326        // Second rule: allow bash for coder (should never be reached)
327        engine.add_rule(PolicyRule {
328            agent: "coder".to_string(),
329            action: "bash".to_string(),
330            resource: "*".to_string(),
331            effect: PolicyEffect::Allow,
332        });
333
334        // The first rule should win
335        match engine.check("coder", "bash", "/tmp/test.sh") {
336            PolicyDecision::Deny(_) => {}
337            _ => panic!("Expected deny decision from first rule"),
338        }
339    }
340
341    #[test]
342    fn test_policy_engine_default_deny() {
343        let engine = PolicyEngine::new();
344
345        // No rules should deny by default
346        match engine.check("agent", "action", "resource") {
347            PolicyDecision::Deny(reason) => {
348                assert!(reason.contains("No policy rule matches"));
349            }
350            _ => panic!("Expected default deny"),
351        }
352    }
353
354    #[test]
355    fn test_policy_engine_rule_count() {
356        let mut engine = PolicyEngine::new();
357        assert_eq!(engine.rule_count(), 0);
358
359        engine.add_rule(PolicyRule {
360            agent: "*".to_string(),
361            action: "*".to_string(),
362            resource: "*".to_string(),
363            effect: PolicyEffect::Allow,
364        });
365        assert_eq!(engine.rule_count(), 1);
366    }
367
368    #[test]
369    fn test_policy_serialization() {
370        let policy_set = PolicySet {
371            rules: vec![
372                PolicyRule {
373                    agent: "coder".to_string(),
374                    action: "tool_call".to_string(),
375                    resource: "echo".to_string(),
376                    effect: PolicyEffect::Allow,
377                },
378                PolicyRule {
379                    agent: "*".to_string(),
380                    action: "bash".to_string(),
381                    resource: "/etc/*".to_string(),
382                    effect: PolicyEffect::Deny,
383                },
384            ],
385        };
386
387        // Serialize and deserialize
388        let json = serde_json::to_value(&policy_set).unwrap();
389        let deserialized: PolicySet = serde_json::from_value(json).unwrap();
390
391        assert_eq!(deserialized.rules.len(), 2);
392        assert_eq!(deserialized.rules[0].agent, "coder");
393        assert_eq!(deserialized.rules[1].effect, PolicyEffect::Deny);
394    }
395
396    #[test]
397    fn test_policy_persistence() {
398        use spec_ai_config::test_utils::create_test_db;
399
400        let persistence = create_test_db();
401
402        // Create an engine with some rules
403        let mut engine = PolicyEngine::new();
404        engine.add_rule(PolicyRule {
405            agent: "coder".to_string(),
406            action: "tool_call".to_string(),
407            resource: "echo".to_string(),
408            effect: PolicyEffect::Allow,
409        });
410        engine.add_rule(PolicyRule {
411            agent: "*".to_string(),
412            action: "bash".to_string(),
413            resource: "*".to_string(),
414            effect: PolicyEffect::Deny,
415        });
416
417        // Save to persistence
418        engine.save_to_persistence(&persistence).unwrap();
419
420        // Load from persistence
421        let loaded = PolicyEngine::load_from_persistence(&persistence).unwrap();
422        assert_eq!(loaded.rule_count(), 2);
423
424        // Verify rules work
425        assert_eq!(
426            loaded.check("coder", "tool_call", "echo"),
427            PolicyDecision::Allow
428        );
429        match loaded.check("coder", "bash", "/tmp/test.sh") {
430            PolicyDecision::Deny(_) => {}
431            _ => panic!("Expected deny"),
432        }
433    }
434
435    #[test]
436    fn test_policy_reload() {
437        use spec_ai_config::test_utils::create_test_db;
438
439        let persistence = create_test_db();
440
441        // Create and save the initial engine
442        let mut engine = PolicyEngine::new();
443        engine.add_rule(PolicyRule {
444            agent: "coder".to_string(),
445            action: "tool_call".to_string(),
446            resource: "echo".to_string(),
447            effect: PolicyEffect::Allow,
448        });
449        engine.save_to_persistence(&persistence).unwrap();
450
451        // Create a new engine with different rules
452        let mut engine2 = PolicyEngine::new();
453        engine2.add_rule(PolicyRule {
454            agent: "*".to_string(),
455            action: "*".to_string(),
456            resource: "*".to_string(),
457            effect: PolicyEffect::Deny,
458        });
459        engine2.save_to_persistence(&persistence).unwrap();
460
461        // Reload first engine - should get new rules
462        engine.reload(&persistence).unwrap();
463        assert_eq!(engine.rule_count(), 1);
464
465        // Should have the deny-all rule now
466        match engine.check("coder", "tool_call", "echo") {
467            PolicyDecision::Deny(_) => {}
468            _ => panic!("Expected deny after reload"),
469        }
470    }
471
472    #[test]
473    fn test_load_empty_persistence() {
474        use spec_ai_config::test_utils::create_test_db;
475
476        let persistence = create_test_db();
477
478        // Load from empty persistence - should get empty engine
479        let engine = PolicyEngine::load_from_persistence(&persistence).unwrap();
480        assert_eq!(engine.rule_count(), 0);
481
482        // Should deny by default
483        match engine.check("agent", "action", "resource") {
484            PolicyDecision::Deny(_) => {}
485            _ => panic!("Expected default deny"),
486        }
487    }
488}