Skip to main content

exomonad_core/protocol/
hook.rs

1//! Hook event types and builders.
2//!
3//! Types for Claude Code hook stdin/stdout communication.
4
5use crate::domain::{SessionId, ToolName, ToolPermission};
6use crate::protocol::Runtime;
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10// ============================================================================
11// Hook Event Types (from Claude Code stdin)
12// ============================================================================
13
14/// Hook event payload received from Claude Code via stdin.
15///
16/// This matches the JSON schema that Claude Code sends to hook commands.
17/// Fields vary by hook type - we capture them all and let the Haskell
18/// handler pick what it needs.
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
20pub struct HookInput {
21    /// Session ID from Claude Code.
22    pub session_id: SessionId,
23
24    /// Path to the transcript file.
25    #[serde(default)]
26    pub transcript_path: String,
27
28    /// Current working directory.
29    #[serde(default)]
30    pub cwd: String,
31
32    /// Permission mode (default, plan, acceptEdits, dontAsk, bypassPermissions).
33    #[serde(default)]
34    pub permission_mode: String,
35
36    /// The hook event name (PreToolUse, PostToolUse, etc.).
37    pub hook_event_name: String,
38
39    /// Runtime environment (claude, gemini). Injected by Rust before WASM call.
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub runtime: Option<Runtime>,
42
43    // ----- Tool-related fields (PreToolUse, PostToolUse, PermissionRequest) -----
44    /// Tool name for tool-related hooks.
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub tool_name: Option<ToolName>,
47
48    /// Tool input arguments for tool-related hooks.
49    #[serde(alias = "tool_parameters")]
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub tool_input: Option<Value>,
52
53    /// Tool use ID for tool-related hooks.
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub tool_use_id: Option<String>,
56
57    /// Tool response (PostToolUse only).
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub tool_response: Option<Value>,
60
61    // ----- Other hook-specific fields -----
62    /// User prompt text (UserPromptSubmit).
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub prompt: Option<String>,
65
66    /// Notification message (Notification).
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub message: Option<String>,
69
70    /// Notification type (Notification).
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub notification_type: Option<String>,
73
74    /// Whether stop hook is active (Stop, SubagentStop).
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub stop_hook_active: Option<bool>,
77
78    /// Compact trigger (PreCompact).
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub trigger: Option<String>,
81
82    /// Custom instructions (PreCompact).
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub custom_instructions: Option<String>,
85
86    /// Session start source (SessionStart).
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub source: Option<String>,
89
90    /// Session end reason (SessionEnd).
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub reason: Option<String>,
93
94    /// Agent's response text (AfterAgent).
95    /// Ref: <https://geminicli.com/docs/hooks/reference/#afteragent>
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub prompt_response: Option<String>,
98
99    /// Event timestamp.
100    /// Ref: <https://geminicli.com/docs/hooks/reference/#afteragent>
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub timestamp: Option<String>,
103}
104
105// ============================================================================
106// Hook Output Types (to Claude Code stdout)
107// ============================================================================
108
109/// Common fields for all hook output types.
110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
111pub struct ClaudePreToolUseOutput {
112    /// Whether to continue processing (default: true).
113    #[serde(rename = "continue", default = "default_true")]
114    pub continue_: bool,
115
116    /// Reason for stopping (when continue_ = false).
117    #[serde(skip_serializing_if = "Option::is_none", rename = "stopReason")]
118    pub stop_reason: Option<String>,
119
120    /// Whether to suppress output in Claude Code UI.
121    #[serde(
122        skip_serializing_if = "Option::is_none",
123        rename = "suppressOutput",
124        default
125    )]
126    pub suppress_output: Option<bool>,
127
128    /// System message to show to user.
129    #[serde(skip_serializing_if = "Option::is_none", rename = "systemMessage")]
130    pub system_message: Option<String>,
131
132    /// Hook-specific output fields.
133    #[serde(skip_serializing_if = "Option::is_none", rename = "hookSpecificOutput")]
134    pub hook_specific_output: Option<HookSpecificOutput>,
135}
136
137impl Default for ClaudePreToolUseOutput {
138    fn default() -> Self {
139        Self {
140            continue_: true, // Semantic default: allow continuation
141            stop_reason: None,
142            suppress_output: None,
143            system_message: None,
144            hook_specific_output: None,
145        }
146    }
147}
148
149fn default_true() -> bool {
150    true
151}
152
153/// Hook-specific output fields.
154///
155/// The inner structure varies by hook type.
156#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
157#[serde(tag = "hookEventName")]
158pub enum HookSpecificOutput {
159    /// PreToolUse hook output.
160    PreToolUse {
161        /// Permission decision: "allow", "deny", or "ask".
162        #[serde(rename = "permissionDecision")]
163        permission_decision: ToolPermission,
164
165        /// Reason for the decision.
166        #[serde(
167            skip_serializing_if = "Option::is_none",
168            rename = "permissionDecisionReason"
169        )]
170        permission_decision_reason: Option<String>,
171
172        /// Modified tool input (only with "allow" decision).
173        #[serde(skip_serializing_if = "Option::is_none", rename = "updatedInput")]
174        updated_input: Option<Value>,
175    },
176
177    /// PostToolUse hook output.
178    PostToolUse {
179        /// Additional context for Claude about the tool result.
180        #[serde(skip_serializing_if = "Option::is_none", rename = "additionalContext")]
181        additional_context: Option<String>,
182    },
183
184    /// UserPromptSubmit hook output.
185    UserPromptSubmit {
186        /// Additional context added to conversation.
187        #[serde(skip_serializing_if = "Option::is_none", rename = "additionalContext")]
188        additional_context: Option<String>,
189    },
190
191    /// SessionStart hook output.
192    SessionStart {
193        /// Context loaded at session start.
194        #[serde(skip_serializing_if = "Option::is_none", rename = "additionalContext")]
195        additional_context: Option<String>,
196    },
197
198    /// PermissionRequest hook output.
199    PermissionRequest {
200        /// Permission decision.
201        decision: PermissionDecision,
202    },
203
204    /// Stop hook output.
205    Stop {
206        /// Decision: "block" to prevent stopping, or None to allow.
207        #[serde(skip_serializing_if = "Option::is_none")]
208        decision: Option<String>,
209
210        /// Claude-facing guidance when blocked.
211        #[serde(skip_serializing_if = "Option::is_none")]
212        reason: Option<String>,
213    },
214
215    /// SubagentStop hook output.
216    SubagentStop {
217        /// Decision: "block" to prevent stopping, or None to allow.
218        #[serde(skip_serializing_if = "Option::is_none")]
219        decision: Option<String>,
220
221        /// Claude-facing guidance when blocked.
222        #[serde(skip_serializing_if = "Option::is_none")]
223        reason: Option<String>,
224    },
225
226    /// Notification hook output.
227    Notification,
228
229    /// PreCompact hook output.
230    PreCompact,
231
232    /// SessionEnd hook output.
233    SessionEnd,
234}
235
236/// Permission decision for PermissionRequest hook.
237#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
238#[serde(tag = "behavior")]
239pub enum PermissionDecision {
240    /// Allow the tool call.
241    #[serde(rename = "allow")]
242    Allow {
243        /// Modified tool input.
244        #[serde(skip_serializing_if = "Option::is_none", rename = "updatedInput")]
245        updated_input: Option<Value>,
246    },
247
248    /// Deny the tool call.
249    #[serde(rename = "deny")]
250    Deny {
251        /// Message explaining denial.
252        message: String,
253
254        /// Whether to interrupt the current turn.
255        #[serde(default)]
256        interrupt: bool,
257    },
258}
259
260// ============================================================================
261// Builder helpers
262// ============================================================================
263
264impl ClaudePreToolUseOutput {
265    /// Create an "allow" response for PreToolUse.
266    pub fn pre_tool_use_allow(reason: Option<String>, modified_input: Option<Value>) -> Self {
267        Self {
268            continue_: true,
269            hook_specific_output: Some(HookSpecificOutput::PreToolUse {
270                permission_decision: ToolPermission::Allow,
271                permission_decision_reason: reason,
272                updated_input: modified_input,
273            }),
274            ..Default::default()
275        }
276    }
277
278    /// Create a "deny" response for PreToolUse.
279    pub fn pre_tool_use_deny(reason: String) -> Self {
280        Self {
281            continue_: true,
282            hook_specific_output: Some(HookSpecificOutput::PreToolUse {
283                permission_decision: ToolPermission::Deny,
284                permission_decision_reason: Some(reason),
285                updated_input: None,
286            }),
287            ..Default::default()
288        }
289    }
290
291    /// Create an "allow" response for PostToolUse with optional context.
292    pub fn post_tool_use_allow(additional_context: Option<String>) -> Self {
293        Self {
294            continue_: true,
295            hook_specific_output: Some(HookSpecificOutput::PostToolUse { additional_context }),
296            ..Default::default()
297        }
298    }
299
300    /// Create a "block" response that stops processing.
301    pub fn block(reason: String) -> Self {
302        Self {
303            continue_: false,
304            stop_reason: Some(reason),
305            ..Default::default()
306        }
307    }
308}
309
310// ============================================================================
311// Internal Domain Types (from WASM, translated at edge)
312// ============================================================================
313
314/// Stop hook decision from WASM (domain type).
315#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
316#[serde(rename_all = "lowercase")]
317pub enum StopDecision {
318    /// Allow the agent to stop
319    Allow,
320    /// Block stopping, send reason as correction prompt
321    Block,
322}
323
324/// Internal stop hook output from WASM.
325/// This is the domain type that Haskell returns. Rust translates to runtime-specific format.
326#[derive(Debug, Clone, Deserialize)]
327pub struct InternalStopHookOutput {
328    /// Decision: allow or block
329    pub decision: StopDecision,
330    /// Reason for blocking (sent to agent as correction prompt)
331    pub reason: Option<String>,
332}
333
334impl InternalStopHookOutput {
335    /// Translate to Claude Code format.
336    pub fn to_claude(&self) -> ClaudeStopHookOutput {
337        match self.decision {
338            StopDecision::Allow => ClaudeStopHookOutput {
339                continue_: true,
340                stop_reason: None,
341            },
342            StopDecision::Block => ClaudeStopHookOutput {
343                continue_: false,
344                stop_reason: self.reason.clone(),
345            },
346        }
347    }
348
349    /// Translate to Gemini CLI format.
350    /// Ref: <https://geminicli.com/docs/hooks/reference/#afteragent>
351    pub fn to_gemini(&self) -> GeminiStopHookOutput {
352        match self.decision {
353            StopDecision::Allow => GeminiStopHookOutput {
354                decision: GeminiStopDecision::Allow,
355                reason: None,
356                continue_: true,
357                clear_context: None,
358                system_message: None,
359                suppress_output: None,
360            },
361            StopDecision::Block => GeminiStopHookOutput {
362                decision: GeminiStopDecision::Deny, // Gemini uses "deny" for retry
363                reason: self.reason.clone(),
364                continue_: true,
365                clear_context: None,
366                system_message: None,
367                suppress_output: None,
368            },
369        }
370    }
371
372    /// Translate to runtime-specific format and serialize to JSON.
373    pub fn to_runtime_json(&self, runtime: &Runtime) -> String {
374        match runtime {
375            Runtime::Claude => serde_json::to_string(&self.to_claude())
376                .unwrap_or_else(|_| r#"{"continue":true}"#.to_string()),
377            Runtime::Gemini => serde_json::to_string(&self.to_gemini())
378                .unwrap_or_else(|_| r#"{"decision":"allow"}"#.to_string()),
379        }
380    }
381}
382
383/// Claude Code stop hook output format.
384#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
385pub struct ClaudeStopHookOutput {
386    /// Whether to continue (true = allow stop, false = block)
387    #[serde(rename = "continue")]
388    pub continue_: bool,
389    /// Reason for blocking
390    #[serde(skip_serializing_if = "Option::is_none", rename = "stopReason")]
391    pub stop_reason: Option<String>,
392}
393
394/// Gemini CLI stop hook decision.
395/// Ref: <https://geminicli.com/docs/hooks/reference/#afteragent>
396#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
397#[serde(rename_all = "lowercase")]
398pub enum GeminiStopDecision {
399    /// Allow the agent to stop
400    Allow,
401    /// Deny and trigger retry with reason as correction prompt
402    Deny,
403}
404
405/// Gemini CLI stop hook output format.
406/// Ref: <https://geminicli.com/docs/hooks/reference/#afteragent>
407#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
408pub struct GeminiStopHookOutput {
409    /// Decision: allow or deny (deny triggers retry with reason as correction prompt)
410    pub decision: GeminiStopDecision,
411    /// Reason sent to agent as correction prompt (when decision = deny)
412    #[serde(skip_serializing_if = "Option::is_none")]
413    pub reason: Option<String>,
414    /// Whether to continue the session (false = exit)
415    #[serde(rename = "continue", default = "default_true")]
416    pub continue_: bool,
417    /// Whether to clear conversation context
418    #[serde(skip_serializing_if = "Option::is_none", rename = "clearContext")]
419    pub clear_context: Option<bool>,
420    /// System message to show to user
421    #[serde(skip_serializing_if = "Option::is_none", rename = "systemMessage")]
422    pub system_message: Option<String>,
423    /// Whether to suppress CLI output
424    #[serde(skip_serializing_if = "Option::is_none", rename = "suppressOutput")]
425    pub suppress_output: Option<bool>,
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    #[test]
433    fn test_hook_input_deserialize() {
434        let json = r#"{
435            "session_id": "abc123",
436            "transcript_path": "/tmp/transcript.jsonl",
437            "cwd": "/home/user/project",
438            "permission_mode": "default",
439            "hook_event_name": "PreToolUse",
440            "tool_name": "Write",
441            "tool_input": {"file_path": "/tmp/test.txt", "content": "hello"},
442            "tool_use_id": "toolu_123"
443        }"#;
444
445        let input: HookInput = serde_json::from_str(json).unwrap();
446        assert_eq!(input.hook_event_name, "PreToolUse");
447        assert_eq!(input.tool_name.as_ref().map(|t| t.as_str()), Some("Write"));
448    }
449
450    #[test]
451    fn test_hook_output_serialize() {
452        let output = ClaudePreToolUseOutput::pre_tool_use_allow(
453            Some("Allowed by ExoMonad".to_string()),
454            Some(serde_json::json!({"file_path": "/tmp/safe.txt"})),
455        );
456
457        let json = serde_json::to_string_pretty(&output).unwrap();
458        assert!(json.contains("permissionDecision"));
459        assert!(json.contains("allow"));
460    }
461
462    // =========================================================================
463    // ClaudePreToolUseOutput comprehensive serialization tests
464    // =========================================================================
465
466    #[test]
467    fn test_hook_output_pre_tool_use_allow_format() {
468        let output = ClaudePreToolUseOutput::pre_tool_use_allow(Some("test reason".into()), None);
469        let json = serde_json::to_value(&output).unwrap();
470
471        assert_eq!(json["continue"], true);
472        assert!(
473            json["stopReason"].is_null() || !json.as_object().unwrap().contains_key("stopReason")
474        );
475
476        let specific = &json["hookSpecificOutput"];
477        assert_eq!(specific["hookEventName"], "PreToolUse");
478        assert_eq!(specific["permissionDecision"], "allow");
479        assert_eq!(specific["permissionDecisionReason"], "test reason");
480    }
481
482    #[test]
483    fn test_hook_output_pre_tool_use_deny_format() {
484        let output = ClaudePreToolUseOutput::pre_tool_use_deny("not allowed".into());
485        let json = serde_json::to_value(&output).unwrap();
486
487        assert_eq!(json["continue"], true); // deny still continues, just blocks this tool
488        let specific = &json["hookSpecificOutput"];
489        assert_eq!(specific["hookEventName"], "PreToolUse");
490        assert_eq!(specific["permissionDecision"], "deny");
491        assert_eq!(specific["permissionDecisionReason"], "not allowed");
492    }
493
494    #[test]
495    fn test_hook_output_pre_tool_use_with_updated_input() {
496        let modified = serde_json::json!({"file_path": "/safe/path.txt"});
497        let output = ClaudePreToolUseOutput::pre_tool_use_allow(None, Some(modified.clone()));
498        let json = serde_json::to_value(&output).unwrap();
499
500        let specific = &json["hookSpecificOutput"];
501        assert_eq!(specific["updatedInput"], modified);
502    }
503
504    #[test]
505    fn test_hook_output_block_format() {
506        let output = ClaudePreToolUseOutput::block("session terminated".into());
507        let json = serde_json::to_value(&output).unwrap();
508
509        assert_eq!(json["continue"], false);
510        assert_eq!(json["stopReason"], "session terminated");
511    }
512
513    #[test]
514    fn test_hook_output_post_tool_use_format() {
515        let output = ClaudePreToolUseOutput::post_tool_use_allow(Some("additional context".into()));
516        let json = serde_json::to_value(&output).unwrap();
517
518        assert_eq!(json["continue"], true);
519        let specific = &json["hookSpecificOutput"];
520        assert_eq!(specific["hookEventName"], "PostToolUse");
521        assert_eq!(specific["additionalContext"], "additional context");
522    }
523
524    #[test]
525    fn test_hook_output_default() {
526        let output = ClaudePreToolUseOutput::default();
527        let json = serde_json::to_value(&output).unwrap();
528
529        // Default should have continue=true and no hook_specific_output
530        assert_eq!(json["continue"], true);
531    }
532
533    // =========================================================================
534    // HookInput comprehensive parsing tests
535    // =========================================================================
536
537    #[test]
538    fn test_hook_input_minimal() {
539        let json = r#"{"session_id":"s","hook_event_name":"Stop"}"#;
540        let input: HookInput = serde_json::from_str(json).unwrap();
541        assert_eq!(input.session_id.as_str(), "s");
542        assert_eq!(input.hook_event_name, "Stop");
543        assert!(input.tool_name.is_none());
544    }
545
546    #[test]
547    fn test_hook_input_with_tool_parameters_alias() {
548        // Gemini CLI uses tool_parameters instead of tool_input
549        let json = r#"{"session_id":"s","hook_event_name":"PreToolUse","tool_parameters":{"key":"value"}}"#;
550        let input: HookInput = serde_json::from_str(json).unwrap();
551        assert!(input.tool_input.is_some());
552        assert_eq!(input.tool_input.unwrap()["key"], "value");
553    }
554
555    #[test]
556    fn test_hook_input_extra_fields_ignored() {
557        let json = r#"{"session_id":"s","hook_event_name":"Stop","unknown_field":"ignored","another":123}"#;
558        let result: Result<HookInput, _> = serde_json::from_str(json);
559        assert!(result.is_ok());
560    }
561
562    #[test]
563    fn test_hook_input_all_fields() {
564        let json = r#"{
565            "session_id": "sess-123",
566            "transcript_path": "/tmp/t.jsonl",
567            "cwd": "/home/user",
568            "permission_mode": "plan",
569            "hook_event_name": "PreToolUse",
570            "tool_name": "Write",
571            "tool_input": {"file_path": "/x"},
572            "tool_use_id": "toolu_abc",
573            "prompt": "user prompt",
574            "message": "notification",
575            "stop_hook_active": true
576        }"#;
577        let input: HookInput = serde_json::from_str(json).unwrap();
578        assert_eq!(input.session_id.as_str(), "sess-123");
579        assert_eq!(input.cwd, "/home/user");
580        assert_eq!(input.permission_mode, "plan");
581        assert_eq!(input.tool_name.as_ref().map(|t| t.as_str()), Some("Write"));
582        assert_eq!(input.prompt, Some("user prompt".into()));
583        assert_eq!(input.stop_hook_active, Some(true));
584    }
585}