1use crate::domain::{SessionId, ToolName, ToolPermission};
6use crate::protocol::Runtime;
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
20pub struct HookInput {
21 pub session_id: SessionId,
23
24 #[serde(default)]
26 pub transcript_path: String,
27
28 #[serde(default)]
30 pub cwd: String,
31
32 #[serde(default)]
34 pub permission_mode: String,
35
36 pub hook_event_name: String,
38
39 #[serde(skip_serializing_if = "Option::is_none")]
41 pub runtime: Option<Runtime>,
42
43 #[serde(skip_serializing_if = "Option::is_none")]
46 pub tool_name: Option<ToolName>,
47
48 #[serde(alias = "tool_parameters")]
50 #[serde(skip_serializing_if = "Option::is_none")]
51 pub tool_input: Option<Value>,
52
53 #[serde(skip_serializing_if = "Option::is_none")]
55 pub tool_use_id: Option<String>,
56
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub tool_response: Option<Value>,
60
61 #[serde(skip_serializing_if = "Option::is_none")]
64 pub prompt: Option<String>,
65
66 #[serde(skip_serializing_if = "Option::is_none")]
68 pub message: Option<String>,
69
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub notification_type: Option<String>,
73
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub stop_hook_active: Option<bool>,
77
78 #[serde(skip_serializing_if = "Option::is_none")]
80 pub trigger: Option<String>,
81
82 #[serde(skip_serializing_if = "Option::is_none")]
84 pub custom_instructions: Option<String>,
85
86 #[serde(skip_serializing_if = "Option::is_none")]
88 pub source: Option<String>,
89
90 #[serde(skip_serializing_if = "Option::is_none")]
92 pub reason: Option<String>,
93
94 #[serde(skip_serializing_if = "Option::is_none")]
97 pub prompt_response: Option<String>,
98
99 #[serde(skip_serializing_if = "Option::is_none")]
102 pub timestamp: Option<String>,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
111pub struct ClaudePreToolUseOutput {
112 #[serde(rename = "continue", default = "default_true")]
114 pub continue_: bool,
115
116 #[serde(skip_serializing_if = "Option::is_none", rename = "stopReason")]
118 pub stop_reason: Option<String>,
119
120 #[serde(
122 skip_serializing_if = "Option::is_none",
123 rename = "suppressOutput",
124 default
125 )]
126 pub suppress_output: Option<bool>,
127
128 #[serde(skip_serializing_if = "Option::is_none", rename = "systemMessage")]
130 pub system_message: Option<String>,
131
132 #[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, 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
157#[serde(tag = "hookEventName")]
158pub enum HookSpecificOutput {
159 PreToolUse {
161 #[serde(rename = "permissionDecision")]
163 permission_decision: ToolPermission,
164
165 #[serde(
167 skip_serializing_if = "Option::is_none",
168 rename = "permissionDecisionReason"
169 )]
170 permission_decision_reason: Option<String>,
171
172 #[serde(skip_serializing_if = "Option::is_none", rename = "updatedInput")]
174 updated_input: Option<Value>,
175 },
176
177 PostToolUse {
179 #[serde(skip_serializing_if = "Option::is_none", rename = "additionalContext")]
181 additional_context: Option<String>,
182 },
183
184 UserPromptSubmit {
186 #[serde(skip_serializing_if = "Option::is_none", rename = "additionalContext")]
188 additional_context: Option<String>,
189 },
190
191 SessionStart {
193 #[serde(skip_serializing_if = "Option::is_none", rename = "additionalContext")]
195 additional_context: Option<String>,
196 },
197
198 PermissionRequest {
200 decision: PermissionDecision,
202 },
203
204 Stop {
206 #[serde(skip_serializing_if = "Option::is_none")]
208 decision: Option<String>,
209
210 #[serde(skip_serializing_if = "Option::is_none")]
212 reason: Option<String>,
213 },
214
215 SubagentStop {
217 #[serde(skip_serializing_if = "Option::is_none")]
219 decision: Option<String>,
220
221 #[serde(skip_serializing_if = "Option::is_none")]
223 reason: Option<String>,
224 },
225
226 Notification,
228
229 PreCompact,
231
232 SessionEnd,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
238#[serde(tag = "behavior")]
239pub enum PermissionDecision {
240 #[serde(rename = "allow")]
242 Allow {
243 #[serde(skip_serializing_if = "Option::is_none", rename = "updatedInput")]
245 updated_input: Option<Value>,
246 },
247
248 #[serde(rename = "deny")]
250 Deny {
251 message: String,
253
254 #[serde(default)]
256 interrupt: bool,
257 },
258}
259
260impl ClaudePreToolUseOutput {
265 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 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 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 pub fn block(reason: String) -> Self {
302 Self {
303 continue_: false,
304 stop_reason: Some(reason),
305 ..Default::default()
306 }
307 }
308}
309
310#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
316#[serde(rename_all = "lowercase")]
317pub enum StopDecision {
318 Allow,
320 Block,
322}
323
324#[derive(Debug, Clone, Deserialize)]
327pub struct InternalStopHookOutput {
328 pub decision: StopDecision,
330 pub reason: Option<String>,
332}
333
334impl InternalStopHookOutput {
335 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 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, reason: self.reason.clone(),
364 continue_: true,
365 clear_context: None,
366 system_message: None,
367 suppress_output: None,
368 },
369 }
370 }
371
372 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#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
385pub struct ClaudeStopHookOutput {
386 #[serde(rename = "continue")]
388 pub continue_: bool,
389 #[serde(skip_serializing_if = "Option::is_none", rename = "stopReason")]
391 pub stop_reason: Option<String>,
392}
393
394#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
397#[serde(rename_all = "lowercase")]
398pub enum GeminiStopDecision {
399 Allow,
401 Deny,
403}
404
405#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
408pub struct GeminiStopHookOutput {
409 pub decision: GeminiStopDecision,
411 #[serde(skip_serializing_if = "Option::is_none")]
413 pub reason: Option<String>,
414 #[serde(rename = "continue", default = "default_true")]
416 pub continue_: bool,
417 #[serde(skip_serializing_if = "Option::is_none", rename = "clearContext")]
419 pub clear_context: Option<bool>,
420 #[serde(skip_serializing_if = "Option::is_none", rename = "systemMessage")]
422 pub system_message: Option<String>,
423 #[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 #[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); 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 assert_eq!(json["continue"], true);
531 }
532
533 #[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 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}