1use std::collections::{HashMap, HashSet};
20use std::sync::{Arc, Mutex};
21
22use tokio::sync::{mpsc, oneshot};
23use tokio_util::sync::CancellationToken;
24
25use crate::domain::{ApprovalChoice, ApprovalKind, Msg, ToolCallId, TurnId};
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum ApprovalDecision {
31 Approve,
32 ApproveAlways,
33 Deny,
34}
35
36impl From<ApprovalChoice> for ApprovalDecision {
37 fn from(choice: ApprovalChoice) -> Self {
38 match choice {
39 ApprovalChoice::Approve => ApprovalDecision::Approve,
40 ApprovalChoice::ApproveAlways => ApprovalDecision::ApproveAlways,
41 ApprovalChoice::Deny => ApprovalDecision::Deny,
42 }
43 }
44}
45
46struct PendingEntry {
47 tx: oneshot::Sender<ApprovalDecision>,
48 allowlist_key: String,
49}
50
51#[derive(Clone)]
55pub struct ApprovalBroker {
56 pending: Arc<Mutex<HashMap<ToolCallId, PendingEntry>>>,
57 allowlist: Arc<Mutex<HashSet<String>>>,
58 msg_tx: mpsc::Sender<Msg>,
59}
60
61impl ApprovalBroker {
62 pub fn new(msg_tx: mpsc::Sender<Msg>) -> Self {
63 Self {
64 pending: Arc::new(Mutex::new(HashMap::new())),
65 allowlist: Arc::new(Mutex::new(HashSet::new())),
66 msg_tx,
67 }
68 }
69
70 pub fn is_allowlisted(&self, key: &str) -> bool {
72 self.allowlist
73 .lock()
74 .unwrap_or_else(|poisoned| poisoned.into_inner())
75 .contains(key)
76 }
77
78 #[allow(clippy::too_many_arguments)]
82 pub async fn request(
83 &self,
84 token: &CancellationToken,
85 turn: TurnId,
86 call_id: ToolCallId,
87 tool: String,
88 risk: String,
89 kind: ApprovalKind,
90 prompt: String,
91 allowlist_key: String,
92 ) -> ApprovalDecision {
93 let (tx, rx) = oneshot::channel();
94 self.pending
97 .lock()
98 .unwrap_or_else(|poisoned| poisoned.into_inner())
99 .insert(
100 call_id,
101 PendingEntry {
102 tx,
103 allowlist_key: allowlist_key.clone(),
104 },
105 );
106
107 let sent = self
108 .msg_tx
109 .send(Msg::ApprovalRequested {
110 turn,
111 call_id,
112 tool,
113 risk,
114 kind,
115 prompt,
116 allowlist_scope: allowlist_key,
117 })
118 .await;
119 if sent.is_err() {
120 self.pending
122 .lock()
123 .unwrap_or_else(|poisoned| poisoned.into_inner())
124 .remove(&call_id);
125 return ApprovalDecision::Deny;
126 }
127
128 tokio::select! {
129 biased;
130 _ = token.cancelled() => {
131 self.pending.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).remove(&call_id);
132 ApprovalDecision::Deny
133 }
134 decision = rx => decision.unwrap_or(ApprovalDecision::Deny),
135 }
136 }
137
138 pub fn resolve(&self, call_id: ToolCallId, decision: ApprovalDecision) {
141 let entry = self
142 .pending
143 .lock()
144 .unwrap_or_else(|poisoned| poisoned.into_inner())
145 .remove(&call_id);
146 if let Some(entry) = entry {
147 if decision == ApprovalDecision::ApproveAlways && !entry.allowlist_key.is_empty() {
151 self.allowlist
152 .lock()
153 .unwrap_or_else(|poisoned| poisoned.into_inner())
154 .insert(entry.allowlist_key);
155 }
156 let _ = entry.tx.send(decision);
157 }
158 }
159}
160
161const NON_ALLOWLISTABLE_TOOLS: &[&str] = &[
168 "web_fetch",
169 "web_search",
170 "type_text",
171 "press_key",
172 "click",
173 "mouse_move",
174 "scroll",
175 "mcp_proxy",
176];
177
178pub fn allowlist_key(tool: &str, command: Option<&str>) -> String {
189 if NON_ALLOWLISTABLE_TOOLS.contains(&tool) {
190 return String::new();
191 }
192 if tool == "execute_command" {
193 if let Some(cmd) = command {
194 let normalized = cmd.split_whitespace().collect::<Vec<_>>().join(" ");
195 if !normalized.is_empty() {
196 return format!("execute_command:{normalized}");
197 }
198 }
199 return "execute_command".to_string();
200 }
201 tool.to_string()
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 #[test]
209 fn allowlist_key_is_per_tool_with_full_command() {
210 assert_eq!(allowlist_key("write_file", None), "write_file");
211 assert_eq!(
214 allowlist_key("execute_command", Some("ls -la")),
215 "execute_command:ls -la"
216 );
217 assert_eq!(allowlist_key("execute_command", None), "execute_command");
219 }
220
221 #[test]
222 fn allowlist_key_distinguishes_argument_variants() {
223 assert_ne!(
225 allowlist_key("execute_command", Some("curl https://safe.example")),
226 allowlist_key("execute_command", Some("curl https://evil.example")),
227 );
228 assert_eq!(
230 allowlist_key("execute_command", Some("cargo build")),
231 allowlist_key("execute_command", Some("cargo build")),
232 );
233 assert_eq!(
234 allowlist_key("execute_command", Some("npm test")),
235 "execute_command:npm test"
236 );
237 assert_ne!(
238 allowlist_key("execute_command", Some("npm test")),
239 allowlist_key("execute_command", Some("npm run build")),
240 );
241 }
242
243 #[test]
244 fn content_bearing_tools_are_non_allowlistable() {
245 for tool in [
248 "web_fetch",
249 "web_search",
250 "type_text",
251 "press_key",
252 "click",
253 "mouse_move",
254 "scroll",
255 "mcp_proxy",
256 ] {
257 assert_eq!(
258 allowlist_key(tool, None),
259 "",
260 "{tool} must be non-allowlistable"
261 );
262 }
263 }
264
265 #[tokio::test]
266 async fn resolve_delivers_decision_and_approve_always_allowlists() {
267 let (tx, _rx) = mpsc::channel::<Msg>(8);
268 let broker = ApprovalBroker::new(tx);
269 let token = CancellationToken::new();
270
271 let b2 = broker.clone();
273 let handle = tokio::spawn(async move {
274 b2.request(
275 &CancellationToken::new(),
276 TurnId(1),
277 ToolCallId(1),
278 "execute_command".to_string(),
279 "shell_mutation".to_string(),
280 ApprovalKind::Shell,
281 "$ npm test".to_string(),
282 "execute_command:npm".to_string(),
283 )
284 .await
285 });
286 tokio::task::yield_now().await;
288 for _ in 0..100 {
290 broker.resolve(ToolCallId(1), ApprovalDecision::ApproveAlways);
291 if broker.is_allowlisted("execute_command:npm") {
292 break;
293 }
294 tokio::task::yield_now().await;
295 }
296 let decision = handle.await.unwrap();
297 assert_eq!(decision, ApprovalDecision::ApproveAlways);
298 assert!(broker.is_allowlisted("execute_command:npm"));
299 let _ = token; }
301
302 #[tokio::test]
303 async fn cancel_token_denies() {
304 let (tx, _rx) = mpsc::channel::<Msg>(8);
305 let broker = ApprovalBroker::new(tx);
306 let token = CancellationToken::new();
307 let token2 = token.clone();
308 let handle = tokio::spawn(async move {
309 broker
310 .request(
311 &token2,
312 TurnId(1),
313 ToolCallId(2),
314 "web_fetch".to_string(),
315 "network".to_string(),
316 ApprovalKind::Web,
317 "web_fetch https://x".to_string(),
318 "web_fetch".to_string(),
319 )
320 .await
321 });
322 tokio::task::yield_now().await;
323 token.cancel();
324 assert_eq!(handle.await.unwrap(), ApprovalDecision::Deny);
325 }
326}