Skip to main content

ai_agent/
hooks.rs

1// Source: /data/home/swei/claudecode/openclaudecode/src/commands/hooks/hooks.tsx
2//         /data/home/swei/claudecode/openclaudecode/src/schemas/hooks.ts
3//         /data/home/swei/claudecode/openclaudecode/src/types/hooks.ts
4//         /data/home/swei/claudecode/openclaudecode/src/utils/hooks/execHttpHook.ts
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::pin::Pin;
8use std::process::Command;
9use tokio::time::{Duration, timeout};
10
11/// All supported hook events.
12pub 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
42/// Reasons for session end.
43pub const EXIT_REASONS: &[&str] = &[
44    "clear",
45    "resume",
46    "logout",
47    "prompt_input_exit",
48    "other",
49    "bypass_permissions_disabled",
50];
51
52/// Reasons for loading instructions.
53pub const INSTRUCTIONS_LOAD_REASONS: &[&str] = &[
54    "session_start",
55    "nested_traversal",
56    "path_glob_match",
57    "include",
58    "compact",
59];
60
61/// Types of instructions memory.
62pub const INSTRUCTIONS_MEMORY_TYPES: &[&str] = &["User", "Project", "Local", "Managed"];
63
64/// Sources of config changes.
65pub const CONFIG_CHANGE_SOURCES: &[&str] = &[
66    "user_settings",
67    "project_settings",
68    "local_settings",
69    "policy_settings",
70    "skills",
71];
72
73/// Default hook timeout in milliseconds (30s for shell, 60s for agent, 10m for HTTP).
74pub 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/// All supported hook events (enum form).
79#[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/// Shell types for hook execution.
179#[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/// Hook type discriminator (matches TS discriminated union).
193#[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/// Permission behavior for hook decisions.
209#[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/// Event-specific hook output sub-schema (mirrors TS hookSpecificOutput).
219#[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/// Permission request decision from hook.
315#[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/// Full hook output (mirrors TS syncHookResponseSchema).
335#[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    // Legacy fields for backwards compatibility
353    #[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/// Async hook output -- hook signals it wants to run in background.
364#[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/// Hook definition (supports all TS hook types: command, prompt, agent, http).
374/// Translated from HookCommand discriminated union in schemas/hooks.ts.
375#[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/// Parameters for a shell command hook.
389#[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/// Parameters for an LLM prompt hook.
412#[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/// Parameters for an agentic verifier hook.
429#[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/// Parameters for an HTTP webhook hook.
446#[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/// Legacy hook definition -- kept for backwards compatibility with code
465/// that constructs HookDefinition directly.
466#[derive(Debug, Clone)]
467pub struct HookDefinition {
468    /// Shell command to execute
469    pub command: Option<String>,
470    /// Timeout in milliseconds
471    pub timeout: Option<u64>,
472    /// Tool name matcher (regex pattern)
473    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/// Hook matcher configuration (from TS HookMatcherSchema).
518#[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
526/// Hook configuration (from settings) -- HashMap<event, matchers>.
527pub type HookConfig = HashMap<String, Vec<HookDefinition>>;
528pub type HookMatcherConfig = HashMap<String, Vec<HookMatcher>>;
529
530/// Hook input passed to handlers.
531#[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    // Event-specific fields (mirrors TS event-specific HookInput types)
550    #[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/// Permission update for hook output.
604#[derive(Debug, Clone, Serialize, Deserialize)]
605#[serde(rename_all = "camelCase")]
606pub struct PermissionUpdate {
607    pub tool: String,
608    pub behavior: String,
609}
610
611/// Notification for hook output.
612#[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/// The result of a single hook execution.
622#[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/// Hook execution outcome.
633#[derive(Debug, Clone, PartialEq, Eq)]
634pub enum HookOutcome {
635    Success,
636    Blocking,
637    NonBlockingError,
638    Cancelled,
639}
640
641/// Hook registry for managing and executing hooks.
642#[derive(Debug, Default, Clone)]
643pub struct HookRegistry {
644    /// Legacy shell-command hooks
645    hooks: HashMap<String, Vec<HookDefinition>>,
646    /// Typed hooks (command, prompt, agent, http)
647    typed_hooks: HashMap<String, Vec<HookMatcher>>,
648}
649
650impl HookRegistry {
651    /// Create a new empty registry.
652    pub fn new() -> Self {
653        Self {
654            hooks: HashMap::new(),
655            typed_hooks: HashMap::new(),
656        }
657    }
658
659    /// Register hooks from legacy configuration.
660    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    /// Register typed hooks from matcher configuration.
671    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    /// Register a single legacy hook.
682    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    /// Register a typed hook matcher.
691    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    /// Execute hooks for an event (runs both legacy and typed hooks in parallel).
700    pub async fn execute(&self, event: &str, mut input: HookInput) -> Vec<HookOutput> {
701        input.event = event.to_string();
702
703        // Collect all async hook futures as pinned boxed trait objects
704        let mut futures: Vec<Pin<Box<dyn futures_util::Future<Output = Option<HookOutput>> + Send>>> =
705            Vec::new();
706
707        // Legacy shell-command hooks
708        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        // Typed hooks (command, prompt, agent, http)
728        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        // Execute all hooks in parallel
752        let results = futures_util::future::join_all(futures).await;
753        results.into_iter().flatten().collect()
754    }
755
756    /// Check if any hooks are registered for an event.
757    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    /// Clear all hooks.
766    pub fn clear(&mut self) {
767        self.hooks.clear();
768        self.typed_hooks.clear();
769    }
770}
771
772/// Get the `if` condition from a HookCommand (if any).
773impl 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
820/// Check an `if` condition against hook input.
821/// Uses simple permission-rule-style matching: "ToolName(pattern)" or "ToolName".
822/// Translated from prepareIfConditionMatcher in TS.
823fn check_if_condition(cond: &str, input: &HookInput) -> bool {
824    let cond = cond.trim();
825    if cond.is_empty() {
826        return true;
827    }
828
829    // Parse "ToolName(pattern)" or just "ToolName"
830    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            // Check tool name
837            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            // Check input pattern (simple glob/wildcard match)
846            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            // Malformed condition -- run anyway
855            true
856        }
857    } else {
858        // Just a tool name prefix
859        if let Some(tool_name) = &input.tool_name {
860            tool_name.contains(cond)
861        } else {
862            false
863        }
864    }
865}
866
867/// Simple pattern matching: "*" matches anything, other patterns are substring match.
868fn matches_pattern(pattern: &str, text: &str) -> bool {
869    if pattern == "*" {
870        return true;
871    }
872    // Split pattern by '*' and check each segment appears in order
873    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
888/// Execute a legacy HookDefinition.
889async 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
904/// Execute a typed HookCommand.
905async 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(&params.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(&params, 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
1109/// Execute a shell command as a hook.
1110async 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    // Clone data needed in the blocking task
1119    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            // Exit code semantics (mirrors TS):
1164            // 0 -> success
1165            // 2 -> blocking error
1166            // other -> non-blocking error
1167            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                    // Blocking error
1176                    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                // Non-blocking error -- still return output
1191                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            // Check for async protocol: {"async": true}
1212            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            // Try to parse as JSON
1231            if let Ok(hook_output) = serde_json::from_str::<HookOutput>(&stdout) {
1232                Ok(Some(hook_output))
1233            } else {
1234                // Non-JSON output treated as message
1235                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
1266/// Execute an HTTP hook (POST to webhook URL).
1267/// Translated from execHttpHook in TypeScript.
1268async 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    // Sanitize CRLF header injection
1275    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    // Basic URL validation
1283    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    // Build headers
1293    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) = &params.headers {
1300        for (key, val) in custom_headers {
1301            // Interpolate environment variables
1302            let interpolated = interpolate_env_vars(val, &params.allowed_env_vars);
1303            // Sanitize CRLF injection in headers
1304            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    // Try to parse response as HookOutput
1352    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
1373/// Interpolate environment variables in header values.
1374/// Only resolves variables listed in `allowed_env_vars`.
1375fn interpolate_env_vars(
1376    value: &str,
1377    allowed_env_vars: &Option<Vec<String>>,
1378) -> String {
1379    // If no allowed vars, don't interpolate anything
1380    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        // Match $VAR_NAME or ${VAR_NAME}
1387        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
1398/// Create a default hook registry.
1399pub 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/// Result of running Stop hooks.
1408#[derive(Debug, Default)]
1409pub struct StopHookResult {
1410    pub prevent_continuation: bool,
1411    pub blocking_errors: Vec<String>,
1412}
1413
1414/// Free function: Run PreToolUse hooks from a registry.
1415/// Returns Ok(true) if any hook blocked, Ok(false) otherwise.
1416pub 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
1460/// Free function: Run PostToolUse hooks from a registry.
1461pub 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
1500/// Free function: Run PostToolUseFailure hooks from a registry.
1501pub 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
1536/// Free function: Run Stop hooks from a registry.
1537/// Returns prevent_continuation and any blocking error messages.
1538pub 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
1583/// Free function: Run StopFailure hooks (fire-and-forget).
1584pub 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        // Unallowed var should not be interpolated
1806        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        // Default agent timeout is 60s = 60000ms
1875        assert_eq!(agent_cmd.timeout_ms(), 60_000);
1876    }
1877}