Skip to main content

claude_code_acp/session/
permission_manager.rs

1//! Permission Manager - Async permission handling for MCP tools
2//!
3//! Based on Zed's permission system pattern:
4//! - Hook sends permission request and returns immediately
5//! - Background task handles the request
6//! - Uses unbounded channels (never block)
7//! - Uses one-shot channels for request/response
8
9use std::sync::Arc;
10
11use sacp::JrConnectionCx;
12use sacp::link::AgentToClient;
13use sacp::schema::{
14    PermissionOption, PermissionOptionId, PermissionOptionKind, RequestPermissionOutcome,
15    RequestPermissionRequest, SessionId, ToolCallUpdate, ToolCallUpdateFields,
16};
17
18use crate::types::AgentError;
19
20/// Permission decision result
21#[derive(Debug, Clone, PartialEq)]
22pub enum PermissionManagerDecision {
23    /// User allowed this tool call (one-time)
24    AllowOnce,
25    /// User allowed this tool call and wants to always allow this pattern
26    AllowAlways,
27    /// User rejected this tool call
28    Rejected,
29    /// Permission request was cancelled
30    Cancelled,
31}
32
33/// Pending permission request from hook
34pub struct PendingPermissionRequest {
35    pub tool_name: String,
36    pub tool_input: serde_json::Value,
37    pub tool_call_id: String,
38    pub session_id: String,
39    pub response_tx: tokio::sync::oneshot::Sender<PermissionManagerDecision>,
40}
41
42/// Permission Manager - handles permission requests in background tasks
43///
44/// # Architecture
45///
46/// Based on Zed's async permission pattern:
47/// 1. Hook sends request via unbounded channel (never blocks)
48/// 2. Background task processes request
49/// 3. One-shot channel returns result to caller
50///
51/// # Example
52///
53/// ```rust,ignore
54/// let manager = PermissionManager::new(connection_cx);
55/// let rx = manager.request_permission("Edit", input, "call_123", "session_456");
56/// let decision = rx.await?;
57/// ```
58pub struct PermissionManager {
59    /// Pending permission requests (unbounded, never blocks on send)
60    pending_requests: tokio::sync::mpsc::UnboundedSender<PendingPermissionRequest>,
61
62    /// Connection to client for sending permission requests
63    /// Note: Currently used by `send_permission_request_to_client` which is prepared
64    /// for future interactive permission dialog implementation.
65    #[allow(dead_code)]
66    connection_cx: Arc<JrConnectionCx<AgentToClient>>,
67}
68
69impl PermissionManager {
70    /// Create a new PermissionManager
71    pub fn new(connection_cx: Arc<JrConnectionCx<AgentToClient>>) -> Self {
72        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
73
74        // Spawn background task to handle permission requests
75        tokio::spawn(async move {
76            Self::handle_permission_requests(rx).await;
77        });
78
79        Self {
80            pending_requests: tx,
81            connection_cx,
82        }
83    }
84
85    /// Request permission (non-blocking)
86    ///
87    /// Returns a receiver that will resolve when user responds.
88    ///
89    /// This never blocks - it immediately sends to the background task
90    /// and returns a receiver for the result.
91    pub fn request_permission(
92        &self,
93        tool_name: String,
94        tool_input: serde_json::Value,
95        tool_call_id: String,
96        session_id: String,
97    ) -> tokio::sync::oneshot::Receiver<PermissionManagerDecision> {
98        let (tx, rx) = tokio::sync::oneshot::channel();
99
100        let request = PendingPermissionRequest {
101            tool_name,
102            tool_input,
103            tool_call_id,
104            session_id,
105            response_tx: tx,
106        };
107
108        // Send to background task (unbounded channel never blocks)
109        drop(self.pending_requests.send(request));
110
111        rx
112    }
113
114    /// Background task: handle permission requests
115    async fn handle_permission_requests(
116        mut receiver: tokio::sync::mpsc::UnboundedReceiver<PendingPermissionRequest>,
117    ) {
118        while let Some(request) = receiver.recv().await {
119            tracing::info!(
120                tool_name = %request.tool_name,
121                tool_call_id = %request.tool_call_id,
122                "Processing permission request in background task"
123            );
124
125            // TODO: Send permission request to client via SACP
126            // For now, we'll simulate the request and response
127
128            // This is where we would:
129            // 1. Build the RequestPermissionRequest
130            // 2. Send it via SACP to the client
131            // 3. Wait for the client's response
132            // 4. Send the result to response_tx
133
134            // For now, deny with a message explaining the limitation
135            let _ = request
136                .response_tx
137                .send(PermissionManagerDecision::Cancelled);
138
139            tracing::warn!(
140                tool_name = %request.tool_name,
141                "Permission request sent but interactive dialog not yet implemented"
142            );
143        }
144    }
145
146    /// Send permission request to client via SACP
147    ///
148    /// Note: This method is prepared for future interactive permission dialog implementation.
149    /// Currently, permission requests are handled by the SDK's `can_use_tool` callback.
150    #[allow(dead_code)]
151    async fn send_permission_request_to_client(
152        &self,
153        tool_name: &str,
154        tool_input: &serde_json::Value,
155        tool_call_id: &str,
156        session_id: &str,
157    ) -> Result<PermissionManagerDecision, AgentError> {
158        // Build the permission options
159        let options = vec![
160            PermissionOption::new(
161                PermissionOptionId::new("allow_always"),
162                "Always Allow",
163                PermissionOptionKind::AllowAlways,
164            ),
165            PermissionOption::new(
166                PermissionOptionId::new("allow_once"),
167                "Allow",
168                PermissionOptionKind::AllowOnce,
169            ),
170            PermissionOption::new(
171                PermissionOptionId::new("reject_once"),
172                "Reject",
173                PermissionOptionKind::RejectOnce,
174            ),
175        ];
176
177        // Build the tool call update with title
178        let tool_call_update = ToolCallUpdate::new(
179            tool_call_id.to_string(),
180            ToolCallUpdateFields::new()
181                .title(&format_tool_title(tool_name, tool_input))
182                .raw_input(tool_input.clone()),
183        );
184
185        // Build the request
186        let request =
187            RequestPermissionRequest::new(SessionId::new(session_id), tool_call_update, options);
188
189        // Send request and wait for response
190        let response = self
191            .connection_cx
192            .send_request(request)
193            .block_task()
194            .await
195            .map_err(|e| AgentError::Internal(format!("Permission request failed: {}", e)))?;
196
197        // Parse the response
198        Ok(parse_permission_response(response.outcome))
199    }
200}
201
202/// Parse a permission response outcome into our decision type
203///
204/// Note: Prepared for future interactive permission dialog implementation.
205#[allow(dead_code)]
206fn parse_permission_response(outcome: RequestPermissionOutcome) -> PermissionManagerDecision {
207    match outcome {
208        RequestPermissionOutcome::Selected(selected) => {
209            match selected.option_id.0.as_ref() {
210                "allow_always" => PermissionManagerDecision::AllowAlways,
211                "allow_once" => PermissionManagerDecision::AllowOnce,
212                "reject_once" => PermissionManagerDecision::Rejected,
213                _ => PermissionManagerDecision::Rejected, // Unknown option, treat as reject
214            }
215        }
216        RequestPermissionOutcome::Cancelled => PermissionManagerDecision::Cancelled,
217        // Handle any future variants (non_exhaustive enum)
218        _ => PermissionManagerDecision::Cancelled,
219    }
220}
221
222/// Format a title for the permission dialog based on tool name and input
223///
224/// Note: Prepared for future interactive permission dialog implementation.
225#[allow(dead_code)]
226fn format_tool_title(tool_name: &str, input: &serde_json::Value) -> String {
227    // Strip mcp__acp__ prefix for display
228    let display_name = tool_name.strip_prefix("mcp__acp__").unwrap_or(tool_name);
229
230    match display_name {
231        "Read" => {
232            let path = input
233                .get("file_path")
234                .and_then(|v| v.as_str())
235                .unwrap_or("file");
236            format!("Read {}", path)
237        }
238        "Write" => {
239            let path = input
240                .get("file_path")
241                .and_then(|v| v.as_str())
242                .unwrap_or("file");
243            format!("Write to {}", path)
244        }
245        "Edit" => {
246            let path = input
247                .get("file_path")
248                .and_then(|v| v.as_str())
249                .unwrap_or("file");
250            format!("Edit {}", path)
251        }
252        "Bash" => {
253            let cmd = input.get("command").and_then(|v| v.as_str()).unwrap_or("");
254            let desc = input.get("description").and_then(|v| v.as_str());
255            desc.map(String::from)
256                .unwrap_or_else(|| format!("Run: {}", truncate_string(cmd, 50)))
257        }
258        _ => display_name.to_string(),
259    }
260}
261
262/// Truncate a string to max length, adding "..." if truncated
263///
264/// Note: Prepared for future interactive permission dialog implementation.
265#[allow(dead_code)]
266fn truncate_string(s: &str, max_len: usize) -> String {
267    if s.len() <= max_len {
268        s.to_string()
269    } else {
270        format!("{}...", &s[..max_len.saturating_sub(3)])
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn test_format_tool_title_read() {
280        let title = format_tool_title("Read", &serde_json::json!({"file_path": "/tmp/test.txt"}));
281        assert_eq!(title, "Read /tmp/test.txt");
282    }
283
284    #[test]
285    fn test_format_tool_title_edit() {
286        let title = format_tool_title("Edit", &serde_json::json!({"file_path": "/tmp/file.txt"}));
287        assert_eq!(title, "Edit /tmp/file.txt");
288    }
289
290    #[test]
291    fn test_format_tool_title_mcp_prefix() {
292        let title = format_tool_title(
293            "mcp__acp__Read",
294            &serde_json::json!({"file_path": "/tmp/test.txt"}),
295        );
296        assert_eq!(title, "Read /tmp/test.txt");
297    }
298
299    #[test]
300    fn test_truncate_string() {
301        assert_eq!(truncate_string("hello", 10), "hello");
302        assert_eq!(truncate_string("hello world", 8), "hello...");
303        assert_eq!(truncate_string("hi", 2), "hi");
304    }
305
306    #[test]
307    fn test_parse_permission_response_selected() {
308        // This would require constructing RequestPermissionOutcome
309        // For now, just verify the function compiles
310        let _ = parse_permission_response;
311    }
312}