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