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    /// `None` rewrites the input but leaves the decision to Claude Code's
62    /// normal permission flow (the user's allow/deny/ask rules).
63    #[serde(rename = "permissionDecision", skip_serializing_if = "Option::is_none")]
64    pub permission_decision: Option<String>,
65    #[serde(rename = "updatedInput")]
66    pub updated_input: UpdatedInput,
67}
68
69/// The modified input to substitute.
70#[derive(Debug, Clone, Serialize)]
71pub struct UpdatedInput {
72    /// The replacement command (typically a no-op like "true" or "exit 0").
73    pub command: String,
74}
75
76/// Output to deny/block command execution.
77#[derive(Debug, Clone, Serialize)]
78pub struct DenyOutput {
79    #[serde(rename = "hookSpecificOutput")]
80    pub hook_specific_output: HookSpecificOutput,
81}
82
83#[derive(Debug, Clone, Serialize)]
84pub struct HookSpecificOutput {
85    #[serde(rename = "hookEventName")]
86    pub hook_event_name: String,
87    #[serde(rename = "permissionDecision")]
88    pub permission_decision: String,
89    #[serde(rename = "permissionDecisionReason")]
90    pub permission_decision_reason: String,
91}
92
93impl HookOutput {
94    /// Create an allow output (command proceeds normally).
95    pub fn allow() -> Self {
96        Self::Allow(AllowOutput {})
97    }
98
99    /// Create an allow output with a modified/replaced command.
100    ///
101    /// This is used for transparent interception: RCH has already executed the
102    /// command remotely, so we replace it with a no-op to prevent double execution.
103    /// The agent sees the remote output but thinks the command ran locally.
104    ///
105    /// # Arguments
106    /// * `replacement_command` - The command to substitute (typically "true" for a no-op)
107    pub fn allow_with_modified_command(replacement_command: impl Into<String>) -> Self {
108        Self::AllowWithModifiedCommand(AllowWithModifiedCommandOutput {
109            hook_specific_output: AllowWithModifiedHookSpecificOutput {
110                hook_event_name: "PreToolUse".to_string(),
111                permission_decision: Some("allow".to_string()),
112                updated_input: UpdatedInput {
113                    command: replacement_command.into(),
114                },
115            },
116        })
117    }
118
119    /// Rewrite the command but let the user's permission rules decide.
120    ///
121    /// For a compound command the rewrite keeps the user's own prefix
122    /// (`cd x && <anything> && rch exec -- cargo build`); answering "allow"
123    /// would approve that prefix without the prompt the user's rules call
124    /// for. Claude Code still applies `updatedInput` without a decision.
125    pub fn rewrite_with_permission_check(replacement_command: impl Into<String>) -> Self {
126        Self::AllowWithModifiedCommand(AllowWithModifiedCommandOutput {
127            hook_specific_output: AllowWithModifiedHookSpecificOutput {
128                hook_event_name: "PreToolUse".to_string(),
129                permission_decision: None,
130                updated_input: UpdatedInput {
131                    command: replacement_command.into(),
132                },
133            },
134        })
135    }
136
137    /// Create a deny output with a reason.
138    pub fn deny(reason: impl Into<String>) -> Self {
139        Self::Deny(DenyOutput {
140            hook_specific_output: HookSpecificOutput {
141                hook_event_name: "PreToolUse".to_string(),
142                permission_decision: "deny".to_string(),
143                permission_decision_reason: reason.into(),
144            },
145        })
146    }
147
148    /// Check if this output allows the command.
149    pub fn is_allow(&self) -> bool {
150        matches!(self, Self::Allow(_) | Self::AllowWithModifiedCommand(_))
151    }
152
153    /// Serialize for Claude Code, carrying the original tool input through.
154    ///
155    /// `updatedInput` replaces the entire tool input, so a rewrite that sends
156    /// only `command` silently drops the Bash tool's other fields: an offloaded
157    /// build lost the agent's extended `timeout` (and was killed at the default)
158    /// and its `run_in_background`. Every original field other than `command` is
159    /// copied into `updatedInput` unchanged.
160    pub fn to_hook_json(
161        &self,
162        original_tool_input: Option<&serde_json::Value>,
163    ) -> serde_json::Result<String> {
164        let mut value = serde_json::to_value(self)?;
165        if let (Self::AllowWithModifiedCommand(_), Some(serde_json::Value::Object(original))) =
166            (self, original_tool_input)
167            && let Some(updated) = value
168                .pointer_mut("/hookSpecificOutput/updatedInput")
169                .and_then(serde_json::Value::as_object_mut)
170        {
171            for (key, field) in original {
172                updated.entry(key.clone()).or_insert_with(|| field.clone());
173            }
174        }
175        serde_json::to_string(&value)
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn test_parse_hook_input() {
185        let json = r#"{
186            "tool_name": "Bash",
187            "tool_input": {
188                "command": "cargo build --release",
189                "description": "Build the project"
190            },
191            "session_id": "abc123"
192        }"#;
193
194        let input: HookInput = serde_json::from_str(json).unwrap();
195        assert_eq!(input.tool_name, "Bash");
196        assert_eq!(input.tool_input.command, "cargo build --release");
197        assert_eq!(input.session_id, Some("abc123".to_string()));
198    }
199
200    #[test]
201    fn test_allow_output() {
202        let output = HookOutput::allow();
203        let json = serde_json::to_string(&output).unwrap();
204        assert_eq!(json, "{}");
205    }
206
207    #[test]
208    fn test_deny_output() {
209        let output = HookOutput::deny("Remote execution failed");
210        let json = serde_json::to_string(&output).unwrap();
211        assert!(json.contains("permissionDecision"));
212        assert!(json.contains("deny"));
213    }
214
215    #[test]
216    fn test_parse_hook_input_minimal() {
217        // Parse without optional fields
218        let json = r#"{
219            "tool_name": "Bash",
220            "tool_input": {
221                "command": "ls -la"
222            }
223        }"#;
224
225        let input: HookInput = serde_json::from_str(json).unwrap();
226        assert_eq!(input.tool_name, "Bash");
227        assert_eq!(input.tool_input.command, "ls -la");
228        assert!(input.tool_input.description.is_none());
229        assert!(input.session_id.is_none());
230    }
231
232    #[test]
233    fn test_parse_hook_input_with_empty_description() {
234        let json = r#"{
235            "tool_name": "Read",
236            "tool_input": {
237                "command": "cat file.txt",
238                "description": ""
239            }
240        }"#;
241
242        let input: HookInput = serde_json::from_str(json).unwrap();
243        assert_eq!(input.tool_name, "Read");
244        assert_eq!(input.tool_input.description, Some("".to_string()));
245    }
246
247    #[test]
248    fn test_hook_output_is_allow_true() {
249        let output = HookOutput::allow();
250        assert!(output.is_allow());
251    }
252
253    #[test]
254    fn test_hook_output_is_allow_false_for_deny() {
255        let output = HookOutput::deny("blocked");
256        assert!(!output.is_allow());
257    }
258
259    #[test]
260    fn test_deny_output_preserves_reason() {
261        let reason = "Command not allowed: security violation";
262        let output = HookOutput::deny(reason);
263
264        if let HookOutput::Deny(deny) = output {
265            assert_eq!(deny.hook_specific_output.permission_decision_reason, reason);
266            assert_eq!(deny.hook_specific_output.permission_decision, "deny");
267            assert_eq!(deny.hook_specific_output.hook_event_name, "PreToolUse");
268        } else {
269            panic!("Expected Deny variant");
270        }
271    }
272
273    #[test]
274    fn test_deny_output_with_empty_reason() {
275        let output = HookOutput::deny("");
276        if let HookOutput::Deny(deny) = output {
277            assert_eq!(deny.hook_specific_output.permission_decision_reason, "");
278        } else {
279            panic!("Expected Deny variant");
280        }
281    }
282
283    #[test]
284    fn test_allow_output_default() {
285        let output = AllowOutput::default();
286        let json = serde_json::to_string(&output).unwrap();
287        assert_eq!(json, "{}");
288    }
289
290    #[test]
291    fn test_deny_output_json_structure() {
292        let output = HookOutput::deny("test reason");
293        let json = serde_json::to_string(&output).unwrap();
294
295        // Verify the exact JSON structure expected by Claude Code
296        assert!(json.contains("hookSpecificOutput"));
297        assert!(json.contains("hookEventName"));
298        assert!(json.contains("PreToolUse"));
299        assert!(json.contains("permissionDecision"));
300        assert!(json.contains("\"deny\""));
301        assert!(json.contains("permissionDecisionReason"));
302        assert!(json.contains("test reason"));
303    }
304
305    #[test]
306    fn test_hook_input_clone() {
307        let original = HookInput {
308            tool_name: "Bash".to_string(),
309            tool_input: ToolInput {
310                command: "cargo test".to_string(),
311                description: Some("Run tests".to_string()),
312            },
313            session_id: Some("session-123".to_string()),
314        };
315
316        let cloned = original.clone();
317        assert_eq!(original.tool_name, cloned.tool_name);
318        assert_eq!(original.tool_input.command, cloned.tool_input.command);
319        assert_eq!(original.session_id, cloned.session_id);
320    }
321
322    #[test]
323    fn test_tool_input_clone() {
324        let original = ToolInput {
325            command: "make build".to_string(),
326            description: None,
327        };
328
329        let cloned = original.clone();
330        assert_eq!(original.command, cloned.command);
331        assert_eq!(original.description, cloned.description);
332    }
333
334    #[test]
335    fn test_hook_output_clone_allow() {
336        let original = HookOutput::allow();
337        let cloned = original.clone();
338        assert!(cloned.is_allow());
339    }
340
341    #[test]
342    fn test_hook_output_clone_deny() {
343        let original = HookOutput::deny("cloned reason");
344        let cloned = original.clone();
345        assert!(!cloned.is_allow());
346    }
347
348    #[test]
349    fn test_deny_output_from_string() {
350        // Test the Into<String> conversion
351        let output = HookOutput::deny(String::from("owned reason"));
352        if let HookOutput::Deny(deny) = output {
353            assert_eq!(
354                deny.hook_specific_output.permission_decision_reason,
355                "owned reason"
356            );
357        } else {
358            panic!("Expected Deny variant");
359        }
360    }
361
362    #[test]
363    fn test_parse_hook_input_different_tools() {
364        let tools = ["Bash", "Read", "Write", "Edit", "Glob", "Grep"];
365
366        for tool in tools {
367            let json = format!(
368                r#"{{"tool_name": "{}", "tool_input": {{"command": "test"}}}}"#,
369                tool
370            );
371            let input: HookInput = serde_json::from_str(&json).unwrap();
372            assert_eq!(input.tool_name, tool);
373        }
374    }
375
376    #[test]
377    fn test_parse_hook_input_unicode_command() {
378        let json = r#"{
379            "tool_name": "Bash",
380            "tool_input": {
381                "command": "echo '日本語 测试 émojis 🦀'"
382            }
383        }"#;
384
385        let input: HookInput = serde_json::from_str(json).unwrap();
386        assert!(input.tool_input.command.contains("日本語"));
387        assert!(input.tool_input.command.contains("🦀"));
388    }
389
390    #[test]
391    fn test_parse_hook_input_special_characters() {
392        let json = r#"{
393            "tool_name": "Bash",
394            "tool_input": {
395                "command": "echo \"hello\\nworld\" | grep 'pattern'"
396            }
397        }"#;
398
399        let input: HookInput = serde_json::from_str(json).unwrap();
400        assert!(input.tool_input.command.contains("echo"));
401        assert!(input.tool_input.command.contains("grep"));
402    }
403
404    #[test]
405    fn test_hook_specific_output_debug() {
406        let output = HookSpecificOutput {
407            hook_event_name: "PreToolUse".to_string(),
408            permission_decision: "deny".to_string(),
409            permission_decision_reason: "test".to_string(),
410        };
411
412        // Verify Debug trait works
413        let debug_str = format!("{:?}", output);
414        assert!(debug_str.contains("HookSpecificOutput"));
415        assert!(debug_str.contains("PreToolUse"));
416    }
417
418    #[test]
419    fn test_deny_output_debug() {
420        let output = DenyOutput {
421            hook_specific_output: HookSpecificOutput {
422                hook_event_name: "PreToolUse".to_string(),
423                permission_decision: "deny".to_string(),
424                permission_decision_reason: "test".to_string(),
425            },
426        };
427
428        let debug_str = format!("{:?}", output);
429        assert!(debug_str.contains("DenyOutput"));
430    }
431
432    #[test]
433    fn test_allow_output_debug() {
434        let output = AllowOutput {};
435        let debug_str = format!("{:?}", output);
436        assert!(debug_str.contains("AllowOutput"));
437    }
438
439    // Tests for AllowWithModifiedCommand (transparent interception)
440
441    #[test]
442    fn test_allow_with_modified_command_serializes() {
443        let output = HookOutput::allow_with_modified_command("true");
444        let json = serde_json::to_string(&output).unwrap();
445
446        // Verify the JSON structure expected by Claude Code
447        assert!(json.contains("hookSpecificOutput"));
448        assert!(json.contains("hookEventName"));
449        assert!(json.contains("PreToolUse"));
450        assert!(json.contains("permissionDecision"));
451        assert!(json.contains("\"allow\""));
452        assert!(json.contains("updatedInput"));
453        assert!(json.contains("\"command\""));
454        assert!(json.contains("\"true\""));
455    }
456
457    #[test]
458    fn test_allow_with_modified_command_is_allow() {
459        let output = HookOutput::allow_with_modified_command("true");
460        assert!(output.is_allow());
461    }
462
463    #[test]
464    fn test_allow_with_modified_command_preserves_replacement() {
465        let output = HookOutput::allow_with_modified_command("exit 101");
466        if let HookOutput::AllowWithModifiedCommand(allow_mod) = output {
467            assert_eq!(
468                allow_mod.hook_specific_output.updated_input.command,
469                "exit 101"
470            );
471            assert_eq!(
472                allow_mod
473                    .hook_specific_output
474                    .permission_decision
475                    .as_deref(),
476                Some("allow")
477            );
478        } else {
479            panic!("Expected AllowWithModifiedCommand variant");
480        }
481    }
482
483    #[test]
484    fn rewrite_with_permission_check_omits_the_decision() {
485        let json = HookOutput::rewrite_with_permission_check("cd x && rch exec -- cargo build")
486            .to_hook_json(None)
487            .unwrap();
488        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
489        let output = &value["hookSpecificOutput"];
490        assert!(output.get("permissionDecision").is_none(), "{json}");
491        assert_eq!(
492            output["updatedInput"]["command"],
493            "cd x && rch exec -- cargo build"
494        );
495        let allow = HookOutput::allow_with_modified_command("true")
496            .to_hook_json(None)
497            .unwrap();
498        assert!(
499            allow.contains("\"permissionDecision\":\"allow\""),
500            "{allow}"
501        );
502    }
503
504    #[test]
505    fn test_allow_with_modified_command_from_string() {
506        let output = HookOutput::allow_with_modified_command(String::from("echo done"));
507        if let HookOutput::AllowWithModifiedCommand(allow_mod) = output {
508            assert_eq!(
509                allow_mod.hook_specific_output.updated_input.command,
510                "echo done"
511            );
512        } else {
513            panic!("Expected AllowWithModifiedCommand variant");
514        }
515    }
516
517    #[test]
518    fn test_allow_with_modified_command_clone() {
519        let original = HookOutput::allow_with_modified_command("true");
520        let cloned = original.clone();
521        assert!(cloned.is_allow());
522        if let HookOutput::AllowWithModifiedCommand(allow_mod) = cloned {
523            assert_eq!(allow_mod.hook_specific_output.updated_input.command, "true");
524        } else {
525            panic!("Expected AllowWithModifiedCommand variant");
526        }
527    }
528
529    #[test]
530    fn test_allow_with_modified_command_json_structure() {
531        // Verify exact JSON format expected by Claude Code
532        let output = HookOutput::allow_with_modified_command("true");
533        let json = serde_json::to_string(&output).unwrap();
534        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
535
536        assert!(parsed.get("hookSpecificOutput").is_some());
537        let hook_output = parsed.get("hookSpecificOutput").unwrap();
538        assert_eq!(hook_output.get("hookEventName").unwrap(), "PreToolUse");
539        assert_eq!(hook_output.get("permissionDecision").unwrap(), "allow");
540        assert!(hook_output.get("updatedInput").is_some());
541        let updated_input = hook_output.get("updatedInput").unwrap();
542        assert_eq!(updated_input.get("command").unwrap(), "true");
543    }
544
545    #[test]
546    fn test_to_hook_json_carries_original_tool_input_fields() {
547        let original = serde_json::json!({
548            "command": "cargo test",
549            "timeout": 600_000,
550            "run_in_background": true,
551            "description": "Run the suite",
552        });
553        let output = HookOutput::allow_with_modified_command("rch exec -- cargo test");
554        let json = output.to_hook_json(Some(&original)).unwrap();
555        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
556        let updated = &parsed["hookSpecificOutput"]["updatedInput"];
557        assert_eq!(updated["command"], "rch exec -- cargo test");
558        assert_eq!(updated["timeout"], 600_000);
559        assert_eq!(updated["run_in_background"], true);
560        assert_eq!(updated["description"], "Run the suite");
561    }
562
563    #[test]
564    fn test_to_hook_json_leaves_deny_and_missing_input_untouched() {
565        let original = serde_json::json!({"command": "x", "timeout": 5});
566        let deny = HookOutput::deny("no");
567        assert_eq!(
568            deny.to_hook_json(Some(&original)).unwrap(),
569            serde_json::to_string(&deny).unwrap()
570        );
571        let modified = HookOutput::allow_with_modified_command("true");
572        assert_eq!(
573            modified.to_hook_json(None).unwrap(),
574            serde_json::to_string(&modified).unwrap()
575        );
576    }
577}