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        // HashSet lookup instead of a per-call linear
48        // scan of a Vec<String> — deny lists grow with deployment age
49        // and this closure runs on every tool call.
50        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    /// Allow-list policy: only the named tools may run.
63    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        // Catches `name -> ""` and `name -> "xyzzy"` stubs: the returned
138        // string must match what the constructor was given.
139        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}