Skip to main content

mermaid_cli/providers/
approval.rs

1//! Inline approval broker for interactive `ask` mode + Auto-mode escalations.
2//!
3//! When a tool is gated and a human decision is needed, the policy gate calls
4//! [`ApprovalBroker::request`] (the broker is injected into [`ExecContext`]).
5//! That sends a `Msg::ApprovalRequested` to the reducer — which renders a modal
6//! — and parks the tool task on a oneshot until the user answers. The reducer
7//! (pure) emits `Cmd::ResolveApproval`; the `EffectRunner` calls
8//! [`ApprovalBroker::resolve`]; the parked task wakes and the tool proceeds or
9//! is denied. The turn pauses for free: while parked, the task hasn't sent
10//! `Msg::ToolFinished`, so its outcome slot stays `None` and no follow-up model
11//! call fires.
12//!
13//! Lock discipline: `pending`/`allowlist` use [`std::sync::Mutex`] (whose guard
14//! is `!Send`) so a guard accidentally held across an `.await` fails to
15//! compile. Every critical section here is tiny and fully synchronous.
16//!
17//! [`ExecContext`]: crate::providers::ctx::ExecContext
18
19use 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/// The user's decision, broker-side. `Cmd::ResolveApproval` carries the pure
28/// `domain::ApprovalChoice`; the `EffectRunner` maps it to this.
29#[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/// Owned by the interactive `EffectRunner`, cloned into each `ExecContext`.
52/// Absent (`None`) in headless runs — the gate then falls back to the
53/// out-of-band DB-approval flow.
54#[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    /// True if the user already chose "don't ask again" for this key.
71    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    /// Prompt the user and block until they answer (or the turn is cancelled).
79    /// Fail-safe: a dropped sender, a gone reducer, or a cancel all resolve to
80    /// `Deny`.
81    #[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        // Register the sender. The guard drops at the end of this statement —
95        // never held across the awaits below.
96        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            // Reducer is gone — clean up and deny.
121            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    /// Deliver the user's decision to the parked task. On `ApproveAlways`,
139    /// remember the key so future matching actions skip the prompt this session.
140    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            // An empty key marks a non-allowlistable action (content-bearing
148            // external tools): never persist it, so "approve always" can't be
149            // recorded for them even if the choice somehow arrives (#6, #31).
150            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
161/// External tools whose risk depends on live runtime context (which window has
162/// focus, what page is loaded, which untrusted server answers), not just their
163/// arguments. A blanket "don't ask again" for these is unsafe — it would mean
164/// "always send any URL/query", "always type anything", or "always run any MCP
165/// tool" — so they are non-allowlistable: an empty key, which the gate and
166/// modal treat as "no approve-always" (#6, #31).
167const 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
178/// Compute the session "don't ask again" allowlist key.
179///
180/// - Content-bearing external tools ([`NON_ALLOWLISTABLE_TOOLS`]) return an
181///   **empty** key, meaning non-allowlistable: the modal hides the
182///   approve-always option and the broker never persists an entry.
183/// - `execute_command` keys on the **full normalized command** (whitespace
184///   collapsed), so approving `curl https://safe.example` does NOT also clear
185///   `curl https://evil.example` — argv0 keying was too coarse for a tool whose
186///   danger lives entirely in its arguments (#6).
187/// - Everything else keys per-tool.
188pub 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        // #6: execute_command keys on the FULL normalized command, so approving
212        // one invocation can't clear a different-argument one.
213        assert_eq!(
214            allowlist_key("execute_command", Some("ls -la")),
215            "execute_command:ls -la"
216        );
217        // No command falls back to the bare tool key.
218        assert_eq!(allowlist_key("execute_command", None), "execute_command");
219    }
220
221    #[test]
222    fn allowlist_key_distinguishes_argument_variants() {
223        // #6: approving `curl https://safe` must NOT also clear `curl https://evil`.
224        assert_ne!(
225            allowlist_key("execute_command", Some("curl https://safe.example")),
226            allowlist_key("execute_command", Some("curl https://evil.example")),
227        );
228        // Whitespace is normalized so trivial spacing differences still match.
229        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        // #6/#31: a blanket "approve always" for these is unsafe (their risk is
246        // context-dependent), so the key is empty ⇒ non-allowlistable.
247        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        // Spawn a request; resolve it from "the reducer side".
272        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        // Give the task a beat to register, then resolve.
287        tokio::task::yield_now().await;
288        // Poll until registered (the send + insert happen before the await).
289        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; // silence unused in this path
300    }
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}