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: std::collections::HashSet<String> = tools.iter().map(|s| (*s).to_owned()).collect();
51 Self::new("deny_tools", move |tool, _| {
52 if denied.contains(tool) {
53 PolicyDecision::Deny {
54 reason: format!("tool {tool:?} is deny-listed"),
55 }
56 } else {
57 PolicyDecision::Allow
58 }
59 })
60 }
61
62 pub fn allow_only(tools: &[&str]) -> Self {
64 let allowed: std::collections::HashSet<String> = tools.iter().map(|s| (*s).to_owned()).collect();
65 Self::new("allow_only", move |tool, _| {
66 if allowed.contains(tool) {
67 PolicyDecision::Allow
68 } else {
69 PolicyDecision::Deny {
70 reason: format!("tool {tool:?} is not allow-listed"),
71 }
72 }
73 })
74 }
75}
76
77impl PolicyEngine for NativePolicy {
78 fn name(&self) -> &str {
79 &self.name
80 }
81
82 fn evaluate(&self, tool: &str, arguments: &Value) -> PolicyDecision {
83 (self.rule)(tool, arguments)
84 }
85}
86
87#[cfg(test)]
88mod tests {
89 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
90
91 use super::*;
92 use serde_json::json;
93
94 #[test]
95 fn deny_list() {
96 let p = NativePolicy::deny_tools(&["drop_database", "send_wire"]);
97 assert_eq!(p.evaluate("search", &json!({})), PolicyDecision::Allow);
98 assert!(matches!(
99 p.evaluate("drop_database", &json!({})),
100 PolicyDecision::Deny { .. }
101 ));
102 }
103
104 #[test]
105 fn allow_list() {
106 let p = NativePolicy::allow_only(&["search", "read_file"]);
107 assert_eq!(p.evaluate("search", &json!({})), PolicyDecision::Allow);
108 assert!(matches!(
109 p.evaluate("db_write", &json!({})),
110 PolicyDecision::Deny { .. }
111 ));
112 }
113
114 #[test]
115 fn custom_argument_rule() {
116 let p = NativePolicy::new("payout_ceiling", |tool, args| {
117 if tool == "payout" && args.get("amount_usd").and_then(Value::as_f64).unwrap_or(0.0) > 50.0 {
118 PolicyDecision::Deny {
119 reason: "single payout above $50".into(),
120 }
121 } else {
122 PolicyDecision::Allow
123 }
124 });
125 assert_eq!(
126 p.evaluate("payout", &json!({"amount_usd": 49.0})),
127 PolicyDecision::Allow
128 );
129 assert!(matches!(
130 p.evaluate("payout", &json!({"amount_usd": 51.0})),
131 PolicyDecision::Deny { .. }
132 ));
133 }
134
135 #[test]
136 fn native_policy_name_returns_the_registered_string() {
137 let a = NativePolicy::deny_tools(&["x"]);
140 assert_eq!(a.name(), "deny_tools");
141 let b = NativePolicy::allow_only(&["x"]);
142 assert_eq!(b.name(), "allow_only");
143 let c = NativePolicy::new("payout_ceiling", |_, _| PolicyDecision::Allow);
144 assert_eq!(c.name(), "payout_ceiling");
145 }
146}