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 match (config.mode, policy) {
140 (_, Auto) => ApprovalDecision::Allow,
141 (_, Always) => require(call, "always-policy tool"),
142 (AutoRun, OnDemand) => ApprovalDecision::Allow,
143 (AllowList, OnDemand) if config.allow_list.iter().any(|k| k == &call.grant_key()) => {
144 ApprovalDecision::Allow
145 }
146 (_, OnDemand) => require(call, "approval required"),
147 }
148 }
149}
150
151fn require(call: &ToolCall<'_>, why: &str) -> ApprovalDecision {
152 ApprovalDecision::RequireApproval {
153 reason: format!("{}: {}", call.tool, why),
154 }
155}
156
157pub fn default_tool_policy_map() -> HashMap<String, ToolPolicy> {
159 DEFAULT_TOOL_POLICIES
160 .iter()
161 .map(|(n, p)| (n.to_string(), *p))
162 .collect()
163}
164
165#[cfg(test)]
166mod tests {
167 use super::super::policy::{ApprovalConfig, ApprovalMode::*, ToolPolicy};
168 use super::*;
169 use serde_json::json;
170 use std::collections::HashMap;
171
172 fn gate(mode: ApprovalMode, allow_list: &[&str]) -> ApprovalGate {
173 let policies = default_tool_policy_map();
174 let config = ApprovalConfig {
175 mode,
176 allow_list: allow_list.iter().map(|s| s.to_string()).collect(),
177 tool_overrides: HashMap::new(),
178 };
179 ApprovalGate::new(policies, config)
180 }
181
182 fn call<'a>(tool: &'a str, binary: Option<&'a str>) -> ToolCall<'a> {
183 static EMPTY_ARGS: std::sync::LazyLock<serde_json::Value> =
184 std::sync::LazyLock::new(|| serde_json::json!({}));
185 ToolCall {
186 tool,
187 binary,
188 args: &EMPTY_ARGS,
189 }
190 }
191 #[test]
193 fn auto_allow_in_manual() {
194 assert!(matches!(
195 gate(Manual, &[]).evaluate(&call("read", None)),
196 ApprovalDecision::Allow
197 ));
198 }
199 #[test]
200 fn auto_allow_in_allowlist() {
201 assert!(matches!(
202 gate(AllowList, &[]).evaluate(&call("read", None)),
203 ApprovalDecision::Allow
204 ));
205 }
206 #[test]
207 fn auto_allow_in_autorun() {
208 assert!(matches!(
209 gate(AutoRun, &[]).evaluate(&call("read", None)),
210 ApprovalDecision::Allow
211 ));
212 }
213
214 #[test]
216 fn ondemand_autorun_allows() {
217 assert!(matches!(
218 gate(AutoRun, &[]).evaluate(&call("exec", Some("curl"))),
219 ApprovalDecision::Allow
220 ));
221 }
222
223 #[test]
225 fn ondemand_allowlist_grant_allows() {
226 assert!(matches!(
227 gate(AllowList, &["exec:curl"]).evaluate(&call("exec", Some("curl"))),
228 ApprovalDecision::Allow
229 ));
230 }
231 #[test]
232 fn ondemand_allowlist_no_grant_prompts() {
233 assert!(matches!(
234 gate(AllowList, &[]).evaluate(&call("exec", Some("curl"))),
235 ApprovalDecision::RequireApproval { .. }
236 ));
237 }
238
239 #[test]
241 fn ondemand_manual_prompts() {
242 assert!(matches!(
243 gate(Manual, &[]).evaluate(&call("exec", Some("curl"))),
244 ApprovalDecision::RequireApproval { .. }
245 ));
246 }
247
248 #[test]
250 fn always_override_prompts_in_autorun() {
251 let policies = default_tool_policy_map();
252 let mut overrides = HashMap::new();
253 overrides.insert("exec".to_string(), ToolPolicy::Always);
254 let config = ApprovalConfig {
255 mode: AutoRun,
256 allow_list: vec![],
257 tool_overrides: overrides,
258 };
259 let g = ApprovalGate::new(policies, config);
260 assert!(matches!(
261 g.evaluate(&call("exec", Some("curl"))),
262 ApprovalDecision::RequireApproval { .. }
263 ));
264 }
265
266 #[test]
268 fn blacklist_beats_auto_override() {
269 let policies = default_tool_policy_map();
270 let mut overrides = HashMap::new();
271 overrides.insert("exec".to_string(), ToolPolicy::Auto); let config = ApprovalConfig {
273 mode: AutoRun,
274 allow_list: vec![],
275 tool_overrides: overrides,
276 };
277 let blacklist = super::super::blacklist::SecurityBlacklist::new(
278 super::super::blacklist::default_blacklist_rules(),
279 );
280 let g = ApprovalGate::with_global_resolvers(policies, config, vec![Box::new(blacklist)]);
281 let args = json!({"mode": "shell", "command": "rm -rf /etc"});
282 let rm_call = ToolCall {
283 tool: "exec",
284 binary: None,
285 args: &args,
286 };
287 assert!(matches!(
288 g.evaluate(&rm_call),
289 ApprovalDecision::RequireApproval { .. }
290 ));
291 }
292
293 #[test]
299 fn always_override_sticks_across_dynamic_resolver() {
300 use super::super::resolver::ToolPolicyResolver;
301 let policies = default_tool_policy_map();
302 let mut overrides = HashMap::new();
303 overrides.insert("exec".to_string(), ToolPolicy::Always);
304 let config = Arc::new(parking_lot::RwLock::new(ApprovalConfig {
305 mode: AutoRun,
306 allow_list: vec![],
307 tool_overrides: overrides,
308 }));
309 struct AlwaysAuto;
311 impl ToolPolicyResolver for AlwaysAuto {
312 fn resolve(&self, _args: &serde_json::Value) -> Option<ToolPolicy> {
313 Some(ToolPolicy::Auto)
314 }
315 }
316 let mut dynamic = HashMap::new();
317 dynamic.insert(
318 "exec".to_string(),
319 Box::new(AlwaysAuto) as Box<dyn ToolPolicyResolver>,
320 );
321 let g = ApprovalGate::with_dynamic_resolvers(policies, config, vec![], dynamic);
322 assert!(
323 matches!(
324 g.evaluate(&call("exec", Some("curl"))),
325 ApprovalDecision::RequireApproval { .. }
326 ),
327 "explicit Always must NOT be relaxed by the dynamic resolver"
328 );
329 }
330
331 #[test]
332 fn grant_key_exec_includes_binary() {
333 assert_eq!(call("exec", Some("curl")).grant_key(), "exec:curl");
334 assert_eq!(call("exec", None).grant_key(), "exec:shell");
335 assert_eq!(call("read", None).grant_key(), "read");
336 }
337 #[test]
338 fn shared_config_mutation_takes_effect_live() {
339 let shared = Arc::new(parking_lot::RwLock::new(ApprovalConfig {
340 mode: ApprovalMode::AllowList,
341 allow_list: vec![],
342 tool_overrides: HashMap::new(),
343 }));
344 let gate =
345 ApprovalGate::with_shared_config(default_tool_policy_map(), shared.clone(), vec![]);
346 let curl = call("exec", Some("curl"));
347 assert!(matches!(
348 gate.evaluate(&curl),
349 ApprovalDecision::RequireApproval { .. }
350 ));
351 shared.write().allow_list.push("exec:curl".to_string());
352 assert!(matches!(gate.evaluate(&curl), ApprovalDecision::Allow));
353 shared.write().allow_list.clear();
354 shared.write().mode = ApprovalMode::AutoRun;
355 assert!(matches!(gate.evaluate(&curl), ApprovalDecision::Allow));
356 }
357}