Skip to main content

claude_codes/io/
control.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4// ============================================================================
5// Control Protocol Types (for bidirectional tool approval)
6// ============================================================================
7
8/// Control request from CLI (tool permission requests, hooks, etc.)
9///
10/// When using `--permission-prompt-tool stdio`, the CLI sends these requests
11/// asking for approval before executing tools. The SDK must respond with a
12/// [`ControlResponse`].
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct ControlRequest {
15    /// Unique identifier for this request (used to correlate responses)
16    pub request_id: String,
17    /// The request payload
18    pub request: ControlRequestPayload,
19}
20
21/// Control request payload variants
22#[derive(Debug, Clone, Serialize, Deserialize)]
23#[serde(tag = "subtype", rename_all = "snake_case")]
24pub enum ControlRequestPayload {
25    /// Tool permission request - Claude wants to use a tool
26    CanUseTool(ToolPermissionRequest),
27    /// Hook callback request
28    HookCallback(HookCallbackRequest),
29    /// MCP message request
30    McpMessage(McpMessageRequest),
31    /// Initialize request (sent by SDK to CLI)
32    Initialize(InitializeRequest),
33}
34
35/// A permission to grant for "remember this decision" functionality.
36///
37/// When responding to a tool permission request, you can include permissions
38/// that should be granted to avoid repeated prompts for similar actions.
39///
40/// # Example
41///
42/// ```
43/// use claude_codes::Permission;
44///
45/// // Grant permission for a specific bash command
46/// let perm = Permission::allow_tool("Bash", "npm test");
47///
48/// // Grant permission to set a mode for the session
49/// let mode_perm = Permission::set_mode("acceptEdits", "session");
50/// ```
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
52pub struct Permission {
53    /// The type of permission (e.g., "addRules", "setMode")
54    #[serde(rename = "type")]
55    pub permission_type: String,
56    /// Where to apply this permission (e.g., "session", "project")
57    pub destination: String,
58    /// The permission mode (for setMode type)
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub mode: Option<String>,
61    /// The behavior (for addRules type, e.g., "allow", "deny")
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub behavior: Option<String>,
64    /// The rules to add (for addRules type)
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub rules: Option<Vec<PermissionRule>>,
67}
68
69/// A rule within a permission grant.
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
71pub struct PermissionRule {
72    /// The name of the tool this rule applies to
73    #[serde(rename = "toolName")]
74    pub tool_name: String,
75    /// The rule content (glob pattern or command pattern)
76    #[serde(rename = "ruleContent")]
77    pub rule_content: String,
78}
79
80impl Permission {
81    /// Create a permission to allow a specific tool with a rule pattern.
82    ///
83    /// # Example
84    /// ```
85    /// use claude_codes::Permission;
86    ///
87    /// // Allow "npm test" bash command for this session
88    /// let perm = Permission::allow_tool("Bash", "npm test");
89    ///
90    /// // Allow reading from /tmp directory
91    /// let read_perm = Permission::allow_tool("Read", "/tmp/**");
92    /// ```
93    pub fn allow_tool(tool_name: impl Into<String>, rule_content: impl Into<String>) -> Self {
94        Permission {
95            permission_type: "addRules".to_string(),
96            destination: "session".to_string(),
97            mode: None,
98            behavior: Some("allow".to_string()),
99            rules: Some(vec![PermissionRule {
100                tool_name: tool_name.into(),
101                rule_content: rule_content.into(),
102            }]),
103        }
104    }
105
106    /// Create a permission to allow a tool with a specific destination.
107    ///
108    /// # Example
109    /// ```
110    /// use claude_codes::Permission;
111    ///
112    /// // Allow for the entire project, not just session
113    /// let perm = Permission::allow_tool_with_destination("Bash", "npm test", "project");
114    /// ```
115    pub fn allow_tool_with_destination(
116        tool_name: impl Into<String>,
117        rule_content: impl Into<String>,
118        destination: impl Into<String>,
119    ) -> Self {
120        Permission {
121            permission_type: "addRules".to_string(),
122            destination: destination.into(),
123            mode: None,
124            behavior: Some("allow".to_string()),
125            rules: Some(vec![PermissionRule {
126                tool_name: tool_name.into(),
127                rule_content: rule_content.into(),
128            }]),
129        }
130    }
131
132    /// Create a permission to set a mode (like acceptEdits or bypassPermissions).
133    ///
134    /// # Example
135    /// ```
136    /// use claude_codes::Permission;
137    ///
138    /// // Accept all edits for this session
139    /// let perm = Permission::set_mode("acceptEdits", "session");
140    /// ```
141    pub fn set_mode(mode: impl Into<String>, destination: impl Into<String>) -> Self {
142        Permission {
143            permission_type: "setMode".to_string(),
144            destination: destination.into(),
145            mode: Some(mode.into()),
146            behavior: None,
147            rules: None,
148        }
149    }
150
151    /// Create a permission from a PermissionSuggestion.
152    ///
153    /// This is useful when you want to grant a permission that Claude suggested.
154    ///
155    /// # Example
156    /// ```
157    /// use claude_codes::{Permission, PermissionSuggestion};
158    ///
159    /// // Convert a suggestion to a permission for the response
160    /// let suggestion = PermissionSuggestion {
161    ///     suggestion_type: "setMode".to_string(),
162    ///     destination: "session".to_string(),
163    ///     mode: Some("acceptEdits".to_string()),
164    ///     behavior: None,
165    ///     rules: None,
166    /// };
167    /// let perm = Permission::from_suggestion(&suggestion);
168    /// ```
169    pub fn from_suggestion(suggestion: &PermissionSuggestion) -> Self {
170        Permission {
171            permission_type: suggestion.suggestion_type.clone(),
172            destination: suggestion.destination.clone(),
173            mode: suggestion.mode.clone(),
174            behavior: suggestion.behavior.clone(),
175            rules: suggestion.rules.as_ref().map(|rules| {
176                rules
177                    .iter()
178                    .filter_map(|v| {
179                        Some(PermissionRule {
180                            tool_name: v.get("toolName")?.as_str()?.to_string(),
181                            rule_content: v.get("ruleContent")?.as_str()?.to_string(),
182                        })
183                    })
184                    .collect()
185            }),
186        }
187    }
188}
189
190/// A suggested permission for tool approval.
191///
192/// When Claude requests tool permission, it may include suggestions for
193/// permissions that could be granted to avoid repeated prompts for similar
194/// actions. The format varies based on the suggestion type:
195///
196/// - `setMode`: `{"type": "setMode", "mode": "acceptEdits", "destination": "session"}`
197/// - `addRules`: `{"type": "addRules", "rules": [...], "behavior": "allow", "destination": "session"}`
198///
199/// Use the helper methods to access common fields.
200#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
201pub struct PermissionSuggestion {
202    /// The type of suggestion (e.g., "setMode", "addRules")
203    #[serde(rename = "type")]
204    pub suggestion_type: String,
205    /// Where to apply this permission (e.g., "session", "project")
206    pub destination: String,
207    /// The permission mode (for setMode type)
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub mode: Option<String>,
210    /// The behavior (for addRules type, e.g., "allow")
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub behavior: Option<String>,
213    /// The rules to add (for addRules type)
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub rules: Option<Vec<Value>>,
216}
217
218/// Tool permission request details
219///
220/// This is sent when Claude wants to use a tool. The SDK should evaluate
221/// the request and respond with allow/deny using the ergonomic builder methods.
222///
223/// # Example
224///
225/// ```
226/// use claude_codes::{ToolPermissionRequest, ControlResponse};
227/// use serde_json::json;
228///
229/// fn handle_permission(req: &ToolPermissionRequest, request_id: &str) -> ControlResponse {
230///     // Block dangerous bash commands
231///     if req.tool_name == "Bash" {
232///         if let Some(cmd) = req.input.get("command").and_then(|v| v.as_str()) {
233///             if cmd.contains("rm -rf") {
234///                 return req.deny("Dangerous command blocked", request_id);
235///             }
236///         }
237///     }
238///
239///     // Allow everything else
240///     req.allow(request_id)
241/// }
242/// ```
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct ToolPermissionRequest {
245    /// Name of the tool Claude wants to use (e.g., "Bash", "Write", "Read")
246    pub tool_name: String,
247    /// Input parameters for the tool
248    pub input: Value,
249    /// Suggested permissions that could be granted to avoid repeated prompts
250    #[serde(default)]
251    pub permission_suggestions: Vec<PermissionSuggestion>,
252    /// Path that was blocked (if this is a retry after path-based denial)
253    #[serde(skip_serializing_if = "Option::is_none")]
254    pub blocked_path: Option<String>,
255    /// Reason why this tool use requires approval
256    #[serde(skip_serializing_if = "Option::is_none")]
257    pub decision_reason: Option<String>,
258    /// The tool use ID for this request
259    #[serde(skip_serializing_if = "Option::is_none")]
260    pub tool_use_id: Option<String>,
261}
262
263impl ToolPermissionRequest {
264    /// Allow the tool to execute with its original input.
265    ///
266    /// # Example
267    /// ```
268    /// # use claude_codes::ToolPermissionRequest;
269    /// # use serde_json::json;
270    /// let req = ToolPermissionRequest {
271    ///     tool_name: "Read".to_string(),
272    ///     input: json!({"file_path": "/tmp/test.txt"}),
273    ///     permission_suggestions: vec![],
274    ///     blocked_path: None,
275    ///     decision_reason: None,
276    ///     tool_use_id: None,
277    /// };
278    /// let response = req.allow("req-123");
279    /// ```
280    pub fn allow(&self, request_id: &str) -> ControlResponse {
281        ControlResponse::from_result(request_id, PermissionResult::allow(self.input.clone()))
282    }
283
284    /// Allow the tool to execute with modified input.
285    ///
286    /// Use this to sanitize or redirect tool inputs. For example, redirecting
287    /// file writes to a safe directory.
288    ///
289    /// # Example
290    /// ```
291    /// # use claude_codes::ToolPermissionRequest;
292    /// # use serde_json::json;
293    /// let req = ToolPermissionRequest {
294    ///     tool_name: "Write".to_string(),
295    ///     input: json!({"file_path": "/etc/passwd", "content": "test"}),
296    ///     permission_suggestions: vec![],
297    ///     blocked_path: None,
298    ///     decision_reason: None,
299    ///     tool_use_id: None,
300    /// };
301    /// // Redirect to safe location
302    /// let safe_input = json!({"file_path": "/tmp/safe/passwd", "content": "test"});
303    /// let response = req.allow_with(safe_input, "req-123");
304    /// ```
305    pub fn allow_with(&self, modified_input: Value, request_id: &str) -> ControlResponse {
306        ControlResponse::from_result(request_id, PermissionResult::allow(modified_input))
307    }
308
309    /// Allow with updated permissions list (raw JSON Values).
310    ///
311    /// Prefer using `allow_and_remember` for type safety.
312    pub fn allow_with_permissions(
313        &self,
314        modified_input: Value,
315        permissions: Vec<Value>,
316        request_id: &str,
317    ) -> ControlResponse {
318        ControlResponse::from_result(
319            request_id,
320            PermissionResult::allow_with_permissions(modified_input, permissions),
321        )
322    }
323
324    /// Allow the tool and grant permissions for "remember this decision".
325    ///
326    /// This is the ergonomic way to allow a tool while also granting permissions
327    /// so similar actions won't require approval in the future.
328    ///
329    /// # Example
330    /// ```
331    /// use claude_codes::{ToolPermissionRequest, Permission};
332    /// use serde_json::json;
333    ///
334    /// let req = ToolPermissionRequest {
335    ///     tool_name: "Bash".to_string(),
336    ///     input: json!({"command": "npm test"}),
337    ///     permission_suggestions: vec![],
338    ///     blocked_path: None,
339    ///     decision_reason: None,
340    ///     tool_use_id: None,
341    /// };
342    ///
343    /// // Allow and remember this decision for the session
344    /// let response = req.allow_and_remember(
345    ///     vec![Permission::allow_tool("Bash", "npm test")],
346    ///     "req-123",
347    /// );
348    /// ```
349    pub fn allow_and_remember(
350        &self,
351        permissions: Vec<Permission>,
352        request_id: &str,
353    ) -> ControlResponse {
354        ControlResponse::from_result(
355            request_id,
356            PermissionResult::allow_with_typed_permissions(self.input.clone(), permissions),
357        )
358    }
359
360    /// Allow the tool with modified input and grant permissions.
361    ///
362    /// Combines input modification with "remember this decision" functionality.
363    pub fn allow_with_and_remember(
364        &self,
365        modified_input: Value,
366        permissions: Vec<Permission>,
367        request_id: &str,
368    ) -> ControlResponse {
369        ControlResponse::from_result(
370            request_id,
371            PermissionResult::allow_with_typed_permissions(modified_input, permissions),
372        )
373    }
374
375    /// Allow the tool and remember using the first permission suggestion.
376    ///
377    /// This is a convenience method for the common case of accepting Claude's
378    /// first suggested permission (usually the most relevant one).
379    ///
380    /// Returns `None` if there are no permission suggestions.
381    ///
382    /// # Example
383    /// ```
384    /// use claude_codes::ToolPermissionRequest;
385    /// use serde_json::json;
386    ///
387    /// let req = ToolPermissionRequest {
388    ///     tool_name: "Bash".to_string(),
389    ///     input: json!({"command": "npm test"}),
390    ///     permission_suggestions: vec![],  // Would have suggestions in real use
391    ///     blocked_path: None,
392    ///     decision_reason: None,
393    ///     tool_use_id: None,
394    /// };
395    ///
396    /// // Try to allow with first suggestion, or just allow without remembering
397    /// let response = req.allow_and_remember_suggestion("req-123")
398    ///     .unwrap_or_else(|| req.allow("req-123"));
399    /// ```
400    pub fn allow_and_remember_suggestion(&self, request_id: &str) -> Option<ControlResponse> {
401        self.permission_suggestions.first().map(|suggestion| {
402            let perm = Permission::from_suggestion(suggestion);
403            self.allow_and_remember(vec![perm], request_id)
404        })
405    }
406
407    /// Deny the tool execution.
408    ///
409    /// The message will be shown to Claude, who may try a different approach.
410    ///
411    /// # Example
412    /// ```
413    /// # use claude_codes::ToolPermissionRequest;
414    /// # use serde_json::json;
415    /// let req = ToolPermissionRequest {
416    ///     tool_name: "Bash".to_string(),
417    ///     input: json!({"command": "sudo rm -rf /"}),
418    ///     permission_suggestions: vec![],
419    ///     blocked_path: None,
420    ///     decision_reason: None,
421    ///     tool_use_id: None,
422    /// };
423    /// let response = req.deny("Dangerous command blocked by policy", "req-123");
424    /// ```
425    pub fn deny(&self, message: impl Into<String>, request_id: &str) -> ControlResponse {
426        ControlResponse::from_result(request_id, PermissionResult::deny(message))
427    }
428
429    /// Deny the tool execution and stop the entire session.
430    ///
431    /// Use this for severe policy violations that should halt all processing.
432    pub fn deny_and_stop(&self, message: impl Into<String>, request_id: &str) -> ControlResponse {
433        ControlResponse::from_result(request_id, PermissionResult::deny_and_interrupt(message))
434    }
435}
436
437/// Result of a permission decision
438///
439/// This type represents the decision made by the permission callback.
440/// It can be serialized directly into the control response format.
441#[derive(Debug, Clone, Serialize, Deserialize)]
442#[serde(tag = "behavior", rename_all = "snake_case")]
443pub enum PermissionResult {
444    /// Allow the tool to execute
445    Allow {
446        /// The (possibly modified) input to pass to the tool
447        #[serde(rename = "updatedInput")]
448        updated_input: Value,
449        /// Optional updated permissions list
450        #[serde(rename = "updatedPermissions", skip_serializing_if = "Option::is_none")]
451        updated_permissions: Option<Vec<Value>>,
452    },
453    /// Deny the tool execution
454    Deny {
455        /// Message explaining why the tool was denied
456        message: String,
457        /// If true, stop the entire session
458        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
459        interrupt: bool,
460    },
461}
462
463impl PermissionResult {
464    /// Create an allow result with the given input
465    pub fn allow(input: Value) -> Self {
466        PermissionResult::Allow {
467            updated_input: input,
468            updated_permissions: None,
469        }
470    }
471
472    /// Create an allow result with raw permissions (as JSON Values).
473    ///
474    /// Prefer using `allow_with_typed_permissions` for type safety.
475    pub fn allow_with_permissions(input: Value, permissions: Vec<Value>) -> Self {
476        PermissionResult::Allow {
477            updated_input: input,
478            updated_permissions: Some(permissions),
479        }
480    }
481
482    /// Create an allow result with typed permissions.
483    ///
484    /// This is the preferred way to grant permissions for "remember this decision"
485    /// functionality.
486    ///
487    /// # Example
488    /// ```
489    /// use claude_codes::{Permission, PermissionResult};
490    /// use serde_json::json;
491    ///
492    /// let result = PermissionResult::allow_with_typed_permissions(
493    ///     json!({"command": "npm test"}),
494    ///     vec![Permission::allow_tool("Bash", "npm test")],
495    /// );
496    /// ```
497    pub fn allow_with_typed_permissions(input: Value, permissions: Vec<Permission>) -> Self {
498        let permission_values: Vec<Value> = permissions
499            .into_iter()
500            .filter_map(|p| serde_json::to_value(p).ok())
501            .collect();
502        PermissionResult::Allow {
503            updated_input: input,
504            updated_permissions: Some(permission_values),
505        }
506    }
507
508    /// Create a deny result
509    pub fn deny(message: impl Into<String>) -> Self {
510        PermissionResult::Deny {
511            message: message.into(),
512            interrupt: false,
513        }
514    }
515
516    /// Create a deny result that also interrupts the session
517    pub fn deny_and_interrupt(message: impl Into<String>) -> Self {
518        PermissionResult::Deny {
519            message: message.into(),
520            interrupt: true,
521        }
522    }
523}
524
525/// Hook callback request
526#[derive(Debug, Clone, Serialize, Deserialize)]
527pub struct HookCallbackRequest {
528    pub callback_id: String,
529    pub input: Value,
530    #[serde(skip_serializing_if = "Option::is_none")]
531    pub tool_use_id: Option<String>,
532}
533
534/// MCP message request
535#[derive(Debug, Clone, Serialize, Deserialize)]
536pub struct McpMessageRequest {
537    pub server_name: String,
538    pub message: Value,
539}
540
541/// Initialize request (SDK -> CLI)
542#[derive(Debug, Clone, Serialize, Deserialize)]
543pub struct InitializeRequest {
544    #[serde(skip_serializing_if = "Option::is_none")]
545    pub hooks: Option<Value>,
546}
547
548/// Control response to CLI
549///
550/// Built using the ergonomic methods on [`ToolPermissionRequest`] or
551/// constructed directly for other control request types.
552#[derive(Debug, Clone, Serialize, Deserialize)]
553pub struct ControlResponse {
554    /// The request ID this response corresponds to
555    pub response: ControlResponsePayload,
556}
557
558impl ControlResponse {
559    /// Create a success response from a PermissionResult
560    ///
561    /// This is the preferred way to construct permission responses.
562    pub fn from_result(request_id: &str, result: PermissionResult) -> Self {
563        // Serialize the PermissionResult to Value for the response
564        let response_value = serde_json::to_value(&result)
565            .expect("PermissionResult serialization should never fail");
566        ControlResponse {
567            response: ControlResponsePayload::Success {
568                request_id: request_id.to_string(),
569                response: Some(response_value),
570            },
571        }
572    }
573
574    /// Create a success response with the given payload (raw Value)
575    pub fn success(request_id: &str, response_data: Value) -> Self {
576        ControlResponse {
577            response: ControlResponsePayload::Success {
578                request_id: request_id.to_string(),
579                response: Some(response_data),
580            },
581        }
582    }
583
584    /// Create an empty success response (for acks)
585    pub fn success_empty(request_id: &str) -> Self {
586        ControlResponse {
587            response: ControlResponsePayload::Success {
588                request_id: request_id.to_string(),
589                response: None,
590            },
591        }
592    }
593
594    /// Create an error response
595    pub fn error(request_id: &str, error_message: impl Into<String>) -> Self {
596        ControlResponse {
597            response: ControlResponsePayload::Error {
598                request_id: request_id.to_string(),
599                error: error_message.into(),
600            },
601        }
602    }
603}
604
605/// Control response payload
606#[derive(Debug, Clone, Serialize, Deserialize)]
607#[serde(tag = "subtype", rename_all = "snake_case")]
608pub enum ControlResponsePayload {
609    Success {
610        request_id: String,
611        #[serde(skip_serializing_if = "Option::is_none")]
612        response: Option<Value>,
613    },
614    Error {
615        request_id: String,
616        error: String,
617    },
618}
619
620/// Wrapper for outgoing control responses (includes type tag)
621#[derive(Debug, Clone, Serialize, Deserialize)]
622pub struct ControlResponseMessage {
623    #[serde(rename = "type")]
624    pub message_type: String,
625    pub response: ControlResponsePayload,
626}
627
628impl From<ControlResponse> for ControlResponseMessage {
629    fn from(resp: ControlResponse) -> Self {
630        ControlResponseMessage {
631            message_type: "control_response".to_string(),
632            response: resp.response,
633        }
634    }
635}
636
637/// Wrapper for outgoing control requests (includes type tag)
638#[derive(Debug, Clone, Serialize, Deserialize)]
639pub struct ControlRequestMessage {
640    #[serde(rename = "type")]
641    pub message_type: String,
642    pub request_id: String,
643    pub request: ControlRequestPayload,
644}
645
646impl ControlRequestMessage {
647    /// Create an initialization request to send to CLI
648    pub fn initialize(request_id: impl Into<String>) -> Self {
649        ControlRequestMessage {
650            message_type: "control_request".to_string(),
651            request_id: request_id.into(),
652            request: ControlRequestPayload::Initialize(InitializeRequest { hooks: None }),
653        }
654    }
655
656    /// Create an initialization request with hooks configuration
657    pub fn initialize_with_hooks(request_id: impl Into<String>, hooks: Value) -> Self {
658        ControlRequestMessage {
659            message_type: "control_request".to_string(),
660            request_id: request_id.into(),
661            request: ControlRequestPayload::Initialize(InitializeRequest { hooks: Some(hooks) }),
662        }
663    }
664}
665
666#[cfg(test)]
667mod tests {
668    use super::*;
669    use crate::io::ClaudeOutput;
670
671    #[test]
672    fn test_deserialize_control_request_can_use_tool() {
673        let json = r#"{
674            "type": "control_request",
675            "request_id": "perm-abc123",
676            "request": {
677                "subtype": "can_use_tool",
678                "tool_name": "Write",
679                "input": {
680                    "file_path": "/home/user/hello.py",
681                    "content": "print('hello')"
682                },
683                "permission_suggestions": [],
684                "blocked_path": null
685            }
686        }"#;
687
688        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
689        assert!(output.is_control_request());
690
691        if let ClaudeOutput::ControlRequest(req) = output {
692            assert_eq!(req.request_id, "perm-abc123");
693            if let ControlRequestPayload::CanUseTool(perm_req) = req.request {
694                assert_eq!(perm_req.tool_name, "Write");
695                assert_eq!(
696                    perm_req.input.get("file_path").unwrap().as_str().unwrap(),
697                    "/home/user/hello.py"
698                );
699            } else {
700                panic!("Expected CanUseTool payload");
701            }
702        } else {
703            panic!("Expected ControlRequest");
704        }
705    }
706
707    #[test]
708    fn test_deserialize_control_request_edit_tool_real() {
709        // Real production message from Claude CLI
710        let json = r#"{"type":"control_request","request_id":"f3cf357c-17d6-4eca-b498-dd17c7ac43dd","request":{"subtype":"can_use_tool","tool_name":"Edit","input":{"file_path":"/home/meawoppl/repos/cc-proxy/proxy/src/ui.rs","old_string":"/// Print hint to re-authenticate\npub fn print_reauth_hint() {\n    println!(\n        \"  {} Run: {} to re-authenticate\",\n        \"→\".bright_blue(),\n        \"claude-portal logout && claude-portal login\".bright_cyan()\n    );\n}","new_string":"/// Print hint to re-authenticate\npub fn print_reauth_hint() {\n    println!(\n        \"  {} Run: {} to re-authenticate\",\n        \"→\".bright_blue(),\n        \"claude-portal --reauth\".bright_cyan()\n    );\n}","replace_all":false},"permission_suggestions":[{"type":"setMode","mode":"acceptEdits","destination":"session"}],"tool_use_id":"toolu_015BDGtNiqNrRSJSDrWXNckW"}}"#;
711
712        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
713        assert!(output.is_control_request());
714        assert_eq!(output.message_type(), "control_request");
715
716        if let ClaudeOutput::ControlRequest(req) = output {
717            assert_eq!(req.request_id, "f3cf357c-17d6-4eca-b498-dd17c7ac43dd");
718            if let ControlRequestPayload::CanUseTool(perm_req) = req.request {
719                assert_eq!(perm_req.tool_name, "Edit");
720                assert_eq!(
721                    perm_req.input.get("file_path").unwrap().as_str().unwrap(),
722                    "/home/meawoppl/repos/cc-proxy/proxy/src/ui.rs"
723                );
724                assert!(perm_req.input.get("old_string").is_some());
725                assert!(perm_req.input.get("new_string").is_some());
726                assert!(!perm_req
727                    .input
728                    .get("replace_all")
729                    .unwrap()
730                    .as_bool()
731                    .unwrap());
732            } else {
733                panic!("Expected CanUseTool payload");
734            }
735        } else {
736            panic!("Expected ControlRequest");
737        }
738    }
739
740    #[test]
741    fn test_tool_permission_request_allow() {
742        let req = ToolPermissionRequest {
743            tool_name: "Read".to_string(),
744            input: serde_json::json!({"file_path": "/tmp/test.txt"}),
745            permission_suggestions: vec![],
746            blocked_path: None,
747            decision_reason: None,
748            tool_use_id: None,
749        };
750
751        let response = req.allow("req-123");
752        let message: ControlResponseMessage = response.into();
753
754        let json = serde_json::to_string(&message).unwrap();
755        assert!(json.contains("\"type\":\"control_response\""));
756        assert!(json.contains("\"subtype\":\"success\""));
757        assert!(json.contains("\"request_id\":\"req-123\""));
758        assert!(json.contains("\"behavior\":\"allow\""));
759        assert!(json.contains("\"updatedInput\""));
760    }
761
762    #[test]
763    fn test_tool_permission_request_allow_with_modified_input() {
764        let req = ToolPermissionRequest {
765            tool_name: "Write".to_string(),
766            input: serde_json::json!({"file_path": "/etc/passwd", "content": "test"}),
767            permission_suggestions: vec![],
768            blocked_path: None,
769            decision_reason: None,
770            tool_use_id: None,
771        };
772
773        let modified_input = serde_json::json!({
774            "file_path": "/tmp/safe/passwd",
775            "content": "test"
776        });
777        let response = req.allow_with(modified_input, "req-456");
778        let message: ControlResponseMessage = response.into();
779
780        let json = serde_json::to_string(&message).unwrap();
781        assert!(json.contains("/tmp/safe/passwd"));
782        assert!(!json.contains("/etc/passwd"));
783    }
784
785    #[test]
786    fn test_tool_permission_request_deny() {
787        let req = ToolPermissionRequest {
788            tool_name: "Bash".to_string(),
789            input: serde_json::json!({"command": "sudo rm -rf /"}),
790            permission_suggestions: vec![],
791            blocked_path: None,
792            decision_reason: None,
793            tool_use_id: None,
794        };
795
796        let response = req.deny("Dangerous command blocked", "req-789");
797        let message: ControlResponseMessage = response.into();
798
799        let json = serde_json::to_string(&message).unwrap();
800        assert!(json.contains("\"behavior\":\"deny\""));
801        assert!(json.contains("Dangerous command blocked"));
802        assert!(!json.contains("\"interrupt\":true"));
803    }
804
805    #[test]
806    fn test_tool_permission_request_deny_and_stop() {
807        let req = ToolPermissionRequest {
808            tool_name: "Bash".to_string(),
809            input: serde_json::json!({"command": "rm -rf /"}),
810            permission_suggestions: vec![],
811            blocked_path: None,
812            decision_reason: None,
813            tool_use_id: None,
814        };
815
816        let response = req.deny_and_stop("Security violation", "req-000");
817        let message: ControlResponseMessage = response.into();
818
819        let json = serde_json::to_string(&message).unwrap();
820        assert!(json.contains("\"behavior\":\"deny\""));
821        assert!(json.contains("\"interrupt\":true"));
822    }
823
824    #[test]
825    fn test_permission_result_serialization() {
826        // Test allow
827        let allow = PermissionResult::allow(serde_json::json!({"test": "value"}));
828        let json = serde_json::to_string(&allow).unwrap();
829        assert!(json.contains("\"behavior\":\"allow\""));
830        assert!(json.contains("\"updatedInput\""));
831
832        // Test deny
833        let deny = PermissionResult::deny("Not allowed");
834        let json = serde_json::to_string(&deny).unwrap();
835        assert!(json.contains("\"behavior\":\"deny\""));
836        assert!(json.contains("\"message\":\"Not allowed\""));
837        assert!(!json.contains("\"interrupt\""));
838
839        // Test deny with interrupt
840        let deny_stop = PermissionResult::deny_and_interrupt("Stop!");
841        let json = serde_json::to_string(&deny_stop).unwrap();
842        assert!(json.contains("\"interrupt\":true"));
843    }
844
845    #[test]
846    fn test_control_request_message_initialize() {
847        let init = ControlRequestMessage::initialize("init-1");
848
849        let json = serde_json::to_string(&init).unwrap();
850        assert!(json.contains("\"type\":\"control_request\""));
851        assert!(json.contains("\"request_id\":\"init-1\""));
852        assert!(json.contains("\"subtype\":\"initialize\""));
853    }
854
855    #[test]
856    fn test_control_response_error() {
857        let response = ControlResponse::error("req-err", "Something went wrong");
858        let message: ControlResponseMessage = response.into();
859
860        let json = serde_json::to_string(&message).unwrap();
861        assert!(json.contains("\"subtype\":\"error\""));
862        assert!(json.contains("\"error\":\"Something went wrong\""));
863    }
864
865    #[test]
866    fn test_roundtrip_control_request() {
867        let original_json = r#"{
868            "type": "control_request",
869            "request_id": "test-123",
870            "request": {
871                "subtype": "can_use_tool",
872                "tool_name": "Bash",
873                "input": {"command": "ls -la"},
874                "permission_suggestions": []
875            }
876        }"#;
877
878        let output: ClaudeOutput = serde_json::from_str(original_json).unwrap();
879
880        let reserialized = serde_json::to_string(&output).unwrap();
881        assert!(reserialized.contains("control_request"));
882        assert!(reserialized.contains("test-123"));
883        assert!(reserialized.contains("Bash"));
884    }
885
886    #[test]
887    fn test_permission_suggestions_parsing() {
888        let json = r#"{
889            "type": "control_request",
890            "request_id": "perm-456",
891            "request": {
892                "subtype": "can_use_tool",
893                "tool_name": "Bash",
894                "input": {"command": "npm test"},
895                "permission_suggestions": [
896                    {"type": "setMode", "mode": "acceptEdits", "destination": "session"},
897                    {"type": "setMode", "mode": "bypassPermissions", "destination": "project"}
898                ]
899            }
900        }"#;
901
902        let output: ClaudeOutput = serde_json::from_str(json).unwrap();
903        if let ClaudeOutput::ControlRequest(req) = output {
904            if let ControlRequestPayload::CanUseTool(perm_req) = req.request {
905                assert_eq!(perm_req.permission_suggestions.len(), 2);
906                assert_eq!(
907                    perm_req.permission_suggestions[0].suggestion_type,
908                    "setMode"
909                );
910                assert_eq!(
911                    perm_req.permission_suggestions[0].mode,
912                    Some("acceptEdits".to_string())
913                );
914                assert_eq!(perm_req.permission_suggestions[0].destination, "session");
915                assert_eq!(
916                    perm_req.permission_suggestions[1].suggestion_type,
917                    "setMode"
918                );
919                assert_eq!(
920                    perm_req.permission_suggestions[1].mode,
921                    Some("bypassPermissions".to_string())
922                );
923                assert_eq!(perm_req.permission_suggestions[1].destination, "project");
924            } else {
925                panic!("Expected CanUseTool payload");
926            }
927        } else {
928            panic!("Expected ControlRequest");
929        }
930    }
931
932    #[test]
933    fn test_permission_suggestion_set_mode_roundtrip() {
934        let suggestion = PermissionSuggestion {
935            suggestion_type: "setMode".to_string(),
936            destination: "session".to_string(),
937            mode: Some("acceptEdits".to_string()),
938            behavior: None,
939            rules: None,
940        };
941
942        let json = serde_json::to_string(&suggestion).unwrap();
943        assert!(json.contains("\"type\":\"setMode\""));
944        assert!(json.contains("\"mode\":\"acceptEdits\""));
945        assert!(json.contains("\"destination\":\"session\""));
946        assert!(!json.contains("\"behavior\""));
947        assert!(!json.contains("\"rules\""));
948
949        let parsed: PermissionSuggestion = serde_json::from_str(&json).unwrap();
950        assert_eq!(parsed, suggestion);
951    }
952
953    #[test]
954    fn test_permission_suggestion_add_rules_roundtrip() {
955        let suggestion = PermissionSuggestion {
956            suggestion_type: "addRules".to_string(),
957            destination: "session".to_string(),
958            mode: None,
959            behavior: Some("allow".to_string()),
960            rules: Some(vec![serde_json::json!({
961                "toolName": "Read",
962                "ruleContent": "//tmp/**"
963            })]),
964        };
965
966        let json = serde_json::to_string(&suggestion).unwrap();
967        assert!(json.contains("\"type\":\"addRules\""));
968        assert!(json.contains("\"behavior\":\"allow\""));
969        assert!(json.contains("\"destination\":\"session\""));
970        assert!(json.contains("\"rules\""));
971        assert!(json.contains("\"toolName\":\"Read\""));
972        assert!(!json.contains("\"mode\""));
973
974        let parsed: PermissionSuggestion = serde_json::from_str(&json).unwrap();
975        assert_eq!(parsed, suggestion);
976    }
977
978    #[test]
979    fn test_permission_suggestion_add_rules_from_real_json() {
980        let json = r#"{"type":"addRules","rules":[{"toolName":"Read","ruleContent":"//tmp/**"}],"behavior":"allow","destination":"session"}"#;
981
982        let parsed: PermissionSuggestion = serde_json::from_str(json).unwrap();
983        assert_eq!(parsed.suggestion_type, "addRules");
984        assert_eq!(parsed.destination, "session");
985        assert_eq!(parsed.behavior, Some("allow".to_string()));
986        assert!(parsed.rules.is_some());
987        assert!(parsed.mode.is_none());
988    }
989
990    #[test]
991    fn test_permission_allow_tool() {
992        let perm = Permission::allow_tool("Bash", "npm test");
993
994        assert_eq!(perm.permission_type, "addRules");
995        assert_eq!(perm.destination, "session");
996        assert_eq!(perm.behavior, Some("allow".to_string()));
997        assert!(perm.mode.is_none());
998
999        let rules = perm.rules.unwrap();
1000        assert_eq!(rules.len(), 1);
1001        assert_eq!(rules[0].tool_name, "Bash");
1002        assert_eq!(rules[0].rule_content, "npm test");
1003    }
1004
1005    #[test]
1006    fn test_permission_allow_tool_with_destination() {
1007        let perm = Permission::allow_tool_with_destination("Read", "/tmp/**", "project");
1008
1009        assert_eq!(perm.permission_type, "addRules");
1010        assert_eq!(perm.destination, "project");
1011        assert_eq!(perm.behavior, Some("allow".to_string()));
1012
1013        let rules = perm.rules.unwrap();
1014        assert_eq!(rules[0].tool_name, "Read");
1015        assert_eq!(rules[0].rule_content, "/tmp/**");
1016    }
1017
1018    #[test]
1019    fn test_permission_set_mode() {
1020        let perm = Permission::set_mode("acceptEdits", "session");
1021
1022        assert_eq!(perm.permission_type, "setMode");
1023        assert_eq!(perm.destination, "session");
1024        assert_eq!(perm.mode, Some("acceptEdits".to_string()));
1025        assert!(perm.behavior.is_none());
1026        assert!(perm.rules.is_none());
1027    }
1028
1029    #[test]
1030    fn test_permission_serialization() {
1031        let perm = Permission::allow_tool("Bash", "npm test");
1032        let json = serde_json::to_string(&perm).unwrap();
1033
1034        assert!(json.contains("\"type\":\"addRules\""));
1035        assert!(json.contains("\"destination\":\"session\""));
1036        assert!(json.contains("\"behavior\":\"allow\""));
1037        assert!(json.contains("\"toolName\":\"Bash\""));
1038        assert!(json.contains("\"ruleContent\":\"npm test\""));
1039    }
1040
1041    #[test]
1042    fn test_permission_from_suggestion_set_mode() {
1043        let suggestion = PermissionSuggestion {
1044            suggestion_type: "setMode".to_string(),
1045            destination: "session".to_string(),
1046            mode: Some("acceptEdits".to_string()),
1047            behavior: None,
1048            rules: None,
1049        };
1050
1051        let perm = Permission::from_suggestion(&suggestion);
1052
1053        assert_eq!(perm.permission_type, "setMode");
1054        assert_eq!(perm.destination, "session");
1055        assert_eq!(perm.mode, Some("acceptEdits".to_string()));
1056    }
1057
1058    #[test]
1059    fn test_permission_from_suggestion_add_rules() {
1060        let suggestion = PermissionSuggestion {
1061            suggestion_type: "addRules".to_string(),
1062            destination: "session".to_string(),
1063            mode: None,
1064            behavior: Some("allow".to_string()),
1065            rules: Some(vec![serde_json::json!({
1066                "toolName": "Read",
1067                "ruleContent": "/tmp/**"
1068            })]),
1069        };
1070
1071        let perm = Permission::from_suggestion(&suggestion);
1072
1073        assert_eq!(perm.permission_type, "addRules");
1074        assert_eq!(perm.behavior, Some("allow".to_string()));
1075
1076        let rules = perm.rules.unwrap();
1077        assert_eq!(rules.len(), 1);
1078        assert_eq!(rules[0].tool_name, "Read");
1079        assert_eq!(rules[0].rule_content, "/tmp/**");
1080    }
1081
1082    #[test]
1083    fn test_permission_result_allow_with_typed_permissions() {
1084        let result = PermissionResult::allow_with_typed_permissions(
1085            serde_json::json!({"command": "npm test"}),
1086            vec![Permission::allow_tool("Bash", "npm test")],
1087        );
1088
1089        let json = serde_json::to_string(&result).unwrap();
1090        assert!(json.contains("\"behavior\":\"allow\""));
1091        assert!(json.contains("\"updatedPermissions\""));
1092        assert!(json.contains("\"toolName\":\"Bash\""));
1093    }
1094
1095    #[test]
1096    fn test_tool_permission_request_allow_and_remember() {
1097        let req = ToolPermissionRequest {
1098            tool_name: "Bash".to_string(),
1099            input: serde_json::json!({"command": "npm test"}),
1100            permission_suggestions: vec![],
1101            blocked_path: None,
1102            decision_reason: None,
1103            tool_use_id: None,
1104        };
1105
1106        let response =
1107            req.allow_and_remember(vec![Permission::allow_tool("Bash", "npm test")], "req-123");
1108        let message: ControlResponseMessage = response.into();
1109        let json = serde_json::to_string(&message).unwrap();
1110
1111        assert!(json.contains("\"type\":\"control_response\""));
1112        assert!(json.contains("\"behavior\":\"allow\""));
1113        assert!(json.contains("\"updatedPermissions\""));
1114        assert!(json.contains("\"toolName\":\"Bash\""));
1115    }
1116
1117    #[test]
1118    fn test_tool_permission_request_allow_and_remember_suggestion() {
1119        let req = ToolPermissionRequest {
1120            tool_name: "Bash".to_string(),
1121            input: serde_json::json!({"command": "npm test"}),
1122            permission_suggestions: vec![PermissionSuggestion {
1123                suggestion_type: "setMode".to_string(),
1124                destination: "session".to_string(),
1125                mode: Some("acceptEdits".to_string()),
1126                behavior: None,
1127                rules: None,
1128            }],
1129            blocked_path: None,
1130            decision_reason: None,
1131            tool_use_id: None,
1132        };
1133
1134        let response = req.allow_and_remember_suggestion("req-123");
1135        assert!(response.is_some());
1136
1137        let message: ControlResponseMessage = response.unwrap().into();
1138        let json = serde_json::to_string(&message).unwrap();
1139
1140        assert!(json.contains("\"type\":\"setMode\""));
1141        assert!(json.contains("\"mode\":\"acceptEdits\""));
1142    }
1143
1144    #[test]
1145    fn test_tool_permission_request_allow_and_remember_suggestion_none() {
1146        let req = ToolPermissionRequest {
1147            tool_name: "Bash".to_string(),
1148            input: serde_json::json!({"command": "npm test"}),
1149            permission_suggestions: vec![], // No suggestions
1150            blocked_path: None,
1151            decision_reason: None,
1152            tool_use_id: None,
1153        };
1154
1155        let response = req.allow_and_remember_suggestion("req-123");
1156        assert!(response.is_none());
1157    }
1158}