1use std::collections::HashMap;
4use std::sync::Arc;
5
6use serde_json::Value;
7
8use super::policy::{ApprovalConfig, ApprovalMode, DEFAULT_TOOL_POLICIES, ToolPolicy};
9use super::resolver::{GlobalResolver, ToolPolicyResolver};
10
11pub struct ToolCall<'a> {
13 pub tool: &'a str,
15 pub binary: Option<&'a str>,
17 pub args: &'a Value,
19}
20
21impl ToolCall<'_> {
22 pub fn grant_key(&self) -> String {
24 match self.tool {
25 "exec" => format!("exec:{}", self.binary.unwrap_or("shell")),
26 other => other.to_string(),
27 }
28 }
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum ApprovalDecision {
33 Allow,
35 RequireApproval { reason: String },
37}
38
39pub struct ApprovalGate {
40 tool_policies: HashMap<String, ToolPolicy>,
41 config: Arc<parking_lot::RwLock<ApprovalConfig>>,
42 global_resolvers: Vec<Box<dyn GlobalResolver>>,
43 dynamic_resolvers: HashMap<String, Box<dyn ToolPolicyResolver>>,
51}
52
53impl ApprovalGate {
54 pub fn new(tool_policies: HashMap<String, ToolPolicy>, config: ApprovalConfig) -> Self {
55 Self::with_global_resolvers(tool_policies, config, Vec::new())
56 }
57 pub fn with_global_resolvers(
58 tool_policies: HashMap<String, ToolPolicy>,
59 config: ApprovalConfig,
60 global_resolvers: Vec<Box<dyn GlobalResolver>>,
61 ) -> Self {
62 Self {
63 tool_policies,
64 config: Arc::new(parking_lot::RwLock::new(config)),
65 global_resolvers,
66 dynamic_resolvers: HashMap::new(),
67 }
68 }
69 pub fn with_shared_config(
70 tool_policies: HashMap<String, ToolPolicy>,
71 config: Arc<parking_lot::RwLock<ApprovalConfig>>,
72 global_resolvers: Vec<Box<dyn GlobalResolver>>,
73 ) -> Self {
74 Self {
75 tool_policies,
76 config,
77 global_resolvers,
78 dynamic_resolvers: HashMap::new(),
79 }
80 }
81 pub fn with_dynamic_resolvers(
86 tool_policies: HashMap<String, ToolPolicy>,
87 config: Arc<parking_lot::RwLock<ApprovalConfig>>,
88 global_resolvers: Vec<Box<dyn GlobalResolver>>,
89 dynamic_resolvers: HashMap<String, Box<dyn ToolPolicyResolver>>,
90 ) -> Self {
91 Self {
92 tool_policies,
93 config,
94 global_resolvers,
95 dynamic_resolvers,
96 }
97 }
98
99 pub fn evaluate(&self, call: &ToolCall<'_>) -> ApprovalDecision {
100 let config = self.config.read();
101 let mut policy = self
103 .tool_policies
104 .get(call.tool)
105 .copied()
106 .unwrap_or(ToolPolicy::OnDemand);
107 if let Some(&override_p) = config.tool_overrides.get(call.tool) {
109 policy = override_p;
110 }
111 if let Some(resolver) = self.dynamic_resolvers.get(call.tool)
123 && let Some(p) = resolver.resolve(call.args)
124 {
125 policy = if policy == ToolPolicy::Always {
126 ToolPolicy::Always
127 } else {
128 p
129 };
130 }
131 for resolver in &self.global_resolvers {
133 if let Some(p) = resolver.resolve(call) {
134 policy = policy.max(p);
135 }
136 }
137 use {ApprovalMode::*, ToolPolicy::*};
139 let has_grant = config.allow_list.iter().any(|k| k == &call.grant_key());
140 match (config.mode, policy) {
141 (_, Auto) => ApprovalDecision::Allow,
142 (_, Always) => require(call, "always-policy tool"),
143 (AutoRun, OnDemand) => ApprovalDecision::Allow,
144 (AllowList, OnDemand) if has_grant => ApprovalDecision::Allow,
145 (Manual, OnDemand) if has_grant => ApprovalDecision::Allow,
151 (_, OnDemand) => require(call, "approval required"),
152 }
153 }
154}
155
156fn require(call: &ToolCall<'_>, why: &str) -> ApprovalDecision {
157 ApprovalDecision::RequireApproval {
158 reason: format!("{}: {}", call.tool, why),
159 }
160}
161
162pub fn default_tool_policy_map() -> HashMap<String, ToolPolicy> {
164 DEFAULT_TOOL_POLICIES
165 .iter()
166 .map(|(n, p)| (n.to_string(), *p))
167 .collect()
168}
169
170#[cfg(test)]
171mod tests {
172 use super::super::policy::{ApprovalConfig, ApprovalMode::*, ToolPolicy};
173 use super::*;
174 use serde_json::json;
175 use std::collections::HashMap;
176
177 fn gate(mode: ApprovalMode, allow_list: &[&str]) -> ApprovalGate {
178 let policies = default_tool_policy_map();
179 let config = ApprovalConfig {
180 mode,
181 allow_list: allow_list.iter().map(|s| s.to_string()).collect(),
182 tool_overrides: HashMap::new(),
183 };
184 ApprovalGate::new(policies, config)
185 }
186
187 fn call<'a>(tool: &'a str, binary: Option<&'a str>) -> ToolCall<'a> {
188 static EMPTY_ARGS: std::sync::LazyLock<serde_json::Value> =
189 std::sync::LazyLock::new(|| serde_json::json!({}));
190 ToolCall {
191 tool,
192 binary,
193 args: &EMPTY_ARGS,
194 }
195 }
196 #[test]
198 fn auto_allow_in_manual() {
199 assert!(matches!(
200 gate(Manual, &[]).evaluate(&call("read", None)),
201 ApprovalDecision::Allow
202 ));
203 }
204 #[test]
205 fn auto_allow_in_allowlist() {
206 assert!(matches!(
207 gate(AllowList, &[]).evaluate(&call("read", None)),
208 ApprovalDecision::Allow
209 ));
210 }
211 #[test]
212 fn auto_allow_in_autorun() {
213 assert!(matches!(
214 gate(AutoRun, &[]).evaluate(&call("read", None)),
215 ApprovalDecision::Allow
216 ));
217 }
218
219 #[test]
221 fn ondemand_autorun_allows() {
222 assert!(matches!(
223 gate(AutoRun, &[]).evaluate(&call("exec", Some("curl"))),
224 ApprovalDecision::Allow
225 ));
226 }
227
228 #[test]
230 fn ondemand_allowlist_grant_allows() {
231 assert!(matches!(
232 gate(AllowList, &["exec:curl"]).evaluate(&call("exec", Some("curl"))),
233 ApprovalDecision::Allow
234 ));
235 }
236 #[test]
237 fn ondemand_allowlist_no_grant_prompts() {
238 assert!(matches!(
239 gate(AllowList, &[]).evaluate(&call("exec", Some("curl"))),
240 ApprovalDecision::RequireApproval { .. }
241 ));
242 }
243
244 #[test]
246 fn ondemand_manual_prompts() {
247 assert!(matches!(
248 gate(Manual, &[]).evaluate(&call("exec", Some("curl"))),
249 ApprovalDecision::RequireApproval { .. }
250 ));
251 }
252
253 #[test]
258 fn ondemand_manual_grant_allows() {
259 assert!(matches!(
260 gate(Manual, &["web_search"]).evaluate(&call("web_search", None)),
261 ApprovalDecision::Allow
262 ));
263 }
264
265 #[test]
267 fn always_override_prompts_in_autorun() {
268 let policies = default_tool_policy_map();
269 let mut overrides = HashMap::new();
270 overrides.insert("exec".to_string(), ToolPolicy::Always);
271 let config = ApprovalConfig {
272 mode: AutoRun,
273 allow_list: vec![],
274 tool_overrides: overrides,
275 };
276 let g = ApprovalGate::new(policies, config);
277 assert!(matches!(
278 g.evaluate(&call("exec", Some("curl"))),
279 ApprovalDecision::RequireApproval { .. }
280 ));
281 }
282
283 #[test]
285 fn blacklist_beats_auto_override() {
286 let policies = default_tool_policy_map();
287 let mut overrides = HashMap::new();
288 overrides.insert("exec".to_string(), ToolPolicy::Auto); let config = ApprovalConfig {
290 mode: AutoRun,
291 allow_list: vec![],
292 tool_overrides: overrides,
293 };
294 let blacklist = super::super::blacklist::SecurityBlacklist::new(
295 super::super::blacklist::default_blacklist_rules(),
296 );
297 let g = ApprovalGate::with_global_resolvers(policies, config, vec![Box::new(blacklist)]);
298 let args = json!({"mode": "shell", "command": "rm -rf /etc"});
299 let rm_call = ToolCall {
300 tool: "exec",
301 binary: None,
302 args: &args,
303 };
304 assert!(matches!(
305 g.evaluate(&rm_call),
306 ApprovalDecision::RequireApproval { .. }
307 ));
308 }
309
310 #[test]
316 fn always_override_sticks_across_dynamic_resolver() {
317 use super::super::resolver::ToolPolicyResolver;
318 let policies = default_tool_policy_map();
319 let mut overrides = HashMap::new();
320 overrides.insert("exec".to_string(), ToolPolicy::Always);
321 let config = Arc::new(parking_lot::RwLock::new(ApprovalConfig {
322 mode: AutoRun,
323 allow_list: vec![],
324 tool_overrides: overrides,
325 }));
326 struct AlwaysAuto;
328 impl ToolPolicyResolver for AlwaysAuto {
329 fn resolve(&self, _args: &serde_json::Value) -> Option<ToolPolicy> {
330 Some(ToolPolicy::Auto)
331 }
332 }
333 let mut dynamic = HashMap::new();
334 dynamic.insert(
335 "exec".to_string(),
336 Box::new(AlwaysAuto) as Box<dyn ToolPolicyResolver>,
337 );
338 let g = ApprovalGate::with_dynamic_resolvers(policies, config, vec![], dynamic);
339 assert!(
340 matches!(
341 g.evaluate(&call("exec", Some("curl"))),
342 ApprovalDecision::RequireApproval { .. }
343 ),
344 "explicit Always must NOT be relaxed by the dynamic resolver"
345 );
346 }
347
348 #[test]
349 fn grant_key_exec_includes_binary() {
350 assert_eq!(call("exec", Some("curl")).grant_key(), "exec:curl");
351 assert_eq!(call("exec", None).grant_key(), "exec:shell");
352 assert_eq!(call("read", None).grant_key(), "read");
353 }
354 #[test]
355 fn shared_config_mutation_takes_effect_live() {
356 let shared = Arc::new(parking_lot::RwLock::new(ApprovalConfig {
357 mode: ApprovalMode::AllowList,
358 allow_list: vec![],
359 tool_overrides: HashMap::new(),
360 }));
361 let gate =
362 ApprovalGate::with_shared_config(default_tool_policy_map(), shared.clone(), vec![]);
363 let curl = call("exec", Some("curl"));
364 assert!(matches!(
365 gate.evaluate(&curl),
366 ApprovalDecision::RequireApproval { .. }
367 ));
368 shared.write().allow_list.push("exec:curl".to_string());
369 assert!(matches!(gate.evaluate(&curl), ApprovalDecision::Allow));
370 shared.write().allow_list.clear();
371 shared.write().mode = ApprovalMode::AutoRun;
372 assert!(matches!(gate.evaluate(&curl), ApprovalDecision::Allow));
373 }
374}