claude_code_acp/session/
permission_request.rs

1//! Interactive permission request handling
2//!
3//! Implements the ACP permission request/response protocol for asking users
4//! whether to allow tool execution.
5
6use sacp::JrConnectionCx;
7use sacp::link::AgentToClient;
8use sacp::schema::{
9    PermissionOption, PermissionOptionId, PermissionOptionKind, RequestPermissionOutcome,
10    RequestPermissionRequest, SessionId, ToolCallUpdate, ToolCallUpdateFields,
11};
12
13use crate::types::AgentError;
14
15/// Permission request outcome after user interaction
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum PermissionOutcome {
18    /// User allowed this tool call (one-time)
19    AllowOnce,
20    /// User allowed this tool call and wants to always allow this pattern
21    AllowAlways,
22    /// User rejected this tool call
23    Rejected,
24    /// Permission request was cancelled
25    Cancelled,
26}
27
28/// Builder for creating permission requests
29#[derive(Debug)]
30pub struct PermissionRequestBuilder {
31    session_id: String,
32    tool_call_id: String,
33    title: String,
34    tool_name: String,
35    tool_input: serde_json::Value,
36}
37
38impl PermissionRequestBuilder {
39    /// Create a new permission request builder
40    pub fn new(
41        session_id: impl Into<String>,
42        tool_call_id: impl Into<String>,
43        tool_name: impl Into<String>,
44        tool_input: serde_json::Value,
45    ) -> Self {
46        let tool_name_str: String = tool_name.into();
47        let title = format_tool_title(&tool_name_str, &tool_input);
48        Self {
49            session_id: session_id.into(),
50            tool_call_id: tool_call_id.into(),
51            title,
52            tool_name: tool_name_str,
53            tool_input,
54        }
55    }
56
57    /// Set a custom title for the permission dialog
58    pub fn title(mut self, title: impl Into<String>) -> Self {
59        self.title = title.into();
60        self
61    }
62
63    /// Build the request and send it to the client
64    ///
65    /// Returns the user's decision as a `PermissionOutcome`.
66    pub async fn request(
67        self,
68        connection_cx: &JrConnectionCx<AgentToClient>,
69    ) -> Result<PermissionOutcome, AgentError> {
70        // Build the options
71        let options = vec![
72            PermissionOption::new(
73                PermissionOptionId::new("allow_always"),
74                "Always Allow",
75                PermissionOptionKind::AllowAlways,
76            ),
77            PermissionOption::new(
78                PermissionOptionId::new("allow_once"),
79                "Allow",
80                PermissionOptionKind::AllowOnce,
81            ),
82            PermissionOption::new(
83                PermissionOptionId::new("reject_once"),
84                "Reject",
85                PermissionOptionKind::RejectOnce,
86            ),
87        ];
88
89        // Build the tool call update with title
90        let tool_call_update = ToolCallUpdate::new(
91            self.tool_call_id.clone(),
92            ToolCallUpdateFields::new()
93                .title(&self.title)
94                .raw_input(self.tool_input.clone()),
95        );
96
97        // Debug: Log the tool call update being sent
98        tracing::debug!(
99            tool_call_id = %self.tool_call_id,
100            title = %self.title,
101            tool_name = %self.tool_name,
102            "Building permission request with ToolCallUpdate"
103        );
104
105        // Build the request
106        let request = RequestPermissionRequest::new(
107            SessionId::new(self.session_id.clone()),
108            tool_call_update,
109            options,
110        );
111
112        // Debug: Log the serialized request for protocol debugging
113        if let Ok(json) = serde_json::to_string_pretty(&request) {
114            tracing::trace!(
115                session_id = %self.session_id,
116                request_json = %json,
117                "Sending session/request_permission"
118            );
119        }
120
121        // Send request and wait for response
122        tracing::info!(
123            tool_call_id = %self.tool_call_id,
124            session_id = %self.session_id,
125            "Sending permission request, waiting for user response..."
126        );
127
128        let response = connection_cx
129            .send_request(request)
130            .block_task()
131            .await
132            .map_err(|e| {
133                tracing::error!(
134                    tool_call_id = %self.tool_call_id,
135                    error = %e,
136                    "Permission request failed"
137                );
138                AgentError::Internal(format!("Permission request failed: {}", e))
139            })?;
140
141        tracing::info!(
142            tool_call_id = %self.tool_call_id,
143            "Received permission response"
144        );
145
146        // Parse the response
147        Ok(parse_permission_response(response.outcome))
148    }
149
150    /// Get the tool name
151    pub fn tool_name(&self) -> &str {
152        &self.tool_name
153    }
154}
155
156/// Parse a permission response outcome into our outcome type
157fn parse_permission_response(outcome: RequestPermissionOutcome) -> PermissionOutcome {
158    match outcome {
159        RequestPermissionOutcome::Selected(selected) => {
160            match selected.option_id.0.as_ref() {
161                "allow_always" => PermissionOutcome::AllowAlways,
162                "allow_once" => PermissionOutcome::AllowOnce,
163                "reject_once" => PermissionOutcome::Rejected,
164                _ => PermissionOutcome::Rejected, // Unknown option, treat as reject
165            }
166        }
167        RequestPermissionOutcome::Cancelled => PermissionOutcome::Cancelled,
168        // Handle any future variants (non_exhaustive enum)
169        _ => PermissionOutcome::Cancelled,
170    }
171}
172
173/// Format a title for the permission dialog based on tool name and input
174fn format_tool_title(tool_name: &str, input: &serde_json::Value) -> String {
175    // Strip mcp__acp__ prefix if present
176    let stripped_name = tool_name.strip_prefix("mcp__acp__").unwrap_or(tool_name);
177
178    match stripped_name {
179        "Read" => {
180            let path = input
181                .get("file_path")
182                .and_then(|v| v.as_str())
183                .unwrap_or("file");
184            format!("Read {}", path)
185        }
186        "Write" => {
187            let path = input
188                .get("file_path")
189                .and_then(|v| v.as_str())
190                .unwrap_or("file");
191            format!("Write to {}", path)
192        }
193        "Edit" => {
194            let path = input
195                .get("file_path")
196                .and_then(|v| v.as_str())
197                .unwrap_or("file");
198            format!("Edit {}", path)
199        }
200        "Bash" => {
201            let cmd = input.get("command").and_then(|v| v.as_str()).unwrap_or("");
202            let desc = input.get("description").and_then(|v| v.as_str());
203            desc.map(String::from)
204                .unwrap_or_else(|| format!("Run: {}", truncate_string(cmd, 50)))
205        }
206        "Grep" => {
207            let pattern = input.get("pattern").and_then(|v| v.as_str()).unwrap_or("");
208            format!("Search: {}", pattern)
209        }
210        "Glob" => {
211            let pattern = input.get("pattern").and_then(|v| v.as_str()).unwrap_or("");
212            format!("Find files: {}", pattern)
213        }
214        _ => stripped_name.to_string(),
215    }
216}
217
218/// Truncate a string to max length, adding "..." if truncated
219fn truncate_string(s: &str, max_len: usize) -> String {
220    if s.len() <= max_len {
221        s.to_string()
222    } else {
223        format!("{}...", &s[..max_len.saturating_sub(3)])
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use sacp::schema::SelectedPermissionOutcome;
231    use serde_json::json;
232
233    #[test]
234    fn test_format_tool_title_read() {
235        let title = format_tool_title("Read", &json!({"file_path": "/tmp/test.txt"}));
236        assert_eq!(title, "Read /tmp/test.txt");
237    }
238
239    #[test]
240    fn test_format_tool_title_bash() {
241        let title = format_tool_title("Bash", &json!({"command": "ls -la"}));
242        assert_eq!(title, "Run: ls -la");
243
244        let title = format_tool_title(
245            "Bash",
246            &json!({"command": "ls -la", "description": "List files"}),
247        );
248        assert_eq!(title, "List files");
249    }
250
251    #[test]
252    fn test_format_tool_title_long_command() {
253        let long_cmd = "echo 'this is a very long command that should be truncated'";
254        let title = format_tool_title("Bash", &json!({"command": long_cmd}));
255        assert!(title.len() <= 60); // "Run: " + 50 chars + "..."
256        assert!(title.ends_with("..."));
257    }
258
259    #[test]
260    fn test_truncate_string() {
261        assert_eq!(truncate_string("hello", 10), "hello");
262        assert_eq!(truncate_string("hello world", 8), "hello...");
263        assert_eq!(truncate_string("hi", 2), "hi");
264    }
265
266    #[test]
267    fn test_permission_outcome_selected() {
268        // Test Selected outcomes
269        let selected_always = RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(
270            PermissionOptionId::new("allow_always"),
271        ));
272        assert_eq!(
273            parse_permission_response(selected_always),
274            PermissionOutcome::AllowAlways
275        );
276
277        let selected_once = RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(
278            PermissionOptionId::new("allow_once"),
279        ));
280        assert_eq!(
281            parse_permission_response(selected_once),
282            PermissionOutcome::AllowOnce
283        );
284
285        let selected_reject = RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(
286            PermissionOptionId::new("reject_once"),
287        ));
288        assert_eq!(
289            parse_permission_response(selected_reject),
290            PermissionOutcome::Rejected
291        );
292    }
293
294    #[test]
295    fn test_permission_outcome_cancelled() {
296        let cancelled = RequestPermissionOutcome::Cancelled;
297        assert_eq!(
298            parse_permission_response(cancelled),
299            PermissionOutcome::Cancelled
300        );
301    }
302
303    #[test]
304    fn test_permission_outcome_unknown() {
305        // Unknown option should be treated as rejected
306        let unknown = RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(
307            PermissionOptionId::new("unknown_option"),
308        ));
309        assert_eq!(
310            parse_permission_response(unknown),
311            PermissionOutcome::Rejected
312        );
313    }
314}