1use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14use crate::extensions::permissions::Permission;
15
16pub const MAX_HOOK_OUTPUT: usize = 256 * 1024; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum HookKind {
33 BeforeToolCall,
35 AfterToolCall,
37 BeforeMessage,
39 OnMessageComplete,
41 OnCompaction,
43 OnSessionStart,
45 OnSessionEnd,
47}
48
49impl HookKind {
50 pub fn as_str(&self) -> &'static str {
53 match self {
54 Self::BeforeToolCall => "before_tool_call",
55 Self::AfterToolCall => "after_tool_call",
56 Self::BeforeMessage => "before_message",
57 Self::OnMessageComplete => "on_message_complete",
58 Self::OnCompaction => "on_compaction",
59 Self::OnSessionStart => "on_session_start",
60 Self::OnSessionEnd => "on_session_end",
61 }
62 }
63
64 #[allow(clippy::should_implement_trait)]
69 pub fn from_str(s: &str) -> Option<Self> {
70 match s {
71 "before_tool_call" => Some(Self::BeforeToolCall),
72 "after_tool_call" => Some(Self::AfterToolCall),
73 "before_message" => Some(Self::BeforeMessage),
74 "on_message_complete" => Some(Self::OnMessageComplete),
75 "on_compaction" => Some(Self::OnCompaction),
76 "on_session_start" => Some(Self::OnSessionStart),
77 "on_session_end" => Some(Self::OnSessionEnd),
78 _ => None,
79 }
80 }
81
82 pub fn allowed_action_names(&self) -> &'static [&'static str] {
84 match self {
85 Self::BeforeToolCall => &["continue", "block", "confirm", "modify"],
86 Self::AfterToolCall => &["continue", "replace"],
87 Self::BeforeMessage => &["continue", "inject"],
88 Self::OnMessageComplete
89 | Self::OnCompaction
90 | Self::OnSessionStart
91 | Self::OnSessionEnd => &["continue"],
92 }
93 }
94
95 pub fn allows_tool_filter(&self) -> bool {
97 matches!(self, Self::BeforeToolCall | Self::AfterToolCall)
98 }
99
100 pub fn allows_result(&self, result: &HookResult) -> bool {
102 matches!(
103 (self, result),
104 (_, HookResult::Continue)
105 | (Self::BeforeToolCall, HookResult::Block { .. })
106 | (Self::BeforeToolCall, HookResult::Confirm { .. })
107 | (Self::BeforeToolCall, HookResult::Modify { .. })
108 | (Self::AfterToolCall, HookResult::Replace { .. })
109 | (Self::BeforeMessage, HookResult::Inject { .. })
110 )
111 }
112
113 pub fn required_permission(&self) -> Permission {
119 match self {
120 Self::BeforeToolCall | Self::AfterToolCall => Permission::ToolsIntercept,
121 Self::BeforeMessage | Self::OnMessageComplete | Self::OnCompaction => {
122 Permission::LlmContent
123 }
124 Self::OnSessionStart | Self::OnSessionEnd => Permission::SessionLifecycle,
125 }
126 }
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct HookEvent {
149 pub kind: HookKind,
151 pub tool_name: Option<String>,
154 #[serde(default)]
157 pub tool_runtime_name: Option<String>,
158 pub tool_input: Option<Value>,
160 pub tool_output: Option<String>,
162 pub message: Option<String>,
164 pub session_id: Option<String>,
166 #[serde(default)]
170 pub transcript: Option<Vec<crate::SharedMessage>>,
171 pub data: Value,
173}
174
175impl HookEvent {
176 pub fn before_tool_call(tool_name: &str, input: Value) -> Self {
178 Self {
179 kind: HookKind::BeforeToolCall,
180 tool_name: Some(tool_name.to_string()),
181 tool_input: Some(input),
182 tool_output: None,
183 message: None,
184 session_id: None,
185 tool_runtime_name: None,
186 transcript: None,
187 data: Value::Null,
188 }
189 }
190
191 pub fn after_tool_call(tool_name: &str, input: Value, output: String) -> Self {
199 let truncated_output = if output.len() > MAX_HOOK_OUTPUT {
200 let boundary = output
201 .char_indices()
202 .map(|(idx, _)| idx)
203 .take_while(|idx| *idx <= MAX_HOOK_OUTPUT)
204 .last()
205 .unwrap_or(0);
206 format!(
207 "{}…[truncated, {} total bytes]",
208 &output[..boundary],
209 output.len()
210 )
211 } else {
212 output
213 };
214 Self {
215 kind: HookKind::AfterToolCall,
216 tool_name: Some(tool_name.to_string()),
217 tool_input: Some(input),
218 tool_output: Some(truncated_output),
219 message: None,
220 session_id: None,
221 tool_runtime_name: None,
222 transcript: None,
223 data: Value::Null,
224 }
225 }
226
227 pub fn before_message(message: &str) -> Self {
229 Self {
230 kind: HookKind::BeforeMessage,
231 tool_name: None,
232 tool_input: None,
233 tool_output: None,
234 message: Some(message.to_string()),
235 session_id: None,
236 tool_runtime_name: None,
237 transcript: None,
238 data: Value::Null,
239 }
240 }
241
242 pub fn on_message_complete(message: &str, data: Value) -> Self {
244 Self {
245 kind: HookKind::OnMessageComplete,
246 tool_name: None,
247 tool_input: None,
248 tool_output: None,
249 message: Some(message.to_string()),
250 session_id: None,
251 tool_runtime_name: None,
252 transcript: None,
253 data,
254 }
255 }
256
257 pub fn on_compaction(
259 old_session_id: &str,
260 new_session_id: &str,
261 summary: &str,
262 message_count: usize,
263 mut data: Value,
264 ) -> Self {
265 if !data.is_object() {
266 data = Value::Object(Default::default());
267 }
268 if let Some(object) = data.as_object_mut() {
269 object.insert(
270 "old_session_id".to_string(),
271 Value::String(old_session_id.to_string()),
272 );
273 object.insert(
274 "new_session_id".to_string(),
275 Value::String(new_session_id.to_string()),
276 );
277 object.insert(
278 "message_count".to_string(),
279 Value::Number(message_count.into()),
280 );
281 }
282 Self {
283 kind: HookKind::OnCompaction,
284 tool_name: None,
285 tool_input: None,
286 tool_output: None,
287 message: Some(summary.to_string()),
288 session_id: Some(new_session_id.to_string()),
289 tool_runtime_name: None,
290 transcript: None,
291 data,
292 }
293 }
294
295 pub fn on_session_start(session_id: &str) -> Self {
297 Self {
298 kind: HookKind::OnSessionStart,
299 tool_name: None,
300 tool_input: None,
301 tool_output: None,
302 message: None,
303 session_id: Some(session_id.to_string()),
304 tool_runtime_name: None,
305 transcript: None,
306 data: Value::Null,
307 }
308 }
309
310 pub fn on_session_end(session_id: &str, transcript: Option<Vec<crate::SharedMessage>>) -> Self {
312 Self {
313 kind: HookKind::OnSessionEnd,
314 tool_name: None,
315 tool_input: None,
316 tool_output: None,
317 message: None,
318 session_id: Some(session_id.to_string()),
319 tool_runtime_name: None,
320 transcript,
321 data: Value::Null,
322 }
323 }
324}
325
326#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
335#[serde(tag = "action", rename_all = "snake_case")]
336pub enum HookResult {
337 #[default]
339 Continue,
340 Block { reason: String },
342 Inject { content: String },
345 Confirm { message: String },
348 Modify { input: Value },
352 Replace { output: String },
359}
360
361impl HookEvent {
364 pub fn with_runtime_name(mut self, name: &str) -> Self {
366 self.tool_runtime_name = Some(name.to_string());
367 self
368 }
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374 use serde_json::json;
375
376 #[test]
380 fn replace_action_roundtrips_extension_wire_format() {
381 let wire = json!({"action": "replace", "output": "compressed"});
383 let parsed: HookResult = serde_json::from_value(wire).expect("must deserialize");
384 match parsed {
385 HookResult::Replace { output } => assert_eq!(output, "compressed"),
386 other => panic!("expected Replace, got {other:?}"),
387 }
388
389 let out = serde_json::to_value(HookResult::Replace { output: "x".into() }).unwrap();
391 assert_eq!(out["action"], "replace");
392 assert_eq!(out["output"], "x");
393 }
394
395 #[test]
400 fn after_tool_call_allows_replace_output() {
401 let replace = HookResult::Replace {
402 output: "compressed".into(),
403 };
404
405 assert!(
407 HookKind::AfterToolCall.allows_result(&replace),
408 "after_tool_call must permit Replace to enable output transforms"
409 );
410
411 assert!(
413 !HookKind::BeforeToolCall.allows_result(&replace),
414 "before_tool_call must not permit Replace (input-only)"
415 );
416 assert!(
417 !HookKind::BeforeMessage.allows_result(&replace),
418 "before_message must not permit Replace"
419 );
420
421 assert!(
423 HookKind::AfterToolCall
424 .allowed_action_names()
425 .contains(&"replace"),
426 "after_tool_call must advertise the 'replace' action"
427 );
428 }
429
430 #[test]
432 fn hook_kind_as_str_roundtrip() {
433 let all = [
434 HookKind::BeforeToolCall,
435 HookKind::AfterToolCall,
436 HookKind::BeforeMessage,
437 HookKind::OnMessageComplete,
438 HookKind::OnCompaction,
439 HookKind::OnSessionStart,
440 HookKind::OnSessionEnd,
441 ];
442 for kind in all {
443 let s = kind.as_str();
444 assert_eq!(
445 HookKind::from_str(s),
446 Some(kind),
447 "round-trip failed for {s}"
448 );
449 }
450 }
451
452 #[test]
454 fn hook_kind_from_str_unknown_returns_none() {
455 assert_eq!(HookKind::from_str(""), None);
456 assert_eq!(HookKind::from_str("BeforeToolCall"), None); assert_eq!(HookKind::from_str("on_crash"), None);
458 }
459
460 #[test]
462 fn hook_kind_serde_snake_case() {
463 let serialized = serde_json::to_string(&HookKind::BeforeToolCall).unwrap();
464 assert_eq!(serialized, r#""before_tool_call""#);
465
466 let back: HookKind = serde_json::from_str(r#""on_session_end""#).unwrap();
467 assert_eq!(back, HookKind::OnSessionEnd);
468 }
469
470 #[test]
472 fn hook_kind_required_permission() {
473 assert_eq!(
474 HookKind::BeforeToolCall.required_permission(),
475 Permission::ToolsIntercept
476 );
477 assert_eq!(
478 HookKind::AfterToolCall.required_permission(),
479 Permission::ToolsIntercept
480 );
481 assert_eq!(
482 HookKind::BeforeMessage.required_permission(),
483 Permission::LlmContent
484 );
485 assert_eq!(
486 HookKind::OnMessageComplete.required_permission(),
487 Permission::LlmContent
488 );
489 assert_eq!(
490 HookKind::OnCompaction.required_permission(),
491 Permission::LlmContent
492 );
493 assert_eq!(
494 HookKind::OnSessionStart.required_permission(),
495 Permission::SessionLifecycle
496 );
497 assert_eq!(
498 HookKind::OnSessionEnd.required_permission(),
499 Permission::SessionLifecycle
500 );
501 }
502
503 #[test]
506 fn hook_event_before_tool_call() {
507 let input = json!({"path": "/tmp/foo"});
508 let ev = HookEvent::before_tool_call("read_file", input.clone());
509
510 assert_eq!(ev.kind, HookKind::BeforeToolCall);
511 assert_eq!(ev.tool_name.as_deref(), Some("read_file"));
512 assert_eq!(ev.tool_input.as_ref(), Some(&input));
513 assert!(ev.tool_output.is_none());
514 assert!(ev.message.is_none());
515 assert!(ev.session_id.is_none());
516 assert_eq!(ev.data, Value::Null);
517 }
518
519 #[test]
520 fn hook_event_after_tool_call() {
521 let input = json!({"query": "select 1"});
522 let ev = HookEvent::after_tool_call("sql_query", input.clone(), "1 row".to_string());
523
524 assert_eq!(ev.kind, HookKind::AfterToolCall);
525 assert_eq!(ev.tool_name.as_deref(), Some("sql_query"));
526 assert_eq!(ev.tool_input.as_ref(), Some(&input));
527 assert_eq!(ev.tool_output.as_deref(), Some("1 row"));
528 assert!(ev.message.is_none());
529 assert!(ev.session_id.is_none());
530 }
531
532 #[test]
533 fn hook_event_before_message() {
534 let ev = HookEvent::before_message("Hello, LLM");
535
536 assert_eq!(ev.kind, HookKind::BeforeMessage);
537 assert!(ev.tool_name.is_none());
538 assert!(ev.tool_input.is_none());
539 assert!(ev.tool_output.is_none());
540 assert_eq!(ev.message.as_deref(), Some("Hello, LLM"));
541 assert!(ev.session_id.is_none());
542 }
543
544 #[test]
545 fn hook_event_on_message_complete() {
546 let ev = HookEvent::on_message_complete("Done", json!({"content_block_count": 1}));
547
548 assert_eq!(ev.kind, HookKind::OnMessageComplete);
549 assert!(ev.tool_name.is_none());
550 assert!(ev.tool_input.is_none());
551 assert!(ev.tool_output.is_none());
552 assert_eq!(ev.message.as_deref(), Some("Done"));
553 assert_eq!(ev.data["content_block_count"], 1);
554 assert!(ev.session_id.is_none());
555 }
556
557 #[test]
558 fn hook_event_on_compaction() {
559 let ev = HookEvent::on_compaction(
560 "old-session",
561 "new-session",
562 "Summary",
563 7,
564 json!({"source": "manual"}),
565 );
566
567 assert_eq!(ev.kind, HookKind::OnCompaction);
568 assert_eq!(ev.message.as_deref(), Some("Summary"));
569 assert_eq!(ev.session_id.as_deref(), Some("new-session"));
570 assert_eq!(ev.data["old_session_id"], "old-session");
571 assert_eq!(ev.data["new_session_id"], "new-session");
572 assert_eq!(ev.data["message_count"], 7);
573 assert_eq!(ev.data["source"], "manual");
574 assert!(ev.transcript.is_none());
575 }
576
577 #[test]
578 fn hook_event_on_session_start() {
579 let ev = HookEvent::on_session_start("sess-abc-123");
580
581 assert_eq!(ev.kind, HookKind::OnSessionStart);
582 assert_eq!(ev.session_id.as_deref(), Some("sess-abc-123"));
583 assert!(ev.tool_name.is_none());
584 assert!(ev.message.is_none());
585 }
586
587 #[test]
588 fn hook_event_on_session_end() {
589 let ev = HookEvent::on_session_end("sess-abc-123", None);
590
591 assert_eq!(ev.kind, HookKind::OnSessionEnd);
592 assert_eq!(ev.session_id.as_deref(), Some("sess-abc-123"));
593 assert!(ev.tool_name.is_none());
594 assert!(ev.message.is_none());
595 }
596
597 #[test]
599 fn hook_event_serde_roundtrip() {
600 let ev = HookEvent::before_tool_call("bash", json!({"cmd": "ls"}));
601 let json = serde_json::to_string(&ev).unwrap();
602 let back: HookEvent = serde_json::from_str(&json).unwrap();
603
604 assert_eq!(back.kind, ev.kind);
605 assert_eq!(back.tool_name, ev.tool_name);
606 assert_eq!(back.tool_input, ev.tool_input);
607 }
608
609 #[test]
613 fn hook_result_default_is_continue() {
614 assert!(matches!(HookResult::default(), HookResult::Continue));
615 }
616
617 #[test]
619 fn hook_result_block_serde() {
620 let r = HookResult::Block {
621 reason: "denied by policy".to_string(),
622 };
623 let json = serde_json::to_string(&r).unwrap();
624 assert!(json.contains(r#""action":"block""#));
626 assert!(json.contains("denied by policy"));
627
628 let back: HookResult = serde_json::from_str(&json).unwrap();
629 assert!(matches!(back, HookResult::Block { reason } if reason == "denied by policy"));
630 }
631
632 #[test]
634 fn hook_result_confirm_serde() {
635 let r = HookResult::Confirm {
636 message: "Run this command?".to_string(),
637 };
638 let json = serde_json::to_string(&r).unwrap();
639 assert_eq!(
640 json,
641 r#"{"action":"confirm","message":"Run this command?"}"#
642 );
643
644 let back: HookResult = serde_json::from_str(&json).unwrap();
645 assert!(matches!(back, HookResult::Confirm { message } if message == "Run this command?"));
646 }
647
648 #[test]
650 fn hook_result_modify_serde() {
651 let r = HookResult::Modify {
652 input: json!({"command": "echo safe"}),
653 };
654 let json = serde_json::to_string(&r).unwrap();
655 assert_eq!(
656 json,
657 r#"{"action":"modify","input":{"command":"echo safe"}}"#
658 );
659
660 let back: HookResult = serde_json::from_str(&json).unwrap();
661 assert!(
662 matches!(back, HookResult::Modify { input } if input == json!({"command": "echo safe"}))
663 );
664 }
665
666 #[test]
668 fn hook_result_continue_serde() {
669 let json = serde_json::to_string(&HookResult::Continue).unwrap();
670 assert_eq!(json, r#"{"action":"continue"}"#);
671 }
672}