1use serde_json::Value;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum PolicyDecision {
8 Allow,
10 Deny {
12 reason: String,
14 },
15}
16
17pub trait PolicyEngine: Send + Sync {
20 fn name(&self) -> &str;
22 fn evaluate(&self, tool: &str, arguments: &Value) -> PolicyDecision;
24}
25
26pub struct NativePolicy {
28 name: String,
29 #[allow(clippy::type_complexity)]
30 rule: Box<dyn Fn(&str, &Value) -> PolicyDecision + Send + Sync>,
31}
32
33impl NativePolicy {
34 pub fn new(
36 name: impl Into<String>,
37 rule: impl Fn(&str, &Value) -> PolicyDecision + Send + Sync + 'static,
38 ) -> Self {
39 Self {
40 name: name.into(),
41 rule: Box::new(rule),
42 }
43 }
44
45 pub fn deny_tools(tools: &[&str]) -> Self {
47 let denied: Vec<String> = tools.iter().map(|s| (*s).to_owned()).collect();
48 Self::new("deny_tools", move |tool, _| {
49 if denied.iter().any(|d| d == tool) {
50 PolicyDecision::Deny {
51 reason: format!("tool {tool:?} is deny-listed"),
52 }
53 } else {
54 PolicyDecision::Allow
55 }
56 })
57 }
58
59 pub fn allow_only(tools: &[&str]) -> Self {
61 let allowed: Vec<String> = tools.iter().map(|s| (*s).to_owned()).collect();
62 Self::new("allow_only", move |tool, _| {
63 if allowed.iter().any(|a| a == tool) {
64 PolicyDecision::Allow
65 } else {
66 PolicyDecision::Deny {
67 reason: format!("tool {tool:?} is not allow-listed"),
68 }
69 }
70 })
71 }
72}
73
74impl PolicyEngine for NativePolicy {
75 fn name(&self) -> &str {
76 &self.name
77 }
78
79 fn evaluate(&self, tool: &str, arguments: &Value) -> PolicyDecision {
80 (self.rule)(tool, arguments)
81 }
82}
83
84#[cfg(test)]
85mod tests {
86 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
87
88 use super::*;
89 use serde_json::json;
90
91 #[test]
92 fn deny_list() {
93 let p = NativePolicy::deny_tools(&["drop_database", "send_wire"]);
94 assert_eq!(p.evaluate("search", &json!({})), PolicyDecision::Allow);
95 assert!(matches!(
96 p.evaluate("drop_database", &json!({})),
97 PolicyDecision::Deny { .. }
98 ));
99 }
100
101 #[test]
102 fn allow_list() {
103 let p = NativePolicy::allow_only(&["search", "read_file"]);
104 assert_eq!(p.evaluate("search", &json!({})), PolicyDecision::Allow);
105 assert!(matches!(
106 p.evaluate("db_write", &json!({})),
107 PolicyDecision::Deny { .. }
108 ));
109 }
110
111 #[test]
112 fn custom_argument_rule() {
113 let p = NativePolicy::new("payout_ceiling", |tool, args| {
114 if tool == "payout" && args.get("amount_usd").and_then(Value::as_f64).unwrap_or(0.0) > 50.0 {
115 PolicyDecision::Deny {
116 reason: "single payout above $50".into(),
117 }
118 } else {
119 PolicyDecision::Allow
120 }
121 });
122 assert_eq!(
123 p.evaluate("payout", &json!({"amount_usd": 49.0})),
124 PolicyDecision::Allow
125 );
126 assert!(matches!(
127 p.evaluate("payout", &json!({"amount_usd": 51.0})),
128 PolicyDecision::Deny { .. }
129 ));
130 }
131
132 #[test]
133 fn native_policy_name_returns_the_registered_string() {
134 let a = NativePolicy::deny_tools(&["x"]);
137 assert_eq!(a.name(), "deny_tools");
138 let b = NativePolicy::allow_only(&["x"]);
139 assert_eq!(b.name(), "allow_only");
140 let c = NativePolicy::new("payout_ceiling", |_, _| PolicyDecision::Allow);
141 assert_eq!(c.name(), "payout_ceiling");
142 }
143}