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    /// Serialize for Claude Code, carrying the original tool input through.
134    ///
135    /// `updatedInput` replaces the entire tool input, so a rewrite that sends
136    /// only `command` silently drops the Bash tool's other fields: an offloaded
137    /// build lost the agent's extended `timeout` (and was killed at the default)
138    /// and its `run_in_background`. Every original field other than `command` is
139    /// copied into `updatedInput` unchanged.
140    pub fn to_hook_json(
141        &self,
142        original_tool_input: Option<&serde_json::Value>,
143    ) -> serde_json::Result<String> {
144        let mut value = serde_json::to_value(self)?;
145        if let (Self::AllowWithModifiedCommand(_), Some(serde_json::Value::Object(original))) =
146            (self, original_tool_input)
147            && let Some(updated) = value
148                .pointer_mut("/hookSpecificOutput/updatedInput")
149                .and_then(serde_json::Value::as_object_mut)
150        {
151            for (key, field) in original {
152                updated.entry(key.clone()).or_insert_with(|| field.clone());
153            }
154        }
155        serde_json::to_string(&value)
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn test_parse_hook_input() {
165        let json = r#"{
166            "tool_name": "Bash",
167            "tool_input": {
168                "command": "cargo build --release",
169                "description": "Build the project"
170            },
171            "session_id": "abc123"
172        }"#;
173
174        let input: HookInput = serde_json::from_str(json).unwrap();
175        assert_eq!(input.tool_name, "Bash");
176        assert_eq!(input.tool_input.command, "cargo build --release");
177        assert_eq!(input.session_id, Some("abc123".to_string()));
178    }
179
180    #[test]
181    fn test_allow_output() {
182        let output = HookOutput::allow();
183        let json = serde_json::to_string(&output).unwrap();
184        assert_eq!(json, "{}");
185    }
186
187    #[test]
188    fn test_deny_output() {
189        let output = HookOutput::deny("Remote execution failed");
190        let json = serde_json::to_string(&output).unwrap();
191        assert!(json.contains("permissionDecision"));
192        assert!(json.contains("deny"));
193    }
194
195    #[test]
196    fn test_parse_hook_input_minimal() {
197        // Parse without optional fields
198        let json = r#"{
199            "tool_name": "Bash",
200            "tool_input": {
201                "command": "ls -la"
202            }
203        }"#;
204
205        let input: HookInput = serde_json::from_str(json).unwrap();
206        assert_eq!(input.tool_name, "Bash");
207        assert_eq!(input.tool_input.command, "ls -la");
208        assert!(input.tool_input.description.is_none());
209        assert!(input.session_id.is_none());
210    }
211
212    #[test]
213    fn test_parse_hook_input_with_empty_description() {
214        let json = r#"{
215            "tool_name": "Read",
216            "tool_input": {
217                "command": "cat file.txt",
218                "description": ""
219            }
220        }"#;
221
222        let input: HookInput = serde_json::from_str(json).unwrap();
223        assert_eq!(input.tool_name, "Read");
224        assert_eq!(input.tool_input.description, Some("".to_string()));
225    }
226
227    #[test]
228    fn test_hook_output_is_allow_true() {
229        let output = HookOutput::allow();
230        assert!(output.is_allow());
231    }
232
233    #[test]
234    fn test_hook_output_is_allow_false_for_deny() {
235        let output = HookOutput::deny("blocked");
236        assert!(!output.is_allow());
237    }
238
239    #[test]
240    fn test_deny_output_preserves_reason() {
241        let reason = "Command not allowed: security violation";
242        let output = HookOutput::deny(reason);
243
244        if let HookOutput::Deny(deny) = output {
245            assert_eq!(deny.hook_specific_output.permission_decision_reason, reason);
246            assert_eq!(deny.hook_specific_output.permission_decision, "deny");
247            assert_eq!(deny.hook_specific_output.hook_event_name, "PreToolUse");
248        } else {
249            panic!("Expected Deny variant");
250        }
251    }
252
253    #[test]
254    fn test_deny_output_with_empty_reason() {
255        let output = HookOutput::deny("");
256        if let HookOutput::Deny(deny) = output {
257            assert_eq!(deny.hook_specific_output.permission_decision_reason, "");
258        } else {
259            panic!("Expected Deny variant");
260        }
261    }
262
263    #[test]
264    fn test_allow_output_default() {
265        let output = AllowOutput::default();
266        let json = serde_json::to_string(&output).unwrap();
267        assert_eq!(json, "{}");
268    }
269
270    #[test]
271    fn test_deny_output_json_structure() {
272        let output = HookOutput::deny("test reason");
273        let json = serde_json::to_string(&output).unwrap();
274
275        // Verify the exact JSON structure expected by Claude Code
276        assert!(json.contains("hookSpecificOutput"));
277        assert!(json.contains("hookEventName"));
278        assert!(json.contains("PreToolUse"));
279        assert!(json.contains("permissionDecision"));
280        assert!(json.contains("\"deny\""));
281        assert!(json.contains("permissionDecisionReason"));
282        assert!(json.contains("test reason"));
283    }
284
285    #[test]
286    fn test_hook_input_clone() {
287        let original = HookInput {
288            tool_name: "Bash".to_string(),
289            tool_input: ToolInput {
290                command: "cargo test".to_string(),
291                description: Some("Run tests".to_string()),
292            },
293            session_id: Some("session-123".to_string()),
294        };
295
296        let cloned = original.clone();
297        assert_eq!(original.tool_name, cloned.tool_name);
298        assert_eq!(original.tool_input.command, cloned.tool_input.command);
299        assert_eq!(original.session_id, cloned.session_id);
300    }
301
302    #[test]
303    fn test_tool_input_clone() {
304        let original = ToolInput {
305            command: "make build".to_string(),
306            description: None,
307        };
308
309        let cloned = original.clone();
310        assert_eq!(original.command, cloned.command);
311        assert_eq!(original.description, cloned.description);
312    }
313
314    #[test]
315    fn test_hook_output_clone_allow() {
316        let original = HookOutput::allow();
317        let cloned = original.clone();
318        assert!(cloned.is_allow());
319    }
320
321    #[test]
322    fn test_hook_output_clone_deny() {
323        let original = HookOutput::deny("cloned reason");
324        let cloned = original.clone();
325        assert!(!cloned.is_allow());
326    }
327
328    #[test]
329    fn test_deny_output_from_string() {
330        // Test the Into<String> conversion
331        let output = HookOutput::deny(String::from("owned reason"));
332        if let HookOutput::Deny(deny) = output {
333            assert_eq!(
334                deny.hook_specific_output.permission_decision_reason,
335                "owned reason"
336            );
337        } else {
338            panic!("Expected Deny variant");
339        }
340    }
341
342    #[test]
343    fn test_parse_hook_input_different_tools() {
344        let tools = ["Bash", "Read", "Write", "Edit", "Glob", "Grep"];
345
346        for tool in tools {
347            let json = format!(
348                r#"{{"tool_name": "{}", "tool_input": {{"command": "test"}}}}"#,
349                tool
350            );
351            let input: HookInput = serde_json::from_str(&json).unwrap();
352            assert_eq!(input.tool_name, tool);
353        }
354    }
355
356    #[test]
357    fn test_parse_hook_input_unicode_command() {
358        let json = r#"{
359            "tool_name": "Bash",
360            "tool_input": {
361                "command": "echo '日本語 测试 émojis 🦀'"
362            }
363        }"#;
364
365        let input: HookInput = serde_json::from_str(json).unwrap();
366        assert!(input.tool_input.command.contains("日本語"));
367        assert!(input.tool_input.command.contains("🦀"));
368    }
369
370    #[test]
371    fn test_parse_hook_input_special_characters() {
372        let json = r#"{
373            "tool_name": "Bash",
374            "tool_input": {
375                "command": "echo \"hello\\nworld\" | grep 'pattern'"
376            }
377        }"#;
378
379        let input: HookInput = serde_json::from_str(json).unwrap();
380        assert!(input.tool_input.command.contains("echo"));
381        assert!(input.tool_input.command.contains("grep"));
382    }
383
384    #[test]
385    fn test_hook_specific_output_debug() {
386        let output = HookSpecificOutput {
387            hook_event_name: "PreToolUse".to_string(),
388            permission_decision: "deny".to_string(),
389            permission_decision_reason: "test".to_string(),
390        };
391
392        // Verify Debug trait works
393        let debug_str = format!("{:?}", output);
394        assert!(debug_str.contains("HookSpecificOutput"));
395        assert!(debug_str.contains("PreToolUse"));
396    }
397
398    #[test]
399    fn test_deny_output_debug() {
400        let output = DenyOutput {
401            hook_specific_output: HookSpecificOutput {
402                hook_event_name: "PreToolUse".to_string(),
403                permission_decision: "deny".to_string(),
404                permission_decision_reason: "test".to_string(),
405            },
406        };
407
408        let debug_str = format!("{:?}", output);
409        assert!(debug_str.contains("DenyOutput"));
410    }
411
412    #[test]
413    fn test_allow_output_debug() {
414        let output = AllowOutput {};
415        let debug_str = format!("{:?}", output);
416        assert!(debug_str.contains("AllowOutput"));
417    }
418
419    // Tests for AllowWithModifiedCommand (transparent interception)
420
421    #[test]
422    fn test_allow_with_modified_command_serializes() {
423        let output = HookOutput::allow_with_modified_command("true");
424        let json = serde_json::to_string(&output).unwrap();
425
426        // Verify the JSON structure expected by Claude Code
427        assert!(json.contains("hookSpecificOutput"));
428        assert!(json.contains("hookEventName"));
429        assert!(json.contains("PreToolUse"));
430        assert!(json.contains("permissionDecision"));
431        assert!(json.contains("\"allow\""));
432        assert!(json.contains("updatedInput"));
433        assert!(json.contains("\"command\""));
434        assert!(json.contains("\"true\""));
435    }
436
437    #[test]
438    fn test_allow_with_modified_command_is_allow() {
439        let output = HookOutput::allow_with_modified_command("true");
440        assert!(output.is_allow());
441    }
442
443    #[test]
444    fn test_allow_with_modified_command_preserves_replacement() {
445        let output = HookOutput::allow_with_modified_command("exit 101");
446        if let HookOutput::AllowWithModifiedCommand(allow_mod) = output {
447            assert_eq!(
448                allow_mod.hook_specific_output.updated_input.command,
449                "exit 101"
450            );
451            assert_eq!(allow_mod.hook_specific_output.permission_decision, "allow");
452        } else {
453            panic!("Expected AllowWithModifiedCommand variant");
454        }
455    }
456
457    #[test]
458    fn test_allow_with_modified_command_from_string() {
459        let output = HookOutput::allow_with_modified_command(String::from("echo done"));
460        if let HookOutput::AllowWithModifiedCommand(allow_mod) = output {
461            assert_eq!(
462                allow_mod.hook_specific_output.updated_input.command,
463                "echo done"
464            );
465        } else {
466            panic!("Expected AllowWithModifiedCommand variant");
467        }
468    }
469
470    #[test]
471    fn test_allow_with_modified_command_clone() {
472        let original = HookOutput::allow_with_modified_command("true");
473        let cloned = original.clone();
474        assert!(cloned.is_allow());
475        if let HookOutput::AllowWithModifiedCommand(allow_mod) = cloned {
476            assert_eq!(allow_mod.hook_specific_output.updated_input.command, "true");
477        } else {
478            panic!("Expected AllowWithModifiedCommand variant");
479        }
480    }
481
482    #[test]
483    fn test_allow_with_modified_command_json_structure() {
484        // Verify exact JSON format expected by Claude Code
485        let output = HookOutput::allow_with_modified_command("true");
486        let json = serde_json::to_string(&output).unwrap();
487        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
488
489        assert!(parsed.get("hookSpecificOutput").is_some());
490        let hook_output = parsed.get("hookSpecificOutput").unwrap();
491        assert_eq!(hook_output.get("hookEventName").unwrap(), "PreToolUse");
492        assert_eq!(hook_output.get("permissionDecision").unwrap(), "allow");
493        assert!(hook_output.get("updatedInput").is_some());
494        let updated_input = hook_output.get("updatedInput").unwrap();
495        assert_eq!(updated_input.get("command").unwrap(), "true");
496    }
497
498    #[test]
499    fn test_to_hook_json_carries_original_tool_input_fields() {
500        let original = serde_json::json!({
501            "command": "cargo test",
502            "timeout": 600_000,
503            "run_in_background": true,
504            "description": "Run the suite",
505        });
506        let output = HookOutput::allow_with_modified_command("rch exec -- cargo test");
507        let json = output.to_hook_json(Some(&original)).unwrap();
508        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
509        let updated = &parsed["hookSpecificOutput"]["updatedInput"];
510        assert_eq!(updated["command"], "rch exec -- cargo test");
511        assert_eq!(updated["timeout"], 600_000);
512        assert_eq!(updated["run_in_background"], true);
513        assert_eq!(updated["description"], "Run the suite");
514    }
515
516    #[test]
517    fn test_to_hook_json_leaves_deny_and_missing_input_untouched() {
518        let original = serde_json::json!({"command": "x", "timeout": 5});
519        let deny = HookOutput::deny("no");
520        assert_eq!(
521            deny.to_hook_json(Some(&original)).unwrap(),
522            serde_json::to_string(&deny).unwrap()
523        );
524        let modified = HookOutput::allow_with_modified_command("true");
525        assert_eq!(
526            modified.to_hook_json(None).unwrap(),
527            serde_json::to_string(&modified).unwrap()
528        );
529    }
530}