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