Skip to main content

recursive/acp/
permission.rs

1//! ACP Sprint-1 permission bridge.
2//!
3//! Defines [`PermissionOutcome`], [`PermissionDecision`], and
4//! a complete permission flow connecting the ACP `session/request_permission`
5//! notification/response protocol to the agent's [`PermissionHook`].
6//!
7//! # Architecture
8//!
9//! 1. When a tool call requires permission, [`AcpPermissionHook::check`]
10//!    sends a `session/request_permission` JSON-RPC notification to the
11//!    client with `permission_id`, `tool_name`, and argument details.
12//! 2. The client receives the notification (as a `session/update` with
13//!    `sessionUpdate: "request_permission"`) and sends back a
14//!    `session/request_permission` JSON-RPC request with the outcome.
15//! 3. The ACP server's dispatch loop routes this to
16//!    [`handle_request_permission`] which resolves the pending
17//!    oneshot channel.
18//! 4. [`AcpPermissionHook::check`] receives the outcome and returns
19//!    `Allow` or `Deny`.
20//! 5. A 30-second timeout defaults to `Deny`.
21
22use std::collections::HashMap;
23use std::sync::Arc;
24use std::sync::Mutex as StdMutex;
25use std::time::Duration;
26use tokio::sync::oneshot;
27
28use async_trait::async_trait;
29use serde_json::Value;
30
31use crate::agent::PermissionDecision;
32use crate::tools::PermissionHook;
33
34/// The outcome of a client permission decision.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum PermissionOutcome {
37    /// Client allowed the tool call.
38    Allowed,
39    /// Client denied the tool call.
40    Denied,
41    /// Client did not respond within the timeout.
42    Timeout,
43}
44
45/// Translate a [`PermissionOutcome`] to [`PermissionDecision`].
46pub fn translate(outcome: PermissionOutcome) -> PermissionDecision {
47    match outcome {
48        PermissionOutcome::Allowed => PermissionDecision::Allow,
49        PermissionOutcome::Denied | PermissionOutcome::Timeout => {
50            PermissionDecision::Deny("permission denied".to_string())
51        }
52    }
53}
54
55// ---------------------------------------------------------------------------
56// PendingPermissionStore
57// ---------------------------------------------------------------------------
58
59/// Thread-safe store of pending permission requests, keyed by permission ID.
60///
61/// Shared between the [`AcpPermissionHook`] (which inserts entries when it
62/// sends a notification) and the ACP server's dispatch loop (which resolves
63/// entries when the client responds via `session/request_permission`).
64#[derive(Clone, Default)]
65pub struct PendingPermissionStore {
66    inner: Arc<StdMutex<HashMap<String, oneshot::Sender<PermissionOutcome>>>>,
67}
68
69impl PendingPermissionStore {
70    /// Create a new empty store.
71    pub fn new() -> Self {
72        Self {
73            inner: Arc::new(StdMutex::new(HashMap::new())),
74        }
75    }
76
77    /// Insert a pending permission request.
78    pub fn insert(&self, id: String, tx: oneshot::Sender<PermissionOutcome>) {
79        if let Ok(mut guard) = self.inner.lock() {
80            guard.insert(id, tx);
81        }
82    }
83
84    /// Remove and return the sender for a given permission ID.
85    pub fn remove(&self, id: &str) -> Option<oneshot::Sender<PermissionOutcome>> {
86        self.inner
87            .lock()
88            .ok()
89            .and_then(|mut guard| guard.remove(id))
90    }
91
92    /// Check if a permission ID is pending.
93    #[allow(dead_code)]
94    pub fn contains(&self, id: &str) -> bool {
95        self.inner
96            .lock()
97            .ok()
98            .is_some_and(|guard| guard.contains_key(id))
99    }
100}
101
102// ---------------------------------------------------------------------------
103// AcpPermissionHook
104// ---------------------------------------------------------------------------
105
106/// A [`PermissionHook`] that communicates permission requests to the ACP client.
107///
108/// When `check()` is called:
109/// 1. Sends a `session/update` notification with `sessionUpdate: "request_permission"`
110///    via the provided notification sender.
111/// 2. Stores the oneshot sender in the shared [`PendingPermissionStore`].
112/// 3. Waits for the client's response (or 30-second timeout).
113/// 4. Returns `Allow` or `Deny` based on the outcome.
114pub struct AcpPermissionHook {
115    /// Shared store of pending requests.
116    store: PendingPermissionStore,
117    /// Session ID for ACP notification routing.
118    session_id: String,
119    /// Sender for JSON-RPC notifications to the client.
120    notif_tx: tokio::sync::mpsc::UnboundedSender<Value>,
121}
122
123impl AcpPermissionHook {
124    /// Create a new ACP permission hook.
125    pub fn new(
126        store: PendingPermissionStore,
127        session_id: String,
128        notif_tx: tokio::sync::mpsc::UnboundedSender<Value>,
129    ) -> Self {
130        Self {
131            store,
132            session_id,
133            notif_tx,
134        }
135    }
136}
137
138#[async_trait]
139impl PermissionHook for AcpPermissionHook {
140    async fn check(&self, tool_name: &str, args: &Value) -> PermissionDecision {
141        let perm_id = uuid::Uuid::new_v4().to_string();
142        let (tx, rx) = oneshot::channel();
143
144        self.store.insert(perm_id.clone(), tx);
145
146        // Build the request_permission notification
147        let notif = serde_json::json!({
148            "jsonrpc": "2.0",
149            "method": "session/update",
150            "params": {
151                "sessionId": self.session_id,
152                "update": {
153                    "sessionUpdate": "request_permission",
154                    "permissionId": perm_id,
155                    "toolName": tool_name,
156                    "arguments": args,
157                }
158            }
159        });
160
161        let _ = self.notif_tx.send(notif);
162
163        // Wait for client response with 30-second timeout
164        tokio::time::timeout(Duration::from_secs(30), rx)
165            .await
166            .ok()
167            .and_then(|r| r.ok())
168            .map(translate)
169            .unwrap_or(PermissionDecision::Deny("permission timeout".to_string()))
170    }
171}
172
173// ---------------------------------------------------------------------------
174// Handle session/request_permission response from client
175// ---------------------------------------------------------------------------
176
177/// Handle a `session/request_permission` request from the client.
178///
179/// The client sends back the outcome (granted=true/false) for a specific
180/// permission_id. We look up the pending request in the store and resolve it.
181///
182/// Returns a JSON-RPC response indicating success or an error if the
183/// permission_id was not found.
184pub fn handle_request_permission(
185    id: &Value,
186    params: Option<&Value>,
187    store: &PendingPermissionStore,
188) -> Value {
189    let params = match params {
190        Some(p) => p,
191        None => {
192            return serde_json::json!({
193                "jsonrpc": "2.0",
194                "id": id,
195                "error": {
196                    "code": -32602,
197                    "message": "Missing params",
198                },
199            });
200        }
201    };
202
203    let perm_id = match params.get("permissionId").and_then(|v| v.as_str()) {
204        Some(s) => s,
205        None => {
206            return serde_json::json!({
207                "jsonrpc": "2.0",
208                "id": id,
209                "error": {
210                    "code": -32602,
211                    "message": "Missing required field 'permissionId'",
212                },
213            });
214        }
215    };
216
217    let outcome = match params.get("outcome").and_then(|v| v.as_str()) {
218        Some("allowed") => PermissionOutcome::Allowed,
219        Some("denied") | Some("rejected") => PermissionOutcome::Denied,
220        _ => {
221            return serde_json::json!({
222                "jsonrpc": "2.0",
223                "id": id,
224                "error": {
225                    "code": -32602,
226                    "message": "Invalid or missing 'outcome' field (expected 'allowed' or 'denied')",
227                },
228            });
229        }
230    };
231
232    match store.remove(perm_id) {
233        Some(tx) => {
234            let _ = tx.send(outcome);
235            serde_json::json!({
236                "jsonrpc": "2.0",
237                "id": id,
238                "result": {},
239            })
240        }
241        None => {
242            serde_json::json!({
243                "jsonrpc": "2.0",
244                "id": id,
245                "error": {
246                    "code": -32000,
247                    "message": format!("Permission request not found: {perm_id}"),
248                },
249            })
250        }
251    }
252}
253
254// ---------------------------------------------------------------------------
255// Tests
256// ---------------------------------------------------------------------------
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    // ── translate ─────────────────────────────────────────────────────────
263
264    #[test]
265    fn translate_allowed_to_allow() {
266        assert_eq!(
267            translate(PermissionOutcome::Allowed),
268            PermissionDecision::Allow
269        );
270    }
271
272    #[test]
273    fn translate_denied_to_deny() {
274        assert_eq!(
275            translate(PermissionOutcome::Denied),
276            PermissionDecision::Deny("permission denied".to_string())
277        );
278    }
279
280    #[test]
281    fn translate_timeout_to_deny() {
282        assert_eq!(
283            translate(PermissionOutcome::Timeout),
284            PermissionDecision::Deny("permission denied".to_string())
285        );
286    }
287
288    // ── PendingPermissionStore ────────────────────────────────────────────
289
290    #[test]
291    fn store_insert_and_remove() {
292        let store = PendingPermissionStore::new();
293        let (tx, _rx) = oneshot::channel();
294        store.insert("perm-1".into(), tx);
295        assert!(store.contains("perm-1"));
296        let removed = store.remove("perm-1");
297        assert!(removed.is_some());
298        assert!(!store.contains("perm-1"));
299    }
300
301    #[test]
302    fn store_remove_nonexistent_returns_none() {
303        let store = PendingPermissionStore::new();
304        assert!(store.remove("nonexistent").is_none());
305    }
306
307    #[test]
308    fn store_default_is_empty() {
309        let store = PendingPermissionStore::default();
310        assert!(!store.contains("anything"));
311    }
312
313    // ── handle_request_permission ─────────────────────────────────────────
314
315    #[test]
316    fn handle_permission_missing_params() {
317        let store = PendingPermissionStore::new();
318        let result = handle_request_permission(&Value::from(1), None, &store);
319        assert!(result["error"].is_object());
320        assert_eq!(result["error"]["code"], -32602);
321    }
322
323    #[test]
324    fn handle_permission_missing_permission_id() {
325        let store = PendingPermissionStore::new();
326        let params = serde_json::json!({"outcome": "allowed"});
327        let result = handle_request_permission(&Value::from(1), Some(&params), &store);
328        assert!(result["error"].is_object());
329        assert_eq!(result["error"]["code"], -32602);
330    }
331
332    #[test]
333    fn handle_permission_invalid_outcome() {
334        let store = PendingPermissionStore::new();
335        let params = serde_json::json!({
336            "permissionId": "p1",
337            "outcome": "maybe"
338        });
339        let result = handle_request_permission(&Value::from(1), Some(&params), &store);
340        assert!(result["error"].is_object());
341        assert_eq!(result["error"]["code"], -32602);
342    }
343
344    #[test]
345    fn handle_permission_nonexistent_id() {
346        let store = PendingPermissionStore::new();
347        let params = serde_json::json!({
348            "permissionId": "nonexistent",
349            "outcome": "allowed"
350        });
351        let result = handle_request_permission(&Value::from(1), Some(&params), &store);
352        assert!(result["error"].is_object());
353        assert_eq!(result["error"]["code"], -32000);
354    }
355
356    #[test]
357    fn handle_permission_allowed_resolves() {
358        let store = PendingPermissionStore::new();
359        let (tx, mut rx) = oneshot::channel();
360        store.insert("p1".into(), tx);
361
362        let params = serde_json::json!({
363            "permissionId": "p1",
364            "outcome": "allowed"
365        });
366        let result = handle_request_permission(&Value::from(1), Some(&params), &store);
367        assert!(result["result"].is_object());
368        // The oneshot should have been resolved
369        let outcome = rx.try_recv().unwrap();
370        assert_eq!(outcome, PermissionOutcome::Allowed);
371    }
372
373    #[test]
374    fn handle_permission_denied_resolves() {
375        let store = PendingPermissionStore::new();
376        let (tx, mut rx) = oneshot::channel();
377        store.insert("p2".into(), tx);
378
379        let params = serde_json::json!({
380            "permissionId": "p2",
381            "outcome": "denied"
382        });
383        let result = handle_request_permission(&Value::from(1), Some(&params), &store);
384        assert!(result["result"].is_object());
385        let outcome = rx.try_recv().unwrap();
386        assert_eq!(outcome, PermissionOutcome::Denied);
387    }
388}