Skip to main content

rch_common/
protocol.rs

1//! Claude Code hook protocol definitions.
2//!
3//! Defines the JSON structures for PreToolUse hook input/output.
4
5use serde::{Deserialize, Serialize};
6
7/// Input received from Claude Code PreToolUse hook.
8#[derive(Debug, Clone, Deserialize)]
9pub struct HookInput {
10    /// The tool being invoked (e.g., "Bash", "Read", "Write").
11    pub tool_name: String,
12    /// Tool-specific input.
13    pub tool_input: ToolInput,
14    /// Optional session ID.
15    #[serde(default)]
16    pub session_id: Option<String>,
17}
18
19/// Tool-specific input for Bash commands.
20#[derive(Debug, Clone, Deserialize)]
21pub struct ToolInput {
22    /// The command to execute.
23    pub command: String,
24    /// Optional description of what the command does.
25    #[serde(default)]
26    pub description: Option<String>,
27}
28
29/// Output sent back to Claude Code from PreToolUse hook.
30#[derive(Debug, Clone, Serialize)]
31#[serde(untagged)]
32pub enum HookOutput {
33    /// Allow the command to proceed (empty object or no output).
34    Allow(AllowOutput),
35    /// Allow with a modified/replaced command (for transparent interception).
36    /// Used when RCH has already executed the command remotely and wants to
37    /// replace the original command with a no-op for transparency.
38    AllowWithModifiedCommand(AllowWithModifiedCommandOutput),
39    /// Deny the command with a reason.
40    Deny(DenyOutput),
41}
42
43/// Empty output to allow command execution.
44#[derive(Debug, Clone, Default, Serialize)]
45pub struct AllowOutput {}
46
47/// Output to allow with a modified command (for transparent interception).
48/// This tells Claude Code "allow this tool, but replace the command with this one".
49/// Used when RCH has already executed remotely and wants to substitute a no-op.
50#[derive(Debug, Clone, Serialize)]
51pub struct AllowWithModifiedCommandOutput {
52    #[serde(rename = "hookSpecificOutput")]
53    pub hook_specific_output: AllowWithModifiedHookSpecificOutput,
54}
55
56/// Hook-specific output for allow-with-modification responses.
57#[derive(Debug, Clone, Serialize)]
58pub struct AllowWithModifiedHookSpecificOutput {
59    #[serde(rename = "hookEventName")]
60    pub hook_event_name: String,
61    #[serde(rename = "permissionDecision")]
62    pub permission_decision: String,
63    #[serde(rename = "updatedInput")]
64    pub updated_input: UpdatedInput,
65}
66
67/// The modified input to substitute.
68#[derive(Debug, Clone, Serialize)]
69pub struct UpdatedInput {
70    /// The replacement command (typically a no-op like "true" or "exit 0").
71    pub command: String,
72}
73
74/// Output to deny/block command execution.
75#[derive(Debug, Clone, Serialize)]
76pub struct DenyOutput {
77    #[serde(rename = "hookSpecificOutput")]
78    pub hook_specific_output: HookSpecificOutput,
79}
80
81#[derive(Debug, Clone, Serialize)]
82pub struct HookSpecificOutput {
83    #[serde(rename = "hookEventName")]
84    pub hook_event_name: String,
85    #[serde(rename = "permissionDecision")]
86    pub permission_decision: String,
87    #[serde(rename = "permissionDecisionReason")]
88    pub permission_decision_reason: String,
89}
90
91impl HookOutput {
92    /// Create an allow output (command proceeds normally).
93    pub fn allow() -> Self {
94        Self::Allow(AllowOutput {})
95    }
96
97    /// Create an allow output with a modified/replaced command.
98    ///
99    /// This is used for transparent interception: RCH has already executed the
100    /// command remotely, so we replace it with a no-op to prevent double execution.
101    /// The agent sees the remote output but thinks the command ran locally.
102    ///
103    /// # Arguments
104    /// * `replacement_command` - The command to substitute (typically "true" for a no-op)
105    pub fn allow_with_modified_command(replacement_command: impl Into<String>) -> Self {
106        Self::AllowWithModifiedCommand(AllowWithModifiedCommandOutput {
107            hook_specific_output: AllowWithModifiedHookSpecificOutput {
108                hook_event_name: "PreToolUse".to_string(),
109                permission_decision: "allow".to_string(),
110                updated_input: UpdatedInput {
111                    command: replacement_command.into(),
112                },
113            },
114        })
115    }
116
117    /// Create a deny output with a reason.
118    pub fn deny(reason: impl Into<String>) -> Self {
119        Self::Deny(DenyOutput {
120            hook_specific_output: HookSpecificOutput {
121                hook_event_name: "PreToolUse".to_string(),
122                permission_decision: "deny".to_string(),
123                permission_decision_reason: reason.into(),
124            },
125        })
126    }
127
128    /// Check if this output allows the command.
129    pub fn is_allow(&self) -> bool {
130        matches!(self, Self::Allow(_) | Self::AllowWithModifiedCommand(_))
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn test_parse_hook_input() {
140        let json = r#"{
141            "tool_name": "Bash",
142            "tool_input": {
143                "command": "cargo build --release",
144                "description": "Build the project"
145            },
146            "session_id": "abc123"
147        }"#;
148
149        let input: HookInput = serde_json::from_str(json).unwrap();
150        assert_eq!(input.tool_name, "Bash");
151        assert_eq!(input.tool_input.command, "cargo build --release");
152        assert_eq!(input.session_id, Some("abc123".to_string()));
153    }
154
155    #[test]
156    fn test_allow_output() {
157        let output = HookOutput::allow();
158        let json = serde_json::to_string(&output).unwrap();
159        assert_eq!(json, "{}");
160    }
161
162    #[test]
163    fn test_deny_output() {
164        let output = HookOutput::deny("Remote execution failed");
165        let json = serde_json::to_string(&output).unwrap();
166        assert!(json.contains("permissionDecision"));
167        assert!(json.contains("deny"));
168    }
169
170    #[test]
171    fn test_parse_hook_input_minimal() {
172        // Parse without optional fields
173        let json = r#"{
174            "tool_name": "Bash",
175            "tool_input": {
176                "command": "ls -la"
177            }
178        }"#;
179
180        let input: HookInput = serde_json::from_str(json).unwrap();
181        assert_eq!(input.tool_name, "Bash");
182        assert_eq!(input.tool_input.command, "ls -la");
183        assert!(input.tool_input.description.is_none());
184        assert!(input.session_id.is_none());
185    }
186
187    #[test]
188    fn test_parse_hook_input_with_empty_description() {
189        let json = r#"{
190            "tool_name": "Read",
191            "tool_input": {
192                "command": "cat file.txt",
193                "description": ""
194            }
195        }"#;
196
197        let input: HookInput = serde_json::from_str(json).unwrap();
198        assert_eq!(input.tool_name, "Read");
199        assert_eq!(input.tool_input.description, Some("".to_string()));
200    }
201
202    #[test]
203    fn test_hook_output_is_allow_true() {
204        let output = HookOutput::allow();
205        assert!(output.is_allow());
206    }
207
208    #[test]
209    fn test_hook_output_is_allow_false_for_deny() {
210        let output = HookOutput::deny("blocked");
211        assert!(!output.is_allow());
212    }
213
214    #[test]
215    fn test_deny_output_preserves_reason() {
216        let reason = "Command not allowed: security violation";
217        let output = HookOutput::deny(reason);
218
219        if let HookOutput::Deny(deny) = output {
220            assert_eq!(deny.hook_specific_output.permission_decision_reason, reason);
221            assert_eq!(deny.hook_specific_output.permission_decision, "deny");
222            assert_eq!(deny.hook_specific_output.hook_event_name, "PreToolUse");
223        } else {
224            panic!("Expected Deny variant");
225        }
226    }
227
228    #[test]
229    fn test_deny_output_with_empty_reason() {
230        let output = HookOutput::deny("");
231        if let HookOutput::Deny(deny) = output {
232            assert_eq!(deny.hook_specific_output.permission_decision_reason, "");
233        } else {
234            panic!("Expected Deny variant");
235        }
236    }
237
238    #[test]
239    fn test_allow_output_default() {
240        let output = AllowOutput::default();
241        let json = serde_json::to_string(&output).unwrap();
242        assert_eq!(json, "{}");
243    }
244
245    #[test]
246    fn test_deny_output_json_structure() {
247        let output = HookOutput::deny("test reason");
248        let json = serde_json::to_string(&output).unwrap();
249
250        // Verify the exact JSON structure expected by Claude Code
251        assert!(json.contains("hookSpecificOutput"));
252        assert!(json.contains("hookEventName"));
253        assert!(json.contains("PreToolUse"));
254        assert!(json.contains("permissionDecision"));
255        assert!(json.contains("\"deny\""));
256        assert!(json.contains("permissionDecisionReason"));
257        assert!(json.contains("test reason"));
258    }
259
260    #[test]
261    fn test_hook_input_clone() {
262        let original = HookInput {
263            tool_name: "Bash".to_string(),
264            tool_input: ToolInput {
265                command: "cargo test".to_string(),
266                description: Some("Run tests".to_string()),
267            },
268            session_id: Some("session-123".to_string()),
269        };
270
271        let cloned = original.clone();
272        assert_eq!(original.tool_name, cloned.tool_name);
273        assert_eq!(original.tool_input.command, cloned.tool_input.command);
274        assert_eq!(original.session_id, cloned.session_id);
275    }
276
277    #[test]
278    fn test_tool_input_clone() {
279        let original = ToolInput {
280            command: "make build".to_string(),
281            description: None,
282        };
283
284        let cloned = original.clone();
285        assert_eq!(original.command, cloned.command);
286        assert_eq!(original.description, cloned.description);
287    }
288
289    #[test]
290    fn test_hook_output_clone_allow() {
291        let original = HookOutput::allow();
292        let cloned = original.clone();
293        assert!(cloned.is_allow());
294    }
295
296    #[test]
297    fn test_hook_output_clone_deny() {
298        let original = HookOutput::deny("cloned reason");
299        let cloned = original.clone();
300        assert!(!cloned.is_allow());
301    }
302
303    #[test]
304    fn test_deny_output_from_string() {
305        // Test the Into<String> conversion
306        let output = HookOutput::deny(String::from("owned reason"));
307        if let HookOutput::Deny(deny) = output {
308            assert_eq!(
309                deny.hook_specific_output.permission_decision_reason,
310                "owned reason"
311            );
312        } else {
313            panic!("Expected Deny variant");
314        }
315    }
316
317    #[test]
318    fn test_parse_hook_input_different_tools() {
319        let tools = ["Bash", "Read", "Write", "Edit", "Glob", "Grep"];
320
321        for tool in tools {
322            let json = format!(
323                r#"{{"tool_name": "{}", "tool_input": {{"command": "test"}}}}"#,
324                tool
325            );
326            let input: HookInput = serde_json::from_str(&json).unwrap();
327            assert_eq!(input.tool_name, tool);
328        }
329    }
330
331    #[test]
332    fn test_parse_hook_input_unicode_command() {
333        let json = r#"{
334            "tool_name": "Bash",
335            "tool_input": {
336                "command": "echo '日本語 测试 émojis 🦀'"
337            }
338        }"#;
339
340        let input: HookInput = serde_json::from_str(json).unwrap();
341        assert!(input.tool_input.command.contains("日本語"));
342        assert!(input.tool_input.command.contains("🦀"));
343    }
344
345    #[test]
346    fn test_parse_hook_input_special_characters() {
347        let json = r#"{
348            "tool_name": "Bash",
349            "tool_input": {
350                "command": "echo \"hello\\nworld\" | grep 'pattern'"
351            }
352        }"#;
353
354        let input: HookInput = serde_json::from_str(json).unwrap();
355        assert!(input.tool_input.command.contains("echo"));
356        assert!(input.tool_input.command.contains("grep"));
357    }
358
359    #[test]
360    fn test_hook_specific_output_debug() {
361        let output = HookSpecificOutput {
362            hook_event_name: "PreToolUse".to_string(),
363            permission_decision: "deny".to_string(),
364            permission_decision_reason: "test".to_string(),
365        };
366
367        // Verify Debug trait works
368        let debug_str = format!("{:?}", output);
369        assert!(debug_str.contains("HookSpecificOutput"));
370        assert!(debug_str.contains("PreToolUse"));
371    }
372
373    #[test]
374    fn test_deny_output_debug() {
375        let output = DenyOutput {
376            hook_specific_output: HookSpecificOutput {
377                hook_event_name: "PreToolUse".to_string(),
378                permission_decision: "deny".to_string(),
379                permission_decision_reason: "test".to_string(),
380            },
381        };
382
383        let debug_str = format!("{:?}", output);
384        assert!(debug_str.contains("DenyOutput"));
385    }
386
387    #[test]
388    fn test_allow_output_debug() {
389        let output = AllowOutput {};
390        let debug_str = format!("{:?}", output);
391        assert!(debug_str.contains("AllowOutput"));
392    }
393
394    // Tests for AllowWithModifiedCommand (transparent interception)
395
396    #[test]
397    fn test_allow_with_modified_command_serializes() {
398        let output = HookOutput::allow_with_modified_command("true");
399        let json = serde_json::to_string(&output).unwrap();
400
401        // Verify the JSON structure expected by Claude Code
402        assert!(json.contains("hookSpecificOutput"));
403        assert!(json.contains("hookEventName"));
404        assert!(json.contains("PreToolUse"));
405        assert!(json.contains("permissionDecision"));
406        assert!(json.contains("\"allow\""));
407        assert!(json.contains("updatedInput"));
408        assert!(json.contains("\"command\""));
409        assert!(json.contains("\"true\""));
410    }
411
412    #[test]
413    fn test_allow_with_modified_command_is_allow() {
414        let output = HookOutput::allow_with_modified_command("true");
415        assert!(output.is_allow());
416    }
417
418    #[test]
419    fn test_allow_with_modified_command_preserves_replacement() {
420        let output = HookOutput::allow_with_modified_command("exit 101");
421        if let HookOutput::AllowWithModifiedCommand(allow_mod) = output {
422            assert_eq!(
423                allow_mod.hook_specific_output.updated_input.command,
424                "exit 101"
425            );
426            assert_eq!(allow_mod.hook_specific_output.permission_decision, "allow");
427        } else {
428            panic!("Expected AllowWithModifiedCommand variant");
429        }
430    }
431
432    #[test]
433    fn test_allow_with_modified_command_from_string() {
434        let output = HookOutput::allow_with_modified_command(String::from("echo done"));
435        if let HookOutput::AllowWithModifiedCommand(allow_mod) = output {
436            assert_eq!(
437                allow_mod.hook_specific_output.updated_input.command,
438                "echo done"
439            );
440        } else {
441            panic!("Expected AllowWithModifiedCommand variant");
442        }
443    }
444
445    #[test]
446    fn test_allow_with_modified_command_clone() {
447        let original = HookOutput::allow_with_modified_command("true");
448        let cloned = original.clone();
449        assert!(cloned.is_allow());
450        if let HookOutput::AllowWithModifiedCommand(allow_mod) = cloned {
451            assert_eq!(allow_mod.hook_specific_output.updated_input.command, "true");
452        } else {
453            panic!("Expected AllowWithModifiedCommand variant");
454        }
455    }
456
457    #[test]
458    fn test_allow_with_modified_command_json_structure() {
459        // Verify exact JSON format expected by Claude Code
460        let output = HookOutput::allow_with_modified_command("true");
461        let json = serde_json::to_string(&output).unwrap();
462        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
463
464        assert!(parsed.get("hookSpecificOutput").is_some());
465        let hook_output = parsed.get("hookSpecificOutput").unwrap();
466        assert_eq!(hook_output.get("hookEventName").unwrap(), "PreToolUse");
467        assert_eq!(hook_output.get("permissionDecision").unwrap(), "allow");
468        assert!(hook_output.get("updatedInput").is_some());
469        let updated_input = hook_output.get("updatedInput").unwrap();
470        assert_eq!(updated_input.get("command").unwrap(), "true");
471    }
472}