Skip to main content

actionguard/
policies.rs

1use crate::{AsyncPolicy, Policy, ToolCall, Vote};
2use std::collections::HashSet;
3use std::future::Future;
4
5/// Votes `Allow` for calls whose tool name is in the list, `Abstain` otherwise —
6/// abstaining (not denying) on the rest lets this compose with other policies
7/// instead of being the sole word on every call.
8pub struct AllowList {
9    names: HashSet<String>,
10}
11
12impl AllowList {
13    pub fn new(names: impl IntoIterator<Item = impl Into<String>>) -> Self {
14        Self {
15            names: names.into_iter().map(Into::into).collect(),
16        }
17    }
18}
19
20impl Policy for AllowList {
21    fn name(&self) -> &str {
22        "allow_list"
23    }
24
25    fn vote(&self, call: &ToolCall) -> Vote {
26        if self.names.contains(&call.name) {
27            Vote::Allow
28        } else {
29            Vote::Abstain
30        }
31    }
32}
33
34/// Votes `Deny` for calls whose tool name is in the list, `Abstain` otherwise.
35/// A `Deny` here wins regardless of what any [`AllowList`] says.
36pub struct DenyList {
37    names: HashSet<String>,
38}
39
40impl DenyList {
41    pub fn new(names: impl IntoIterator<Item = impl Into<String>>) -> Self {
42        Self {
43            names: names.into_iter().map(Into::into).collect(),
44        }
45    }
46}
47
48impl Policy for DenyList {
49    fn name(&self) -> &str {
50        "deny_list"
51    }
52
53    fn vote(&self, call: &ToolCall) -> Vote {
54        if self.names.contains(&call.name) {
55            Vote::Deny(format!("{} is on the deny list", call.name))
56        } else {
57            Vote::Abstain
58        }
59    }
60}
61
62/// For calls to `tool`, requires the string argument `argument` to match `pattern`.
63/// Abstains for other tools. For `tool`, votes `Allow` on a match and `Deny` on a
64/// missing/non-string/non-matching argument — e.g. keeping a `read_file` call
65/// inside `/workspace` regardless of what an [`AllowList`] says about the tool name.
66pub struct ArgMatchesRegex {
67    tool: String,
68    argument: String,
69    pattern: regex::Regex,
70}
71
72impl ArgMatchesRegex {
73    pub fn new(
74        tool: impl Into<String>,
75        argument: impl Into<String>,
76        pattern: &str,
77    ) -> Result<Self, regex::Error> {
78        Ok(Self {
79            tool: tool.into(),
80            argument: argument.into(),
81            pattern: regex::Regex::new(pattern)?,
82        })
83    }
84}
85
86impl Policy for ArgMatchesRegex {
87    fn name(&self) -> &str {
88        "arg_matches_regex"
89    }
90
91    fn vote(&self, call: &ToolCall) -> Vote {
92        if call.name != self.tool {
93            return Vote::Abstain;
94        }
95        match call.argument_str(&self.argument) {
96            Some(value) if self.pattern.is_match(value) => Vote::Allow,
97            Some(value) => Vote::Deny(format!(
98                "{}={value:?} does not match /{}/",
99                self.argument,
100                self.pattern.as_str()
101            )),
102            None => Vote::Deny(format!(
103                "missing or non-string argument {:?}",
104                self.argument
105            )),
106        }
107    }
108}
109
110/// Wraps an async closure as an [`AsyncPolicy`] — the escape hatch for checks this
111/// crate can't sensibly hardcode: an LLM-as-judge asking whether an action matches
112/// the user's stated intent, a call to an external policy service.
113pub struct CustomAsyncPolicy<F> {
114    name: String,
115    vote: F,
116}
117
118impl<F, Fut> CustomAsyncPolicy<F>
119where
120    F: Fn(ToolCall) -> Fut + Send + Sync,
121    Fut: Future<Output = Vote> + Send,
122{
123    pub fn new(name: impl Into<String>, vote: F) -> Self {
124        Self {
125            name: name.into(),
126            vote,
127        }
128    }
129}
130
131#[async_trait::async_trait]
132impl<F, Fut> AsyncPolicy for CustomAsyncPolicy<F>
133where
134    F: Fn(ToolCall) -> Fut + Send + Sync,
135    Fut: Future<Output = Vote> + Send,
136{
137    fn name(&self) -> &str {
138        &self.name
139    }
140
141    async fn vote(&self, call: &ToolCall) -> Vote {
142        (self.vote)(call.clone()).await
143    }
144}