Skip to main content

av_sandbox/
policy.rs

1//! Policy engines: the trait + native Rust rules.
2
3use serde_json::Value;
4
5/// A policy verdict.
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum PolicyDecision {
8    /// Allow the call.
9    Allow,
10    /// Deny with a reason (returned to the agent in the authorization error).
11    Deny {
12        /// Machine-readable reason.
13        reason: String,
14    },
15}
16
17/// A policy engine evaluates (tool, arguments) → decision. Engines must be
18/// total: any internal failure is a `Deny` (fail-closed), never a panic.
19pub trait PolicyEngine: Send + Sync {
20    /// Engine name (for events/metrics).
21    fn name(&self) -> &str;
22    /// Evaluate a tool call.
23    fn evaluate(&self, tool: &str, arguments: &Value) -> PolicyDecision;
24}
25
26/// Native policy: closure-based rules (the zero-dependency default).
27pub 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    /// Build from a rule closure.
35    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    /// Deny-list policy: block the named tools outright.
46    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    /// Allow-list policy: only the named tools may run.
60    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        // Catches `name -> ""` and `name -> "xyzzy"` stubs: the returned
135        // string must match what the constructor was given.
136        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}