1use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::pin::Pin;
8use std::process::Command;
9use tokio::time::{Duration, timeout};
10
11pub const HOOK_EVENTS: &[&str] = &[
13 "PreToolUse",
14 "PostToolUse",
15 "PostToolUseFailure",
16 "Notification",
17 "UserPromptSubmit",
18 "SessionStart",
19 "SessionEnd",
20 "Stop",
21 "StopFailure",
22 "SubagentStart",
23 "SubagentStop",
24 "PreCompact",
25 "PostCompact",
26 "PermissionRequest",
27 "PermissionDenied",
28 "Setup",
29 "TeammateIdle",
30 "TaskCreated",
31 "TaskCompleted",
32 "Elicitation",
33 "ElicitationResult",
34 "ConfigChange",
35 "WorktreeCreate",
36 "WorktreeRemove",
37 "InstructionsLoaded",
38 "CwdChanged",
39 "FileChanged",
40];
41
42pub const EXIT_REASONS: &[&str] = &[
44 "clear",
45 "resume",
46 "logout",
47 "prompt_input_exit",
48 "other",
49 "bypass_permissions_disabled",
50];
51
52pub const INSTRUCTIONS_LOAD_REASONS: &[&str] = &[
54 "session_start",
55 "nested_traversal",
56 "path_glob_match",
57 "include",
58 "compact",
59];
60
61pub const INSTRUCTIONS_MEMORY_TYPES: &[&str] = &["User", "Project", "Local", "Managed"];
63
64pub const CONFIG_CHANGE_SOURCES: &[&str] = &[
66 "user_settings",
67 "project_settings",
68 "local_settings",
69 "policy_settings",
70 "skills",
71];
72
73pub const DEFAULT_SHELL_TIMEOUT_MS: u64 = 30_000;
75pub const DEFAULT_AGENT_TIMEOUT_S: u64 = 60;
76pub const DEFAULT_HTTP_TIMEOUT_MS: u64 = 600_000;
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(rename_all = "camelCase")]
81pub enum HookEvent {
82 PreToolUse,
83 PostToolUse,
84 PostToolUseFailure,
85 Notification,
86 UserPromptSubmit,
87 SessionStart,
88 SessionEnd,
89 Stop,
90 StopFailure,
91 SubagentStart,
92 SubagentStop,
93 PreCompact,
94 PostCompact,
95 PermissionRequest,
96 PermissionDenied,
97 Setup,
98 TeammateIdle,
99 TaskCreated,
100 TaskCompleted,
101 Elicitation,
102 ElicitationResult,
103 ConfigChange,
104 WorktreeCreate,
105 WorktreeRemove,
106 InstructionsLoaded,
107 CwdChanged,
108 FileChanged,
109}
110
111impl HookEvent {
112 pub fn as_str(&self) -> &'static str {
113 match self {
114 HookEvent::PreToolUse => "PreToolUse",
115 HookEvent::PostToolUse => "PostToolUse",
116 HookEvent::PostToolUseFailure => "PostToolUseFailure",
117 HookEvent::Notification => "Notification",
118 HookEvent::UserPromptSubmit => "UserPromptSubmit",
119 HookEvent::SessionStart => "SessionStart",
120 HookEvent::SessionEnd => "SessionEnd",
121 HookEvent::Stop => "Stop",
122 HookEvent::StopFailure => "StopFailure",
123 HookEvent::SubagentStart => "SubagentStart",
124 HookEvent::SubagentStop => "SubagentStop",
125 HookEvent::PreCompact => "PreCompact",
126 HookEvent::PostCompact => "PostCompact",
127 HookEvent::PermissionRequest => "PermissionRequest",
128 HookEvent::PermissionDenied => "PermissionDenied",
129 HookEvent::Setup => "Setup",
130 HookEvent::TeammateIdle => "TeammateIdle",
131 HookEvent::TaskCreated => "TaskCreated",
132 HookEvent::TaskCompleted => "TaskCompleted",
133 HookEvent::Elicitation => "Elicitation",
134 HookEvent::ElicitationResult => "ElicitationResult",
135 HookEvent::ConfigChange => "ConfigChange",
136 HookEvent::WorktreeCreate => "WorktreeCreate",
137 HookEvent::WorktreeRemove => "WorktreeRemove",
138 HookEvent::InstructionsLoaded => "InstructionsLoaded",
139 HookEvent::CwdChanged => "CwdChanged",
140 HookEvent::FileChanged => "FileChanged",
141 }
142 }
143
144 pub fn from_str(s: &str) -> Option<Self> {
145 match s {
146 "PreToolUse" => Some(HookEvent::PreToolUse),
147 "PostToolUse" => Some(HookEvent::PostToolUse),
148 "PostToolUseFailure" => Some(HookEvent::PostToolUseFailure),
149 "Notification" => Some(HookEvent::Notification),
150 "UserPromptSubmit" => Some(HookEvent::UserPromptSubmit),
151 "SessionStart" => Some(HookEvent::SessionStart),
152 "SessionEnd" => Some(HookEvent::SessionEnd),
153 "Stop" => Some(HookEvent::Stop),
154 "StopFailure" => Some(HookEvent::StopFailure),
155 "SubagentStart" => Some(HookEvent::SubagentStart),
156 "SubagentStop" => Some(HookEvent::SubagentStop),
157 "PreCompact" => Some(HookEvent::PreCompact),
158 "PostCompact" => Some(HookEvent::PostCompact),
159 "PermissionRequest" => Some(HookEvent::PermissionRequest),
160 "PermissionDenied" => Some(HookEvent::PermissionDenied),
161 "Setup" => Some(HookEvent::Setup),
162 "TeammateIdle" => Some(HookEvent::TeammateIdle),
163 "TaskCreated" => Some(HookEvent::TaskCreated),
164 "TaskCompleted" => Some(HookEvent::TaskCompleted),
165 "Elicitation" => Some(HookEvent::Elicitation),
166 "ElicitationResult" => Some(HookEvent::ElicitationResult),
167 "ConfigChange" => Some(HookEvent::ConfigChange),
168 "WorktreeCreate" => Some(HookEvent::WorktreeCreate),
169 "WorktreeRemove" => Some(HookEvent::WorktreeRemove),
170 "InstructionsLoaded" => Some(HookEvent::InstructionsLoaded),
171 "CwdChanged" => Some(HookEvent::CwdChanged),
172 "FileChanged" => Some(HookEvent::FileChanged),
173 _ => None,
174 }
175 }
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(rename_all = "lowercase")]
181pub enum HookShell {
182 Bash,
183 PowerShell,
184}
185
186impl Default for HookShell {
187 fn default() -> Self {
188 HookShell::Bash
189 }
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194#[serde(rename_all = "lowercase")]
195pub enum HookType {
196 Command,
197 Prompt,
198 Agent,
199 Http,
200}
201
202impl Default for HookType {
203 fn default() -> Self {
204 HookType::Command
205 }
206}
207
208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
210#[serde(rename_all = "camelCase")]
211pub enum PermissionBehavior {
212 Ask,
213 Deny,
214 Allow,
215 Passthrough,
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
220#[serde(rename_all = "camelCase", tag = "hookEventName")]
221pub enum HookSpecificOutput {
222 #[serde(rename = "PreToolUse")]
223 PreToolUse {
224 #[serde(skip_serializing_if = "Option::is_none")]
225 permission_decision: Option<PermissionBehavior>,
226 #[serde(skip_serializing_if = "Option::is_none")]
227 permission_decision_reason: Option<String>,
228 #[serde(skip_serializing_if = "Option::is_none")]
229 updated_input: Option<serde_json::Value>,
230 #[serde(skip_serializing_if = "Option::is_none")]
231 additional_context: Option<String>,
232 },
233 #[serde(rename = "UserPromptSubmit")]
234 UserPromptSubmit {
235 #[serde(skip_serializing_if = "Option::is_none")]
236 additional_context: Option<String>,
237 },
238 #[serde(rename = "SessionStart")]
239 SessionStart {
240 #[serde(skip_serializing_if = "Option::is_none")]
241 additional_context: Option<String>,
242 #[serde(skip_serializing_if = "Option::is_none")]
243 initial_user_message: Option<String>,
244 #[serde(skip_serializing_if = "Option::is_none")]
245 watch_paths: Option<Vec<String>>,
246 },
247 #[serde(rename = "Setup")]
248 Setup {
249 #[serde(skip_serializing_if = "Option::is_none")]
250 additional_context: Option<String>,
251 },
252 #[serde(rename = "SubagentStart")]
253 SubagentStart {
254 #[serde(skip_serializing_if = "Option::is_none")]
255 additional_context: Option<String>,
256 },
257 #[serde(rename = "PostToolUse")]
258 PostToolUse {
259 #[serde(skip_serializing_if = "Option::is_none")]
260 additional_context: Option<String>,
261 #[serde(skip_serializing_if = "Option::is_none")]
262 updated_mcp_tool_output: Option<serde_json::Value>,
263 },
264 #[serde(rename = "PostToolUseFailure")]
265 PostToolUseFailure {
266 #[serde(skip_serializing_if = "Option::is_none")]
267 additional_context: Option<String>,
268 },
269 #[serde(rename = "PermissionDenied")]
270 PermissionDenied {
271 #[serde(skip_serializing_if = "Option::is_none")]
272 retry: Option<bool>,
273 },
274 #[serde(rename = "Notification")]
275 Notification {
276 #[serde(skip_serializing_if = "Option::is_none")]
277 additional_context: Option<String>,
278 },
279 #[serde(rename = "PermissionRequest")]
280 PermissionRequest {
281 #[serde(skip_serializing_if = "Option::is_none")]
282 decision: Option<PermissionRequestDecision>,
283 },
284 #[serde(rename = "Elicitation")]
285 Elicitation {
286 #[serde(skip_serializing_if = "Option::is_none")]
287 action: Option<String>,
288 #[serde(skip_serializing_if = "Option::is_none")]
289 content: Option<serde_json::Value>,
290 },
291 #[serde(rename = "ElicitationResult")]
292 ElicitationResult {
293 #[serde(skip_serializing_if = "Option::is_none")]
294 action: Option<String>,
295 #[serde(skip_serializing_if = "Option::is_none")]
296 content: Option<serde_json::Value>,
297 },
298 #[serde(rename = "CwdChanged")]
299 CwdChanged {
300 #[serde(skip_serializing_if = "Option::is_none")]
301 watch_paths: Option<Vec<String>>,
302 },
303 #[serde(rename = "FileChanged")]
304 FileChanged {
305 #[serde(skip_serializing_if = "Option::is_none")]
306 watch_paths: Option<Vec<String>>,
307 },
308 #[serde(rename = "WorktreeCreate")]
309 WorktreeCreate {
310 worktree_path: String,
311 },
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize)]
316#[serde(rename_all = "camelCase", untagged)]
317pub enum PermissionRequestDecision {
318 Allow {
319 behavior: String,
320 #[serde(skip_serializing_if = "Option::is_none")]
321 updated_input: Option<serde_json::Value>,
322 #[serde(skip_serializing_if = "Option::is_none")]
323 updated_permissions: Option<Vec<PermissionUpdate>>,
324 },
325 Deny {
326 behavior: String,
327 #[serde(skip_serializing_if = "Option::is_none")]
328 message: Option<String>,
329 #[serde(skip_serializing_if = "Option::is_none")]
330 interrupt: Option<bool>,
331 },
332}
333
334#[derive(Debug, Clone, Serialize, Deserialize)]
336#[serde(rename_all = "camelCase")]
337pub struct HookOutput {
338 #[serde(skip_serializing_if = "Option::is_none")]
339 pub continue_execution: Option<bool>,
340 #[serde(skip_serializing_if = "Option::is_none")]
341 pub suppress_output: Option<bool>,
342 #[serde(skip_serializing_if = "Option::is_none")]
343 pub stop_reason: Option<String>,
344 #[serde(skip_serializing_if = "Option::is_none")]
345 pub decision: Option<String>,
346 #[serde(skip_serializing_if = "Option::is_none")]
347 pub reason: Option<String>,
348 #[serde(skip_serializing_if = "Option::is_none")]
349 pub system_message: Option<String>,
350 #[serde(skip_serializing_if = "Option::is_none")]
351 pub hook_specific_output: Option<HookSpecificOutput>,
352 #[serde(skip_serializing_if = "Option::is_none")]
354 pub message: Option<String>,
355 #[serde(skip_serializing_if = "Option::is_none")]
356 pub permission_update: Option<PermissionUpdate>,
357 #[serde(skip_serializing_if = "Option::is_none")]
358 pub block: Option<bool>,
359 #[serde(skip_serializing_if = "Option::is_none")]
360 pub notification: Option<Notification>,
361}
362
363#[derive(Debug, Clone, Serialize, Deserialize)]
365#[serde(rename_all = "camelCase")]
366pub struct AsyncHookOutput {
367 #[serde(rename = "async")]
368 pub async_run: bool,
369 #[serde(skip_serializing_if = "Option::is_none")]
370 pub async_timeout: Option<u64>,
371}
372
373#[derive(Debug, Clone, Serialize, Deserialize)]
376#[serde(rename_all = "camelCase", tag = "type")]
377pub enum HookCommand {
378 #[serde(rename = "command")]
379 Command(CommandHookParams),
380 #[serde(rename = "prompt")]
381 Prompt(PromptHookParams),
382 #[serde(rename = "agent")]
383 Agent(AgentHookParams),
384 #[serde(rename = "http")]
385 Http(HttpHookParams),
386}
387
388#[derive(Debug, Clone, Serialize, Deserialize)]
390#[serde(rename_all = "camelCase")]
391pub struct CommandHookParams {
392 pub command: String,
393 #[serde(skip_serializing_if = "Option::is_none")]
394 pub r#if: Option<String>,
395 #[serde(skip_serializing_if = "Option::is_none")]
396 pub shell: Option<HookShell>,
397 #[serde(skip_serializing_if = "Option::is_none")]
398 pub timeout: Option<u64>,
399 #[serde(skip_serializing_if = "Option::is_none")]
400 pub status_message: Option<String>,
401 #[serde(skip_serializing_if = "Option::is_none")]
402 pub once: Option<bool>,
403 #[serde(skip_serializing_if = "Option::is_none")]
404 #[serde(default)]
405 pub async_run: Option<bool>,
406 #[serde(skip_serializing_if = "Option::is_none")]
407 #[serde(default)]
408 pub async_rewake: Option<bool>,
409}
410
411#[derive(Debug, Clone, Serialize, Deserialize)]
413#[serde(rename_all = "camelCase")]
414pub struct PromptHookParams {
415 pub prompt: String,
416 #[serde(skip_serializing_if = "Option::is_none")]
417 pub r#if: Option<String>,
418 #[serde(skip_serializing_if = "Option::is_none")]
419 pub timeout: Option<u64>,
420 #[serde(skip_serializing_if = "Option::is_none")]
421 pub model: Option<String>,
422 #[serde(skip_serializing_if = "Option::is_none")]
423 pub status_message: Option<String>,
424 #[serde(skip_serializing_if = "Option::is_none")]
425 pub once: Option<bool>,
426}
427
428#[derive(Debug, Clone, Serialize, Deserialize)]
430#[serde(rename_all = "camelCase")]
431pub struct AgentHookParams {
432 pub prompt: String,
433 #[serde(skip_serializing_if = "Option::is_none")]
434 pub r#if: Option<String>,
435 #[serde(skip_serializing_if = "Option::is_none")]
436 pub timeout: Option<u64>,
437 #[serde(skip_serializing_if = "Option::is_none")]
438 pub model: Option<String>,
439 #[serde(skip_serializing_if = "Option::is_none")]
440 pub status_message: Option<String>,
441 #[serde(skip_serializing_if = "Option::is_none")]
442 pub once: Option<bool>,
443}
444
445#[derive(Debug, Clone, Serialize, Deserialize)]
447#[serde(rename_all = "camelCase")]
448pub struct HttpHookParams {
449 pub url: String,
450 #[serde(skip_serializing_if = "Option::is_none")]
451 pub r#if: Option<String>,
452 #[serde(skip_serializing_if = "Option::is_none")]
453 pub timeout: Option<u64>,
454 #[serde(skip_serializing_if = "Option::is_none")]
455 pub headers: Option<HashMap<String, String>>,
456 #[serde(skip_serializing_if = "Option::is_none")]
457 pub allowed_env_vars: Option<Vec<String>>,
458 #[serde(skip_serializing_if = "Option::is_none")]
459 pub status_message: Option<String>,
460 #[serde(skip_serializing_if = "Option::is_none")]
461 pub once: Option<bool>,
462}
463
464#[derive(Debug, Clone)]
467pub struct HookDefinition {
468 pub command: Option<String>,
470 pub timeout: Option<u64>,
472 pub matcher: Option<String>,
474}
475
476impl From<HookCommand> for HookDefinition {
477 fn from(cmd: HookCommand) -> Self {
478 match cmd {
479 HookCommand::Command(p) => HookDefinition {
480 command: Some(p.command),
481 timeout: p.timeout,
482 matcher: None,
483 },
484 HookCommand::Prompt(_) | HookCommand::Agent(_) | HookCommand::Http(_) => {
485 HookDefinition {
486 command: None,
487 timeout: None,
488 matcher: None,
489 }
490 }
491 }
492 }
493}
494
495impl<'de> Deserialize<'de> for HookDefinition {
496 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
497 where
498 D: serde::Deserializer<'de>,
499 {
500 #[derive(Deserialize)]
501 #[serde(rename_all = "camelCase")]
502 struct HookDef {
503 command: Option<String>,
504 timeout: Option<u64>,
505 matcher: Option<String>,
506 }
507
508 let def = HookDef::deserialize(deserializer)?;
509 Ok(HookDefinition {
510 command: def.command,
511 timeout: def.timeout.or(Some(DEFAULT_SHELL_TIMEOUT_MS)),
512 matcher: def.matcher,
513 })
514 }
515}
516
517#[derive(Debug, Clone, Serialize, Deserialize)]
519#[serde(rename_all = "camelCase")]
520pub struct HookMatcher {
521 #[serde(skip_serializing_if = "Option::is_none")]
522 pub matcher: Option<String>,
523 pub hooks: Vec<HookCommand>,
524}
525
526pub type HookConfig = HashMap<String, Vec<HookDefinition>>;
528pub type HookMatcherConfig = HashMap<String, Vec<HookMatcher>>;
529
530#[derive(Debug, Clone, Default, Serialize, Deserialize)]
532#[serde(rename_all = "camelCase")]
533pub struct HookInput {
534 pub event: String,
535 #[serde(skip_serializing_if = "Option::is_none")]
536 pub tool_name: Option<String>,
537 #[serde(skip_serializing_if = "Option::is_none")]
538 pub tool_input: Option<serde_json::Value>,
539 #[serde(skip_serializing_if = "Option::is_none")]
540 pub tool_output: Option<serde_json::Value>,
541 #[serde(skip_serializing_if = "Option::is_none")]
542 pub tool_use_id: Option<String>,
543 #[serde(skip_serializing_if = "Option::is_none")]
544 pub session_id: Option<String>,
545 #[serde(skip_serializing_if = "Option::is_none")]
546 pub cwd: Option<String>,
547 #[serde(skip_serializing_if = "Option::is_none")]
548 pub error: Option<String>,
549 #[serde(skip_serializing_if = "Option::is_none")]
551 pub source: Option<String>,
552 #[serde(skip_serializing_if = "Option::is_none")]
553 pub reason: Option<String>,
554 #[serde(skip_serializing_if = "Option::is_none")]
555 pub final_text: Option<String>,
556 #[serde(skip_serializing_if = "Option::is_none")]
557 pub agent_id: Option<String>,
558 #[serde(skip_serializing_if = "Option::is_none")]
559 pub agent_type: Option<String>,
560 #[serde(skip_serializing_if = "Option::is_none")]
561 pub trigger: Option<String>,
562 #[serde(skip_serializing_if = "Option::is_none")]
563 pub old_cwd: Option<String>,
564 #[serde(skip_serializing_if = "Option::is_none")]
565 pub file_path: Option<String>,
566 #[serde(skip_serializing_if = "Option::is_none")]
567 pub file_event: Option<String>,
568 #[serde(skip_serializing_if = "Option::is_none")]
569 pub mcp_server_name: Option<String>,
570 #[serde(skip_serializing_if = "Option::is_none")]
571 pub requested_schema: Option<serde_json::Value>,
572 #[serde(skip_serializing_if = "Option::is_none")]
573 pub config_source: Option<String>,
574}
575
576impl HookInput {
577 pub fn new(event: &str) -> Self {
578 Self {
579 event: event.to_string(),
580 tool_name: None,
581 tool_input: None,
582 tool_output: None,
583 tool_use_id: None,
584 session_id: None,
585 cwd: None,
586 error: None,
587 source: None,
588 reason: None,
589 final_text: None,
590 agent_id: None,
591 agent_type: None,
592 trigger: None,
593 old_cwd: None,
594 file_path: None,
595 file_event: None,
596 mcp_server_name: None,
597 requested_schema: None,
598 config_source: None,
599 }
600 }
601}
602
603#[derive(Debug, Clone, Serialize, Deserialize)]
605#[serde(rename_all = "camelCase")]
606pub struct PermissionUpdate {
607 pub tool: String,
608 pub behavior: String,
609}
610
611#[derive(Debug, Clone, Serialize, Deserialize)]
613#[serde(rename_all = "camelCase")]
614pub struct Notification {
615 pub title: String,
616 pub body: String,
617 #[serde(skip_serializing_if = "Option::is_none")]
618 pub level: Option<String>,
619}
620
621#[derive(Debug, Clone)]
623pub struct HookResult {
624 pub outcome: HookOutcome,
625 pub output: Option<HookOutput>,
626 pub blocking_error: Option<String>,
627 pub prevent_continuation: Option<bool>,
628 pub stop_reason: Option<String>,
629 pub additional_context: Option<String>,
630}
631
632#[derive(Debug, Clone, PartialEq, Eq)]
634pub enum HookOutcome {
635 Success,
636 Blocking,
637 NonBlockingError,
638 Cancelled,
639}
640
641#[derive(Debug, Default, Clone)]
643pub struct HookRegistry {
644 hooks: HashMap<String, Vec<HookDefinition>>,
646 typed_hooks: HashMap<String, Vec<HookMatcher>>,
648}
649
650impl HookRegistry {
651 pub fn new() -> Self {
653 Self {
654 hooks: HashMap::new(),
655 typed_hooks: HashMap::new(),
656 }
657 }
658
659 pub fn register_from_config(&mut self, config: HookConfig) {
661 for (event, definitions) in config {
662 if !HOOK_EVENTS.contains(&event.as_str()) {
663 continue;
664 }
665 let existing = self.hooks.entry(event).or_insert_with(Vec::new);
666 existing.extend(definitions);
667 }
668 }
669
670 pub fn register_from_matcher_config(&mut self, config: HookMatcherConfig) {
672 for (event, matchers) in config {
673 if !HOOK_EVENTS.contains(&event.as_str()) {
674 continue;
675 }
676 let existing = self.typed_hooks.entry(event).or_insert_with(Vec::new);
677 existing.extend(matchers);
678 }
679 }
680
681 pub fn register(&mut self, event: &str, definition: HookDefinition) {
683 if !HOOK_EVENTS.contains(&event) {
684 return;
685 }
686 let existing = self.hooks.entry(event.to_string()).or_insert_with(Vec::new);
687 existing.push(definition);
688 }
689
690 pub fn register_matcher(&mut self, event: &str, matcher: HookMatcher) {
692 if !HOOK_EVENTS.contains(&event) {
693 return;
694 }
695 let existing = self.typed_hooks.entry(event.to_string()).or_insert_with(Vec::new);
696 existing.push(matcher);
697 }
698
699 pub async fn execute(&self, event: &str, mut input: HookInput) -> Vec<HookOutput> {
701 input.event = event.to_string();
702
703 let mut futures: Vec<Pin<Box<dyn futures_util::Future<Output = Option<HookOutput>> + Send>>> =
705 Vec::new();
706
707 if let Some(definitions) = self.hooks.get(event) {
709 for def in definitions {
710 if let Some(matcher) = &def.matcher {
711 if let Some(tool_name) = &input.tool_name {
712 if let Ok(re) = regex::Regex::new(matcher) {
713 if !re.is_match(tool_name) {
714 continue;
715 }
716 }
717 }
718 }
719
720 if let Some(command) = &def.command {
721 let fut = execute_hook_def(def.clone(), &input);
722 futures.push(Box::pin(fut));
723 }
724 }
725 }
726
727 if let Some(matchers) = self.typed_hooks.get(event) {
729 for matcher in matchers {
730 if let Some(matcher_pattern) = &matcher.matcher {
731 if let Some(tool_name) = &input.tool_name {
732 if !tool_name.contains(matcher_pattern.as_str()) {
733 continue;
734 }
735 }
736 }
737
738 for hook_cmd in &matcher.hooks {
739 if let Some(cond) = hook_cmd.if_condition() {
740 if !check_if_condition(cond, &input) {
741 continue;
742 }
743 }
744
745 let fut = execute_hook_command(hook_cmd.clone(), &input);
746 futures.push(Box::pin(fut));
747 }
748 }
749 }
750
751 let results = futures_util::future::join_all(futures).await;
753 results.into_iter().flatten().collect()
754 }
755
756 pub fn has_hooks(&self, event: &str) -> bool {
758 self.hooks
759 .get(event)
760 .map(|h| !h.is_empty())
761 .unwrap_or(false)
762 || self.typed_hooks.get(event).map(|h| !h.is_empty()).unwrap_or(false)
763 }
764
765 pub fn clear(&mut self) {
767 self.hooks.clear();
768 self.typed_hooks.clear();
769 }
770}
771
772impl HookCommand {
774 pub fn if_condition(&self) -> Option<&str> {
775 match self {
776 HookCommand::Command(p) => p.r#if.as_deref(),
777 HookCommand::Prompt(p) => p.r#if.as_deref(),
778 HookCommand::Agent(p) => p.r#if.as_deref(),
779 HookCommand::Http(p) => p.r#if.as_deref(),
780 }
781 }
782
783 pub fn status_message(&self) -> Option<&str> {
784 match self {
785 HookCommand::Command(p) => p.status_message.as_deref(),
786 HookCommand::Prompt(p) => p.status_message.as_deref(),
787 HookCommand::Agent(p) => p.status_message.as_deref(),
788 HookCommand::Http(p) => p.status_message.as_deref(),
789 }
790 }
791
792 pub fn timeout_ms(&self) -> u64 {
793 match self {
794 HookCommand::Command(p) => p.timeout.unwrap_or(DEFAULT_SHELL_TIMEOUT_MS),
795 HookCommand::Prompt(p) => p.timeout.unwrap_or(DEFAULT_SHELL_TIMEOUT_MS),
796 HookCommand::Agent(p) => {
797 p.timeout.unwrap_or(DEFAULT_AGENT_TIMEOUT_S) * 1000
798 }
799 HookCommand::Http(p) => p.timeout.unwrap_or(DEFAULT_HTTP_TIMEOUT_MS),
800 }
801 }
802
803 pub fn is_once(&self) -> bool {
804 match self {
805 HookCommand::Command(p) => p.once.unwrap_or(false),
806 HookCommand::Prompt(p) => p.once.unwrap_or(false),
807 HookCommand::Agent(p) => p.once.unwrap_or(false),
808 HookCommand::Http(p) => p.once.unwrap_or(false),
809 }
810 }
811
812 pub fn is_async(&self) -> bool {
813 match self {
814 HookCommand::Command(p) => p.async_run.unwrap_or(false),
815 _ => false,
816 }
817 }
818}
819
820fn check_if_condition(cond: &str, input: &HookInput) -> bool {
824 let cond = cond.trim();
825 if cond.is_empty() {
826 return true;
827 }
828
829 if let Some(paren_start) = cond.find('(') {
831 let paren_end = cond.rfind(')');
832 if let Some(paren_end) = paren_end {
833 let tool_part = &cond[..paren_start];
834 let pattern = &cond[paren_start + 1..paren_end];
835
836 if let Some(tool_name) = &input.tool_name {
838 if !tool_name.contains(tool_part) {
839 return false;
840 }
841 } else {
842 return false;
843 }
844
845 if let Some(tool_input) = &input.tool_input {
847 let input_str = tool_input.to_string();
848 if !matches_pattern(pattern, &input_str) {
849 return false;
850 }
851 }
852 true
853 } else {
854 true
856 }
857 } else {
858 if let Some(tool_name) = &input.tool_name {
860 tool_name.contains(cond)
861 } else {
862 false
863 }
864 }
865}
866
867fn matches_pattern(pattern: &str, text: &str) -> bool {
869 if pattern == "*" {
870 return true;
871 }
872 let segments: Vec<&str> = pattern.split('*').filter(|s| !s.is_empty()).collect();
874 if segments.is_empty() {
875 return true;
876 }
877 let mut pos = 0;
878 for segment in &segments {
879 if let Some(found) = text[pos..].find(*segment) {
880 pos = pos + found + segment.len();
881 } else {
882 return false;
883 }
884 }
885 true
886}
887
888async fn execute_hook_def(
890 def: HookDefinition,
891 input: &HookInput,
892) -> Option<HookOutput> {
893 if let Some(command) = &def.command {
894 let shell = HookShell::Bash;
895 execute_shell_hook(&command, &shell, input, def.timeout.unwrap_or(DEFAULT_SHELL_TIMEOUT_MS))
896 .await
897 .ok()
898 .flatten()
899 } else {
900 None
901 }
902}
903
904async fn execute_hook_command(cmd: HookCommand, input: &HookInput) -> Option<HookOutput> {
906 match cmd {
907 HookCommand::Command(params) => {
908 let shell = params.shell.clone().unwrap_or_default();
909 execute_shell_hook(¶ms.command, &shell, input, params.timeout.unwrap_or(DEFAULT_SHELL_TIMEOUT_MS))
910 .await
911 .ok()
912 .flatten()
913 }
914 HookCommand::Http(params) => {
915 execute_http_hook(¶ms, input).await.ok().flatten()
916 }
917HookCommand::Prompt(params) => {
918 let hook = crate::utils::hooks::PromptHook {
919 prompt: params.prompt.clone(),
920 timeout: params.timeout,
921 model: params.model.clone(),
922 };
923 let tool_use_context = std::sync::Arc::new(
924 crate::utils::hooks::can_use_tool::ToolUseContext {
925 session_id: input.session_id.clone().unwrap_or_default(),
926 cwd: input.cwd.clone(),
927 is_non_interactive_session: false,
928 options: None,
929 }
930 );
931 let (_signal_tx, signal_rx) = tokio::sync::watch::channel(false);
932 let hook_name = format!("prompt:{}", input.event);
933 let json_input = serde_json::to_string(input).unwrap_or_default();
934
935 match crate::utils::hooks::exec_prompt_hook(
936 &hook,
937 &hook_name,
938 &input.event,
939 &json_input,
940 signal_rx,
941 tool_use_context,
942 None,
943 input.tool_use_id.clone(),
944 )
945 .await
946 {
947 crate::utils::hooks::ExecPromptHookResult::Success { .. } => {
948 Some(HookOutput {
949 continue_execution: Some(true),
950 suppress_output: Some(true),
951 stop_reason: None,
952 decision: Some("allow".to_string()),
953 reason: None,
954 system_message: None,
955 hook_specific_output: None,
956 message: None,
957 permission_update: None,
958 notification: None,
959 block: None,
960 })
961 }
962 crate::utils::hooks::ExecPromptHookResult::Blocking {
963 blocking_error, ..
964 } => {
965 Some(HookOutput {
966 continue_execution: Some(false),
967 suppress_output: Some(false),
968 stop_reason: Some(blocking_error),
969 decision: Some("deny".to_string()),
970 reason: None,
971 system_message: None,
972 hook_specific_output: None,
973 message: None,
974 permission_update: None,
975 notification: None,
976 block: Some(true),
977 })
978 }
979 crate::utils::hooks::ExecPromptHookResult::Cancelled => {
980 Some(HookOutput {
981 continue_execution: Some(true),
982 suppress_output: Some(false),
983 stop_reason: None,
984 decision: None,
985 reason: Some("hook timed out".to_string()),
986 system_message: None,
987 hook_specific_output: None,
988 message: Some("Prompt hook cancelled/timeout".to_string()),
989 permission_update: None,
990 notification: None,
991 block: None,
992 })
993 }
994 crate::utils::hooks::ExecPromptHookResult::NonBlockingError { stderr, .. } => {
995 Some(HookOutput {
996 continue_execution: Some(true),
997 suppress_output: Some(false),
998 stop_reason: None,
999 decision: None,
1000 reason: Some(stderr),
1001 system_message: None,
1002 hook_specific_output: None,
1003 message: None,
1004 permission_update: None,
1005 notification: None,
1006 block: None,
1007 })
1008 }
1009 }
1010 }
1011HookCommand::Agent(params) => {
1012 let hook = crate::utils::hooks::exec_agent_hook::AgentHook {
1013 prompt: params.prompt.clone(),
1014 timeout: params.timeout,
1015 model: params.model.clone(),
1016 };
1017 let tool_use_context = std::sync::Arc::new(
1018 crate::utils::hooks::can_use_tool::ToolUseContext {
1019 session_id: input.session_id.clone().unwrap_or_default(),
1020 cwd: input.cwd.clone(),
1021 is_non_interactive_session: false,
1022 options: None,
1023 }
1024 );
1025 let (_signal_tx, signal_rx) = tokio::sync::watch::channel(false);
1026 let hook_name = format!("agent:{}", input.event);
1027 let json_input = serde_json::to_string(input).unwrap_or_default();
1028
1029 match crate::utils::hooks::exec_agent_hook(
1030 &hook,
1031 &hook_name,
1032 &input.event,
1033 &json_input,
1034 signal_rx,
1035 tool_use_context,
1036 None,
1037 &[],
1038 None,
1039 )
1040 .await
1041 {
1042 crate::utils::hooks::ExecAgentHookResult::Success { .. } => {
1043 Some(HookOutput {
1044 continue_execution: Some(true),
1045 suppress_output: Some(true),
1046 stop_reason: None,
1047 decision: Some("allow".to_string()),
1048 reason: None,
1049 system_message: None,
1050 hook_specific_output: None,
1051 message: None,
1052 permission_update: None,
1053 notification: None,
1054 block: None,
1055 })
1056 }
1057 crate::utils::hooks::ExecAgentHookResult::Blocking {
1058 blocking_error, ..
1059 } => {
1060 Some(HookOutput {
1061 continue_execution: Some(false),
1062 suppress_output: Some(false),
1063 stop_reason: Some(blocking_error),
1064 decision: Some("deny".to_string()),
1065 reason: None,
1066 system_message: None,
1067 hook_specific_output: None,
1068 message: None,
1069 permission_update: None,
1070 notification: None,
1071 block: Some(true),
1072 })
1073 }
1074 crate::utils::hooks::ExecAgentHookResult::Cancelled => {
1075 Some(HookOutput {
1076 continue_execution: Some(true),
1077 suppress_output: Some(false),
1078 stop_reason: None,
1079 decision: None,
1080 reason: Some("hook cancelled".to_string()),
1081 system_message: None,
1082 hook_specific_output: None,
1083 message: Some("Agent hook cancelled".to_string()),
1084 permission_update: None,
1085 notification: None,
1086 block: None,
1087 })
1088 }
1089 crate::utils::hooks::ExecAgentHookResult::NonBlockingError { stderr, .. } => {
1090 Some(HookOutput {
1091 continue_execution: Some(true),
1092 suppress_output: Some(false),
1093 stop_reason: None,
1094 decision: None,
1095 reason: Some(stderr),
1096 system_message: None,
1097 hook_specific_output: None,
1098 message: None,
1099 permission_update: None,
1100 notification: None,
1101 block: None,
1102 })
1103 }
1104 }
1105 }
1106 }
1107}
1108
1109async fn execute_shell_hook(
1111 command: &str,
1112 shell: &HookShell,
1113 input: &HookInput,
1114 timeout_ms: u64,
1115) -> Result<Option<HookOutput>, crate::error::AgentError> {
1116 let input_json = serde_json::to_string(input).map_err(crate::error::AgentError::Json)?;
1117
1118 let cmd_str = command.to_string();
1120 let event = input.event.clone();
1121 let tool_name = input.tool_name.clone();
1122 let session_id = input.session_id.clone();
1123 let cwd = input.cwd.clone();
1124 let project_dir = crate::utils::get_original_cwd()
1125 .to_string_lossy()
1126 .to_string();
1127 let shell = shell.clone();
1128
1129 let result = timeout(
1130 Duration::from_millis(timeout_ms),
1131 tokio::task::spawn_blocking(move || {
1132 let (prog, args) = match shell {
1133 HookShell::Bash => ("bash", vec!["-c".to_string(), cmd_str.clone()]),
1134 HookShell::PowerShell => ("pwsh", vec![
1135 "-NoProfile".to_string(),
1136 "-NonInteractive".to_string(),
1137 "-Command".to_string(),
1138 cmd_str.clone(),
1139 ]),
1140 };
1141
1142 let mut cmd = Command::new(prog);
1143 cmd.args(&args)
1144 .env("HOOK_EVENT", &event)
1145 .env("HOOK_TOOL_NAME", tool_name.as_deref().unwrap_or(""))
1146 .env("HOOK_SESSION_ID", session_id.as_deref().unwrap_or(""))
1147 .env("HOOK_CWD", cwd.as_deref().unwrap_or(""))
1148 .env("HOOK_PROJECT_DIR", &project_dir)
1149 .env("HOOK_INPUT", &input_json)
1150 .stdin(std::process::Stdio::piped())
1151 .stdout(std::process::Stdio::piped())
1152 .stderr(std::process::Stdio::piped());
1153
1154 let mut child = cmd.spawn()?;
1155
1156 use std::io::Write;
1157 if let Some(mut stdin) = child.stdin.take() {
1158 stdin.write_all(input_json.as_bytes())?;
1159 }
1160
1161 let output = child.wait_with_output()?;
1162
1163 if !output.status.success() {
1168 let stderr_msg = String::from_utf8_lossy(&output.stderr).trim().to_string();
1169 let exit_code = if output.status.code().is_some() {
1170 output.status.code().unwrap()
1171 } else {
1172 -1
1173 };
1174 if exit_code == 2 {
1175 return Ok(Some(HookOutput {
1177 continue_execution: Some(false),
1178 suppress_output: None,
1179 stop_reason: Some(stderr_msg.clone()),
1180 decision: None,
1181 reason: None,
1182 system_message: None,
1183 hook_specific_output: None,
1184 message: Some(stderr_msg),
1185 block: Some(true),
1186 permission_update: None,
1187 notification: None,
1188 }));
1189 }
1190 return Ok(Some(HookOutput {
1192 continue_execution: None,
1193 suppress_output: None,
1194 stop_reason: None,
1195 decision: None,
1196 reason: None,
1197 system_message: None,
1198 hook_specific_output: None,
1199 message: Some(stderr_msg),
1200 block: None,
1201 permission_update: None,
1202 notification: None,
1203 }));
1204 }
1205
1206 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
1207 if stdout.is_empty() {
1208 return Ok(None);
1209 }
1210
1211 if let Ok(async_out) = serde_json::from_str::<AsyncHookOutput>(&stdout) {
1213 if async_out.async_run {
1214 return Ok(Some(HookOutput {
1215 continue_execution: Some(true),
1216 suppress_output: Some(true),
1217 stop_reason: None,
1218 decision: None,
1219 reason: None,
1220 system_message: None,
1221 hook_specific_output: None,
1222 message: Some("Hook running in background".to_string()),
1223 block: None,
1224 permission_update: None,
1225 notification: None,
1226 }));
1227 }
1228 }
1229
1230 if let Ok(hook_output) = serde_json::from_str::<HookOutput>(&stdout) {
1232 Ok(Some(hook_output))
1233 } else {
1234 Ok(Some(HookOutput {
1236 message: Some(stdout),
1237 permission_update: None,
1238 block: None,
1239 notification: None,
1240 continue_execution: None,
1241 suppress_output: None,
1242 stop_reason: None,
1243 decision: None,
1244 reason: None,
1245 system_message: None,
1246 hook_specific_output: None,
1247 }))
1248 }
1249 }),
1250 )
1251 .await;
1252
1253 match result {
1254 Ok(Ok(r)) => r,
1255 Ok(Err(e)) => {
1256 let err = std::io::Error::new(std::io::ErrorKind::Other, e.to_string());
1257 Err(crate::error::AgentError::Io(err))
1258 }
1259 Err(_) => {
1260 let err = std::io::Error::new(std::io::ErrorKind::TimedOut, "Hook timeout");
1261 Err(crate::error::AgentError::Io(err))
1262 }
1263 }
1264}
1265
1266async fn execute_http_hook(
1269 params: &HttpHookParams,
1270 input: &HookInput,
1271) -> Result<Option<HookOutput>, crate::error::AgentError> {
1272 let mut url = params.url.clone();
1273
1274 if url.contains('\r') || url.contains('\n') {
1276 return Err(crate::error::AgentError::Internal(format!(
1277 "HTTP hook URL contains disallowed characters: {}",
1278 &url
1279 )));
1280 }
1281
1282 if !url.starts_with("http://") && !url.starts_with("https://") {
1284 return Err(crate::error::AgentError::Internal(format!(
1285 "HTTP hook URL must start with http:// or https://: {}",
1286 url
1287 )));
1288 }
1289
1290 let body = serde_json::to_string(input).map_err(crate::error::AgentError::Json)?;
1291
1292 let mut header_map = reqwest::header::HeaderMap::new();
1294 header_map.insert(
1295 reqwest::header::CONTENT_TYPE,
1296 reqwest::header::HeaderValue::from_static("application/json"),
1297 );
1298
1299 if let Some(custom_headers) = ¶ms.headers {
1300 for (key, val) in custom_headers {
1301 let interpolated = interpolate_env_vars(val, ¶ms.allowed_env_vars);
1303 if interpolated.contains('\r') || interpolated.contains('\n') {
1305 continue;
1306 }
1307 if let Ok(header_val) = reqwest::header::HeaderValue::from_str(&interpolated) {
1308 if let Ok(header_name) = reqwest::header::HeaderName::from_bytes(key.as_bytes()) {
1309 header_map.insert(header_name, header_val);
1310 }
1311 }
1312 }
1313 }
1314
1315 let timeout_s = params.timeout.unwrap_or(DEFAULT_HTTP_TIMEOUT_MS / 1000) as u64;
1316
1317 let client = reqwest::Client::builder()
1318 .timeout(Duration::from_secs(if timeout_s == 0 { 600 } else { timeout_s }))
1319 .build()
1320 .map_err(|e| crate::error::AgentError::Internal(format!("Failed to build HTTP client: {}", e)))?;
1321
1322 let response = client
1323 .post(&url)
1324 .headers(header_map)
1325 .body(body)
1326 .send()
1327 .await
1328 .map_err(|e| crate::error::AgentError::Internal(format!("HTTP hook request failed: {}", e)))?;
1329
1330 let status = response.status();
1331 let body = response.text().await.map_err(|e| {
1332 crate::error::AgentError::Internal(format!("Failed to read HTTP hook response: {}", e))
1333 })?;
1334
1335 if !status.is_success() {
1336 return Ok(Some(HookOutput {
1337 message: Some(format!("HTTP hook returned status {}: {}", status, body)),
1338 block: Some(status.as_u16() >= 500),
1339 continue_execution: None,
1340 suppress_output: None,
1341 stop_reason: None,
1342 decision: None,
1343 reason: None,
1344 system_message: None,
1345 hook_specific_output: None,
1346 permission_update: None,
1347 notification: None,
1348 }));
1349 }
1350
1351 if let Ok(output) = serde_json::from_str::<HookOutput>(&body) {
1353 Ok(Some(output))
1354 } else if !body.trim().is_empty() {
1355 Ok(Some(HookOutput {
1356 message: Some(body),
1357 block: None,
1358 continue_execution: None,
1359 suppress_output: None,
1360 stop_reason: None,
1361 decision: None,
1362 reason: None,
1363 system_message: None,
1364 hook_specific_output: None,
1365 permission_update: None,
1366 notification: None,
1367 }))
1368 } else {
1369 Ok(None)
1370 }
1371}
1372
1373fn interpolate_env_vars(
1376 value: &str,
1377 allowed_env_vars: &Option<Vec<String>>,
1378) -> String {
1379 if allowed_env_vars.is_none() || allowed_env_vars.as_ref().unwrap().is_empty() {
1381 return value.to_string();
1382 }
1383
1384 let mut result = value.to_string();
1385 for var in allowed_env_vars.as_ref().unwrap() {
1386 let dollar_var = format!("${}", var);
1388 let brace_var = format!("${{{}}}", var);
1389 if let Ok(env_val) = std::env::var(var) {
1390 result = result.replace(&dollar_var, &env_val).replace(&brace_var, &env_val);
1391 } else {
1392 result = result.replace(&dollar_var, "").replace(&brace_var, "");
1393 }
1394 }
1395 result
1396}
1397
1398pub fn create_hook_registry(config: Option<HookConfig>) -> HookRegistry {
1400 let mut registry = HookRegistry::new();
1401 if let Some(c) = config {
1402 registry.register_from_config(c);
1403 }
1404 registry
1405}
1406
1407#[derive(Debug, Default)]
1409pub struct StopHookResult {
1410 pub prevent_continuation: bool,
1411 pub blocking_errors: Vec<String>,
1412}
1413
1414pub async fn run_pre_tool_use_hooks(
1417 registry: &HookRegistry,
1418 tool_name: &str,
1419 tool_input: &serde_json::Value,
1420 tool_use_id: &str,
1421 cwd: &str,
1422) -> Result<bool, crate::error::AgentError> {
1423 if !registry.has_hooks("PreToolUse") {
1424 return Ok(false);
1425 }
1426 let input = HookInput {
1427 event: "PreToolUse".to_string(),
1428 tool_name: Some(tool_name.to_string()),
1429 tool_input: Some(tool_input.clone()),
1430 tool_output: None,
1431 tool_use_id: Some(tool_use_id.to_string()),
1432 session_id: None,
1433 cwd: Some(cwd.to_string()),
1434 error: None,
1435 source: None,
1436 reason: None,
1437 final_text: None,
1438 agent_id: None,
1439 agent_type: None,
1440 trigger: None,
1441 old_cwd: None,
1442 file_path: None,
1443 file_event: None,
1444 mcp_server_name: None,
1445 requested_schema: None,
1446 config_source: None,
1447 };
1448 let results = registry.execute("PreToolUse", input).await;
1449 for output in results {
1450 if output.block == Some(true) {
1451 return Err(crate::error::AgentError::Tool(format!(
1452 "Tool '{}' blocked by PreToolUse hook",
1453 tool_name
1454 )));
1455 }
1456 }
1457 Ok(false)
1458}
1459
1460pub async fn run_post_tool_use_hooks(
1462 registry: &HookRegistry,
1463 tool_name: &str,
1464 tool_output: &crate::types::ToolResult,
1465 tool_use_id: &str,
1466 cwd: &str,
1467) {
1468 if !registry.has_hooks("PostToolUse") {
1469 return;
1470 }
1471 let input = HookInput {
1472 event: "PostToolUse".to_string(),
1473 tool_name: Some(tool_name.to_string()),
1474 tool_input: None,
1475 tool_output: Some(serde_json::json!({
1476 "result_type": tool_output.result_type,
1477 "content": tool_output.content,
1478 "is_error": tool_output.is_error,
1479 })),
1480 tool_use_id: Some(tool_use_id.to_string()),
1481 session_id: None,
1482 cwd: Some(cwd.to_string()),
1483 error: None,
1484 source: None,
1485 reason: None,
1486 final_text: None,
1487 agent_id: None,
1488 agent_type: None,
1489 trigger: None,
1490 old_cwd: None,
1491 file_path: None,
1492 file_event: None,
1493 mcp_server_name: None,
1494 requested_schema: None,
1495 config_source: None,
1496 };
1497 let _ = registry.execute("PostToolUse", input).await;
1498}
1499
1500pub async fn run_post_tool_use_failure_hooks(
1502 registry: &HookRegistry,
1503 tool_name: &str,
1504 error: &str,
1505 tool_use_id: &str,
1506 cwd: &str,
1507) {
1508 if !registry.has_hooks("PostToolUseFailure") {
1509 return;
1510 }
1511 let input = HookInput {
1512 event: "PostToolUseFailure".to_string(),
1513 tool_name: Some(tool_name.to_string()),
1514 tool_input: None,
1515 tool_output: None,
1516 tool_use_id: Some(tool_use_id.to_string()),
1517 session_id: None,
1518 cwd: Some(cwd.to_string()),
1519 error: Some(error.to_string()),
1520 source: None,
1521 reason: None,
1522 final_text: None,
1523 agent_id: None,
1524 agent_type: None,
1525 trigger: None,
1526 old_cwd: None,
1527 file_path: None,
1528 file_event: None,
1529 mcp_server_name: None,
1530 requested_schema: None,
1531 config_source: None,
1532 };
1533 let _ = registry.execute("PostToolUseFailure", input).await;
1534}
1535
1536pub async fn run_stop_hooks(
1539 registry: &HookRegistry,
1540 cwd: &str,
1541 final_text: &str,
1542) -> StopHookResult {
1543 if !registry.has_hooks("Stop") {
1544 return StopHookResult::default();
1545 }
1546 let input = HookInput {
1547 event: "Stop".to_string(),
1548 tool_name: None,
1549 tool_input: None,
1550 tool_output: Some(serde_json::json!({ "text": final_text })),
1551 tool_use_id: None,
1552 session_id: None,
1553 cwd: Some(cwd.to_string()),
1554 error: None,
1555 source: None,
1556 reason: None,
1557 final_text: Some(final_text.to_string()),
1558 agent_id: None,
1559 agent_type: None,
1560 trigger: None,
1561 old_cwd: None,
1562 file_path: None,
1563 file_event: None,
1564 mcp_server_name: None,
1565 requested_schema: None,
1566 config_source: None,
1567 };
1568 let results = registry.execute("Stop", input).await;
1569 let mut blocking_errors = Vec::new();
1570 for output in results {
1571 if output.block == Some(true) {
1572 if let Some(msg) = output.message {
1573 blocking_errors.push(msg);
1574 }
1575 }
1576 }
1577 StopHookResult {
1578 prevent_continuation: blocking_errors.is_empty(),
1579 blocking_errors,
1580 }
1581}
1582
1583pub async fn run_stop_failure_hooks(
1585 registry: &HookRegistry,
1586 error: &str,
1587 cwd: &str,
1588) {
1589 if !registry.has_hooks("StopFailure") {
1590 return;
1591 }
1592 let input = HookInput {
1593 event: "StopFailure".to_string(),
1594 tool_name: None,
1595 tool_input: None,
1596 tool_output: None,
1597 tool_use_id: None,
1598 session_id: None,
1599 cwd: Some(cwd.to_string()),
1600 error: Some(error.to_string()),
1601 source: None,
1602 reason: None,
1603 final_text: None,
1604 agent_id: None,
1605 agent_type: None,
1606 trigger: None,
1607 old_cwd: None,
1608 file_path: None,
1609 file_event: None,
1610 mcp_server_name: None,
1611 requested_schema: None,
1612 config_source: None,
1613 };
1614 let _ = registry.execute("StopFailure", input).await;
1615}
1616
1617#[cfg(test)]
1618mod tests {
1619 use super::*;
1620
1621 #[test]
1622 fn test_hook_event_as_str() {
1623 assert_eq!(HookEvent::PreToolUse.as_str(), "PreToolUse");
1624 assert_eq!(HookEvent::PostToolUse.as_str(), "PostToolUse");
1625 assert_eq!(HookEvent::SessionStart.as_str(), "SessionStart");
1626 }
1627
1628 #[test]
1629 fn test_hook_event_from_str() {
1630 assert_eq!(
1631 HookEvent::from_str("PreToolUse"),
1632 Some(HookEvent::PreToolUse)
1633 );
1634 assert_eq!(HookEvent::from_str("Invalid"), None);
1635 }
1636
1637 #[test]
1638 fn test_hook_events_constant() {
1639 assert!(HOOK_EVENTS.contains(&"PreToolUse"));
1640 assert!(HOOK_EVENTS.contains(&"PostToolUse"));
1641 assert!(HOOK_EVENTS.contains(&"SessionStart"));
1642 }
1643
1644 #[test]
1645 fn test_hook_registry_new() {
1646 let registry = HookRegistry::new();
1647 assert!(!registry.has_hooks("PreToolUse"));
1648 }
1649
1650 #[test]
1651 fn test_hook_registry_register() {
1652 let mut registry = HookRegistry::new();
1653 registry.register(
1654 "PreToolUse",
1655 HookDefinition {
1656 command: Some("echo test".to_string()),
1657 timeout: Some(5000),
1658 matcher: Some("Read.*".to_string()),
1659 },
1660 );
1661 assert!(registry.has_hooks("PreToolUse"));
1662 }
1663
1664 #[test]
1665 fn test_hook_registry_clear() {
1666 let mut registry = HookRegistry::new();
1667 registry.register(
1668 "PreToolUse",
1669 HookDefinition {
1670 command: Some("echo test".to_string()),
1671 timeout: None,
1672 matcher: None,
1673 },
1674 );
1675 registry.clear();
1676 assert!(!registry.has_hooks("PreToolUse"));
1677 }
1678
1679 #[test]
1680 fn test_hook_input_new() {
1681 let input = HookInput::new("PreToolUse");
1682 assert_eq!(input.event, "PreToolUse");
1683 }
1684
1685 #[test]
1686 fn test_hook_output_serialization() {
1687 let output = HookOutput {
1688 message: Some("test message".to_string()),
1689 permission_update: None,
1690 block: Some(true),
1691 notification: None,
1692 continue_execution: None,
1693 suppress_output: None,
1694 stop_reason: None,
1695 decision: None,
1696 reason: None,
1697 system_message: None,
1698 hook_specific_output: None,
1699 };
1700 let json = serde_json::to_string(&output).unwrap();
1701 assert!(json.contains("test message"));
1702 }
1703
1704 #[test]
1705 fn test_create_hook_registry() {
1706 let registry = create_hook_registry(None);
1707 assert!(!registry.has_hooks("PreToolUse"));
1708 }
1709
1710 #[tokio::test]
1711 async fn test_execute_no_hooks() {
1712 let registry = HookRegistry::new();
1713 let input = HookInput::new("PreToolUse");
1714 let results = registry.execute("PreToolUse", input).await;
1715 assert!(results.is_empty());
1716 }
1717
1718 #[tokio::test]
1719 async fn test_execute_with_invalid_event() {
1720 let registry = HookRegistry::new();
1721 let input = HookInput::new("InvalidEvent");
1722 let results = registry.execute("InvalidEvent", input).await;
1723 assert!(results.is_empty());
1724 }
1725
1726 #[test]
1727 fn test_check_if_condition_exact_tool() {
1728 let input = HookInput {
1729 tool_name: Some("Bash".to_string()),
1730 ..HookInput::new("PreToolUse")
1731 };
1732 assert!(check_if_condition("Bash", &input));
1733 assert!(!check_if_condition("Read", &input));
1734 }
1735
1736 #[test]
1737 fn test_check_if_condition_with_pattern() {
1738 let input = HookInput {
1739 tool_name: Some("Bash".to_string()),
1740 tool_input: Some(serde_json::json!({"command": "git status"})),
1741 ..HookInput::new("PreToolUse")
1742 };
1743 assert!(check_if_condition("Bash(git)", &input));
1744 assert!(!check_if_condition("Bash(npm)", &input));
1745 }
1746
1747 #[test]
1748 fn test_check_if_condition_wildcard() {
1749 let input = HookInput {
1750 tool_name: Some("Bash".to_string()),
1751 tool_input: Some(serde_json::json!({"command": "anything"})),
1752 ..HookInput::new("PreToolUse")
1753 };
1754 assert!(check_if_condition("Bash(*)", &input));
1755 }
1756
1757 #[test]
1758 fn test_matches_pattern_glob() {
1759 assert!(matches_pattern("*.ts", "foo.ts"));
1760 assert!(!matches_pattern("*.ts", "foo.js"));
1761 assert!(matches_pattern("*test*", "my_test_file"));
1762 assert!(matches_pattern("*", "anything"));
1763 }
1764
1765 #[test]
1766 fn test_hook_shell_default() {
1767 let shell = HookShell::default();
1768 assert_eq!(shell, HookShell::Bash);
1769 }
1770
1771 #[test]
1772 fn test_hook_type_default() {
1773 let ty = HookType::default();
1774 assert_eq!(ty, HookType::Command);
1775 }
1776
1777 #[test]
1778 fn test_hook_command_if_condition() {
1779 let cmd: HookCommand = serde_json::from_str(
1780 r#"{"type":"command","command":"echo hi","if":"Bash(git)"}"#,
1781 )
1782 .unwrap();
1783 assert_eq!(cmd.if_condition(), Some("Bash(git)"));
1784 }
1785
1786 #[test]
1787 fn test_http_hook_params_deserialize() {
1788 let params: HttpHookParams = serde_json::from_str(
1789 r#"{"url":"https://example.com/webhook","timeout":30,"headers":{"Authorization":"Bearer $TOKEN"},"allowedEnvVars":["TOKEN"]}"#,
1790 )
1791 .unwrap();
1792 assert_eq!(params.url, "https://example.com/webhook");
1793 assert!(params.headers.is_some());
1794 }
1795
1796 #[test]
1797 fn test_interpolate_env_vars() {
1798 unsafe {
1799 std::env::set_var("TEST_HOOK_VAR", "secret123");
1800 }
1801 let result =
1802 interpolate_env_vars("Bearer $TEST_HOOK_VAR", &Some(vec!["TEST_HOOK_VAR".to_string()]));
1803 assert_eq!(result, "Bearer secret123");
1804
1805 unsafe {
1807 std::env::set_var("UNALLOWED_VAR", "leaked");
1808 }
1809 let result = interpolate_env_vars("$UNALLOWED_VAR", &Some(vec!["OTHER".to_string()]));
1810 assert_eq!(result, "$UNALLOWED_VAR");
1811
1812 unsafe {
1813 std::env::remove_var("TEST_HOOK_VAR");
1814 std::env::remove_var("UNALLOWED_VAR");
1815 }
1816 }
1817
1818 #[test]
1819 fn test_interpolate_env_vars_brace_syntax() {
1820 unsafe {
1821 std::env::set_var("MY_TOKEN", "abc");
1822 }
1823 let result =
1824 interpolate_env_vars("Bearer ${MY_TOKEN}", &Some(vec!["MY_TOKEN".to_string()]));
1825 assert_eq!(result, "Bearer abc");
1826 unsafe {
1827 std::env::remove_var("MY_TOKEN");
1828 }
1829 }
1830
1831 #[test]
1832 fn test_hook_specific_output() {
1833 let output = HookSpecificOutput::PreToolUse {
1834 permission_decision: Some(PermissionBehavior::Allow),
1835 permission_decision_reason: None,
1836 updated_input: None,
1837 additional_context: Some("approved".to_string()),
1838 };
1839 let json = serde_json::to_string(&output).unwrap();
1840 assert!(json.contains("PreToolUse"));
1841 assert!(json.contains("allow"));
1842 }
1843
1844 #[test]
1845 fn test_hook_matcher_deserialize() {
1846 let matcher: HookMatcher = serde_json::from_str(
1847 r#"{"matcher":"Bash","hooks":[{"type":"command","command":"echo bash"}]}"#,
1848 )
1849 .unwrap();
1850 assert_eq!(matcher.matcher.as_deref(), Some("Bash"));
1851 assert_eq!(matcher.hooks.len(), 1);
1852 }
1853
1854 #[test]
1855 fn test_async_hook_output() {
1856 let json = r#"{"async": true, "asyncTimeout": 60}"#;
1857 let output: AsyncHookOutput = serde_json::from_str(json).unwrap();
1858 assert!(output.async_run);
1859 assert_eq!(output.async_timeout, Some(60));
1860 }
1861
1862 #[test]
1863 fn test_hook_command_timeout_ms() {
1864 let cmd: HookCommand = serde_json::from_str(
1865 r#"{"type":"command","command":"echo hi","timeout":5}"#,
1866 )
1867 .unwrap();
1868 assert_eq!(cmd.timeout_ms(), 5);
1869
1870 let agent_cmd: HookCommand = serde_json::from_str(
1871 r#"{"type":"agent","prompt":"verify"}"#,
1872 )
1873 .unwrap();
1874 assert_eq!(agent_cmd.timeout_ms(), 60_000);
1876 }
1877}