1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3
4use spec_ai_config::persistence::Persistence;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "lowercase")]
9pub enum PolicyEffect {
10 Allow,
11 Deny,
12}
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct PolicyRule {
17 pub agent: String,
19 pub action: String,
21 pub resource: String,
23 pub effect: PolicyEffect,
25}
26
27impl PolicyRule {
28 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
38pub struct PolicySet {
39 pub rules: Vec<PolicyRule>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum PolicyDecision {
45 Allow,
47 Deny(String),
49}
50
51#[derive(Debug, Clone)]
53pub struct PolicyEngine {
54 policy_set: PolicySet,
55}
56
57impl PolicyEngine {
58 pub fn new() -> Self {
60 Self {
61 policy_set: PolicySet::default(),
62 }
63 }
64
65 pub fn with_policy_set(policy_set: PolicySet) -> Self {
67 Self { policy_set }
68 }
69
70 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 Ok(Self::new())
82 }
83 }
84 }
85
86 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 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 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 PolicyDecision::Deny(format!(
118 "No policy rule matches agent '{}', action '{}', resource '{}' (default deny)",
119 agent, action, resource
120 ))
121 }
122
123 pub fn rule_count(&self) -> usize {
125 self.policy_set.rules.len()
126 }
127
128 pub fn add_rule(&mut self, rule: PolicyRule) {
130 self.policy_set.rules.push(rule);
131 }
132
133 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
145fn wildcard_match(pattern: &str, text: &str) -> bool {
148 if pattern == "*" {
149 return true;
150 }
151
152 if pattern.contains('*') {
154 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 i == parts.len() - 1 && !pattern.ends_with('*') {
172 return text.ends_with(part);
174 }
175 }
176
177 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 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 engine.add_rule(PolicyRule {
321 agent: "*".to_string(),
322 action: "bash".to_string(),
323 resource: "*".to_string(),
324 effect: PolicyEffect::Deny,
325 });
326 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 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 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 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_core::test_utils::create_test_db;
399
400 let persistence = create_test_db();
401
402 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 engine.save_to_persistence(&persistence).unwrap();
419
420 let loaded = PolicyEngine::load_from_persistence(&persistence).unwrap();
422 assert_eq!(loaded.rule_count(), 2);
423
424 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_core::test_utils::create_test_db;
438
439 let persistence = create_test_db();
440
441 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 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 engine.reload(&persistence).unwrap();
463 assert_eq!(engine.rule_count(), 1);
464
465 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_core::test_utils::create_test_db;
475
476 let persistence = create_test_db();
477
478 let engine = PolicyEngine::load_from_persistence(&persistence).unwrap();
480 assert_eq!(engine.rule_count(), 0);
481
482 match engine.check("agent", "action", "resource") {
484 PolicyDecision::Deny(_) => {}
485 _ => panic!("Expected default deny"),
486 }
487 }
488}