Skip to main content

recursive/hooks/
external.rs

1//! External process-based hooks.
2//!
3//! External hooks are executable scripts/programs placed in hook directories
4//! (`~/.recursive/hooks/` or `<workspace>/.recursive/hooks/`). They receive
5//! a JSON event on stdin and must reply with a JSON decision on stdout within
6//! 5 seconds. Timeout or non-parseable output is treated as "continue".
7//!
8//! # Protocol
9//!
10//! **Input** (stdin, single line JSON):
11//! ```json
12//! {
13//!   "event": "preToolCall",
14//!   "toolName": "Bash",
15//!   "args": {"command": "rm -rf /"},
16//!   "mode": "ask"
17//! }
18//! ```
19//!
20//! **Output** (stdout, single line JSON):
21//! ```json
22//! {"action": "continue"}
23//! {"action": "skip", "message": "dangerous command"}
24//! {"action": "error", "message": "not allowed"}
25//! ```
26
27use std::collections::HashSet;
28use std::sync::{Arc, Mutex};
29
30use crate::error::{Error, Result};
31use crate::event::AgentEvent;
32use crate::hooks::config::{matches_hook, HookCommand, HookCommandType, HookFailMode, HooksConfig};
33use crate::llm::ChatProvider;
34use crate::message::Message;
35use serde::{Deserialize, Serialize};
36use std::path::PathBuf;
37use std::time::Duration;
38use tokio::process::Command;
39use tokio::sync::mpsc;
40use tokio::time::timeout;
41use tokio_util::sync::CancellationToken;
42
43pub use crate::hooks::HookAction;
44
45/// Time limit for a single external hook to respond.
46/// Can be overridden via `RECURSIVE_HOOK_TIMEOUT_SECS` for slow CI / test
47/// environments — e.g. `RECURSIVE_HOOK_TIMEOUT_SECS=30`.
48const HOOK_TIMEOUT: Duration = Duration::from_secs(5);
49
50fn default_hook_timeout() -> Duration {
51    std::env::var("RECURSIVE_HOOK_TIMEOUT_SECS")
52        .ok()
53        .and_then(|s| s.parse::<u64>().ok())
54        .map(Duration::from_secs)
55        .unwrap_or(HOOK_TIMEOUT)
56}
57
58// ── JSON protocol types ────────────────────────────────────────────
59
60/// The kind of lifecycle event sent to the external hook.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "camelCase")]
63pub enum HookEvent {
64    // existing
65    PreToolCall,
66    PostToolCall,
67    PermissionRequest,
68    // new in Goal 204
69    PostToolCallFailure,
70    PermissionDenied,
71    SessionStart,
72    SessionEnd,
73    UserPromptSubmit,
74    Stop,
75    SubagentStart,
76    SubagentStop,
77    Notification,
78    Setup,
79}
80
81/// Input payload sent to the external hook on stdin.
82#[derive(Debug, Clone, Serialize)]
83#[serde(rename_all = "camelCase")]
84pub struct HookInput {
85    pub event: HookEvent,
86    /// Tool name — `None` for non-tool events.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub tool_name: Option<String>,
89    /// Tool arguments — `None` for non-tool events.
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub args: Option<serde_json::Value>,
92    pub mode: String,
93    // optional context fields for specific events
94    /// User's input content (UserPromptSubmit).
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub content: Option<String>,
97    /// Notification message (Notification).
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub message: Option<String>,
100    /// Nesting depth (SubagentStart / SubagentStop).
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub depth: Option<usize>,
103    /// Denial reason (PermissionDenied).
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub reason: Option<String>,
106    /// Error message (PostToolCallFailure).
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub error: Option<String>,
109}
110
111/// Action returned by the external hook, as deserialized from JSON.
112#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
113#[serde(rename_all = "camelCase")]
114enum JsonAction {
115    Continue,
116    Skip,
117    Error,
118}
119
120/// Output payload expected from the external hook on stdout.
121#[derive(Debug, Clone, Deserialize, Default)]
122#[serde(rename_all = "camelCase")]
123struct HookOutput {
124    #[serde(default)]
125    action: Option<JsonAction>,
126    #[serde(default)]
127    message: Option<String>,
128
129    // ── Goal 205: extended output fields ──────────────────────────
130    /// Append to the next LLM system prompt.
131    #[serde(default)]
132    additional_context: Option<String>,
133    /// Override tool arguments (PreToolCall only).
134    #[serde(default)]
135    updated_input: Option<serde_json::Value>,
136    /// Warning message shown to the user (via AgentEvent).
137    #[serde(default)]
138    system_message: Option<String>,
139    /// When true, suppress writing hook stdout to the transcript.
140    #[serde(default)]
141    suppress_output: bool,
142    /// Permission decision: "allow" / "deny" / "passthrough".
143    #[serde(default)]
144    permission_decision: Option<String>,
145    /// Reason for the permission decision (audit log only).
146    #[serde(default)]
147    permission_decision_reason: Option<String>,
148}
149
150/// Permission decision returned by a hook.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub enum PermissionDecision {
153    Allow,
154    Deny,
155    Passthrough,
156}
157
158impl PermissionDecision {
159    fn from_str(s: &str) -> Option<Self> {
160        match s {
161            "allow" => Some(Self::Allow),
162            "deny" => Some(Self::Deny),
163            "passthrough" => Some(Self::Passthrough),
164            _ => None,
165        }
166    }
167}
168
169/// Rich result returned by a hook dispatch, containing the primary action
170/// as well as optional structured metadata for the caller to consume.
171#[derive(Debug, Clone)]
172pub struct HookResult {
173    /// Primary decision.
174    pub action: HookAction,
175    /// Extra context to inject into the next LLM system prompt.
176    pub additional_context: Option<String>,
177    /// Override tool arguments (meaningful only for PreToolCall).
178    pub updated_input: Option<serde_json::Value>,
179    /// User-facing warning message.
180    pub system_message: Option<String>,
181    /// When `true`, suppress the hook's raw stdout from the transcript.
182    pub suppress_output: bool,
183    /// Explicit permission decision (overrides normal permission check).
184    pub permission_decision: Option<PermissionDecision>,
185    /// Reason for the permission decision (for audit logging).
186    pub permission_decision_reason: Option<String>,
187}
188
189impl Default for HookResult {
190    fn default() -> Self {
191        Self {
192            action: HookAction::Continue,
193            additional_context: None,
194            updated_input: None,
195            system_message: None,
196            suppress_output: false,
197            permission_decision: None,
198            permission_decision_reason: None,
199        }
200    }
201}
202
203impl HookResult {
204    /// Shorthand for the default "do nothing" result.
205    pub fn continue_default() -> Self {
206        Self::default()
207    }
208
209    /// Produce a hook result based on the fail mode.
210    ///
211    /// - `Open` (default): returns `Continue` — preserves pre-Goal-281
212    ///   fail-open behavior for notification hooks.
213    /// - `Closed`: for gating events (`PreToolCall`, `UserPromptSubmit`,
214    ///   `PermissionRequest`) returns `Error(reason)` so the agent sees
215    ///   the failure as a rejection. For all other event types
216    ///   (notification hooks like `PostToolCall`, `SessionStart`, etc.)
217    ///   returns `Continue` since a notification failure shouldn't block.
218    pub fn from_fail_mode(mode: HookFailMode, reason: &str, event: &HookEvent) -> Self {
219        match mode {
220            HookFailMode::Open => Self::continue_default(),
221            HookFailMode::Closed => {
222                let action = match event {
223                    HookEvent::PreToolCall
224                    | HookEvent::UserPromptSubmit
225                    | HookEvent::PermissionRequest => HookAction::Error(reason.to_string()),
226                    _ => HookAction::Continue,
227                };
228                Self {
229                    action,
230                    ..Self::default()
231                }
232            }
233        }
234    }
235}
236
237impl HookOutput {
238    /// Convert the external hook's JSON output into a `HookResult`.
239    fn into_hook_result(self) -> HookResult {
240        let action = match self.action.unwrap_or(JsonAction::Continue) {
241            JsonAction::Continue => HookAction::Continue,
242            JsonAction::Skip => HookAction::Skip,
243            JsonAction::Error => HookAction::Error(
244                self.message
245                    .clone()
246                    .unwrap_or_else(|| "external hook blocked".to_string()),
247            ),
248        };
249        let permission_decision = self
250            .permission_decision
251            .as_deref()
252            .and_then(PermissionDecision::from_str);
253        HookResult {
254            action,
255            additional_context: self.additional_context,
256            updated_input: self.updated_input,
257            system_message: self.system_message,
258            suppress_output: self.suppress_output,
259            permission_decision,
260            permission_decision_reason: self.permission_decision_reason,
261        }
262    }
263}
264
265// ── Resolved hook entry ────────────────────────────────────────────
266
267/// What kind of execution a resolved hook uses.
268#[derive(Clone, Debug)]
269enum ResolvedHookKind {
270    /// Local executable (binary or shell script) with optional arguments.
271    ///
272    /// The command field in `HooksConfig` is split via `shell_words::split`
273    /// so users can write `"/path/to/hook arg1 arg2"` and get proper quoting.
274    Command(PathBuf, Vec<String>),
275    /// HTTP POST to a remote endpoint.
276    Http {
277        url: String,
278        headers: Option<std::collections::HashMap<String, String>>,
279        allowed_env_vars: Option<Vec<String>>,
280    },
281    /// LLM prompt evaluation — the prompt template is evaluated by an LLM.
282    Prompt {
283        /// Template string; `$ARGUMENTS` is replaced with serialised `HookInput`.
284        prompt: String,
285    },
286}
287
288/// A single resolved hook entry (command path + metadata from config).
289#[derive(Clone, Debug)]
290struct ResolvedHook {
291    /// How to execute this hook.
292    kind: ResolvedHookKind,
293    /// Per-hook timeout override (None = use global HOOK_TIMEOUT).
294    timeout_secs: Option<u64>,
295    /// Event name this hook is registered for (None = all events).
296    event_name: Option<String>,
297    /// Tool/arg filter pattern (None = all tools).
298    matcher: Option<String>,
299    /// When `true`, run once and then mark as executed.
300    once: bool,
301    /// When `true`, run in background — Agent continues immediately.
302    r#async: bool,
303    /// When `true`, run in background and cancel Agent on exit code 2.
304    async_rewake: bool,
305    /// Fail behavior on timeout / error / non-zero exit.
306    fail_mode: HookFailMode,
307}
308
309// ── Runner ─────────────────────────────────────────────────────────
310
311// ── Runner ─────────────────────────────────────────────────────────
312
313/// Discovers and runs external hook executables.
314///
315/// External hooks are scanned from one or more directories. Each
316/// executable file is treated as a hook. When `dispatch` is called,
317/// the runner sends the event to each hook in order and returns the
318/// first non-`Continue` decision. Hooks that timeout or return
319/// invalid output are treated as `Continue` (fail-open).
320/// Forwards a lifecycle hook to a Claude-compatible host via
321/// `control_request` / `hook_callback`. Implemented by the CLI control bridge.
322#[async_trait::async_trait]
323pub trait SdkHookForwarder: Send + Sync {
324    /// Ask the host to run registered SDK callbacks for this event.
325    /// Return `None` to fall through to local external hooks.
326    async fn forward(&self, input: &HookInput) -> Option<HookResult>;
327}
328
329#[derive(Clone)]
330pub struct ExternalHookRunner {
331    hooks: Vec<ResolvedHook>,
332    /// Optional LLM provider for prompt-type hooks.
333    llm: Option<Arc<dyn ChatProvider>>,
334    /// Tracks indices of `once: true` hooks that have already been executed.
335    executed_once: Arc<Mutex<HashSet<usize>>>,
336    /// Optional cancellation token for `asyncRewake` hooks.
337    /// Test-only: pre-canned `HookResult`s indexed by hook position.
338    /// When set, `run_hook` returns the mock result instead of spawning a process.
339    #[cfg(test)]
340    mock_results: Vec<Option<HookResult>>,
341    /// Test-only: pre-canned exit codes for `asyncRewake` path.
342    /// When set, `run_command_exit_code` returns the mock exit code instead of spawning.
343    #[cfg(test)]
344    mock_exit_codes: Vec<Option<i32>>,
345    pub cancel_token: Option<CancellationToken>,
346    /// Optional event channel for TUI progress events.
347    event_tx: Option<mpsc::UnboundedSender<AgentEvent>>,
348    /// Optional Claude SDK hook forwarder (CLI control channel).
349    sdk_forwarder: Option<Arc<dyn SdkHookForwarder>>,
350}
351
352impl ExternalHookRunner {
353    /// Scan the given directories and collect all executable files.
354    ///
355    /// This is the legacy discovery mode: every executable in the directory
356    /// is treated as a hook that fires for all events.
357    pub fn discover(dirs: &[PathBuf]) -> Self {
358        let mut hooks = Vec::new();
359        for dir in dirs {
360            if let Ok(entries) = std::fs::read_dir(dir) {
361                for entry in entries.flatten() {
362                    let path = entry.path();
363                    if is_executable(&path) {
364                        hooks.push(ResolvedHook {
365                            kind: ResolvedHookKind::Command(path, vec![]),
366                            timeout_secs: None,
367                            event_name: None,
368                            matcher: None,
369                            once: false,
370                            r#async: false,
371                            async_rewake: false,
372                            fail_mode: HookFailMode::Open,
373                        });
374                    }
375                }
376            }
377        }
378        #[cfg(not(test))]
379        return Self {
380            hooks,
381            llm: None,
382            executed_once: Arc::new(Mutex::new(HashSet::new())),
383            cancel_token: None,
384            event_tx: None,
385            sdk_forwarder: None,
386        };
387        #[cfg(test)]
388        Self {
389            hooks,
390            llm: None,
391            executed_once: Arc::new(Mutex::new(HashSet::new())),
392            cancel_token: None,
393            event_tx: None,
394            sdk_forwarder: None,
395            mock_results: vec![],
396            mock_exit_codes: vec![],
397        }
398    }
399
400    /// Build a runner from a structured `HooksConfig`.
401    ///
402    /// Supports `command` and `http` types; `prompt` / `agent` require an LLM
403    /// provider and are handled by [`from_config_with_llm`].
404    pub fn from_config(config: HooksConfig) -> Self {
405        Self::from_config_with_llm(config, None)
406    }
407
408    /// Build a runner from a structured `HooksConfig`, with an optional LLM
409    /// provider for prompt/agent-type hooks.
410    pub fn from_config_with_llm(config: HooksConfig, llm: Option<Arc<dyn ChatProvider>>) -> Self {
411        let mut hooks = Vec::new();
412        for (event_name, matchers) in config.events {
413            for matcher_entry in matchers {
414                for cmd in &matcher_entry.hooks {
415                    if let Some(resolved) = Self::resolve_command(
416                        cmd,
417                        Some(event_name.clone()),
418                        matcher_entry.matcher.clone(),
419                    ) {
420                        hooks.push(resolved);
421                    }
422                }
423            }
424        }
425        #[cfg(not(test))]
426        return Self {
427            hooks,
428            llm,
429            executed_once: Arc::new(Mutex::new(HashSet::new())),
430            cancel_token: None,
431            event_tx: None,
432            sdk_forwarder: None,
433        };
434        #[cfg(test)]
435        Self {
436            hooks,
437            llm,
438            executed_once: Arc::new(Mutex::new(HashSet::new())),
439            cancel_token: None,
440            event_tx: None,
441            sdk_forwarder: None,
442            mock_results: vec![],
443            mock_exit_codes: vec![],
444        }
445    }
446
447    /// Attach a `CancellationToken` for `asyncRewake` hooks.
448    pub fn with_cancel_token(mut self, token: CancellationToken) -> Self {
449        self.cancel_token = Some(token);
450        self
451    }
452
453    /// Attach an `AgentEvent` channel for TUI progress events.
454    pub fn with_event_tx(mut self, tx: mpsc::UnboundedSender<AgentEvent>) -> Self {
455        self.event_tx = Some(tx);
456        self
457    }
458
459    /// Attach a Claude SDK hook forwarder (control-channel `hook_callback`).
460    pub fn with_sdk_forwarder(mut self, forwarder: Arc<dyn SdkHookForwarder>) -> Self {
461        self.sdk_forwarder = Some(forwarder);
462        self
463    }
464
465    /// Replace the SDK hook forwarder at runtime.
466    pub fn set_sdk_forwarder(&mut self, forwarder: Option<Arc<dyn SdkHookForwarder>>) {
467        self.sdk_forwarder = forwarder;
468    }
469
470    /// Inject canned `HookResult`s for tests that must not spawn real processes.
471    ///
472    /// `results[i]` is the response returned for hook at index `i`.
473    /// `None` means "run the real hook" (useful to test a mix of mocked and real).
474    /// When the index is beyond the `results` slice the real hook is executed.
475    ///
476    /// This replaces process execution entirely — the hook's command path is
477    /// ignored. Use this to test dispatch logic (event filtering, short-circuit,
478    /// once, async rewake) without any OS process creation.
479    #[cfg(test)]
480    fn with_mock_results(mut self, results: Vec<Option<HookResult>>) -> Self {
481        self.mock_results = results;
482        self
483    }
484
485    /// Inject pre-canned exit codes for `asyncRewake` path tests.
486    ///
487    /// Indexed by hook position; `None` means "exit 0 (no cancellation)".
488    #[cfg(test)]
489    fn with_mock_exit_codes(mut self, codes: Vec<Option<i32>>) -> Self {
490        self.mock_exit_codes = codes;
491        self
492    }
493
494    /// Emit an `AgentEvent` if an event channel is registered.
495    fn emit(&self, event: AgentEvent) {
496        if let Some(tx) = &self.event_tx {
497            let _ = tx.send(event);
498        }
499    }
500
501    /// Convert a single `HookCommand` entry to a `ResolvedHook`.
502    ///
503    /// Returns `None` for types that need an LLM (prompt/agent) or missing fields.
504    fn resolve_command(
505        cmd: &HookCommand,
506        event_name: Option<String>,
507        matcher: Option<String>,
508    ) -> Option<ResolvedHook> {
509        let base = |kind: ResolvedHookKind| ResolvedHook {
510            kind,
511            timeout_secs: Some(cmd.timeout),
512            event_name,
513            matcher,
514            once: cmd.once,
515            r#async: cmd.r#async,
516            async_rewake: cmd.async_rewake,
517            fail_mode: cmd.fail_mode,
518        };
519
520        match cmd.r#type {
521            HookCommandType::Command => {
522                let command_str = cmd.command.as_deref()?;
523                // Parse the command string with shell-quoting rules so that
524                // commands like `/path/to/hook '{"action":"skip"}'` split
525                // correctly into (program, args).
526                let mut parts = shell_words::split(command_str)
527                    .unwrap_or_else(|_| vec![command_str.to_string()]);
528                if parts.is_empty() {
529                    return None;
530                }
531                let path = expand_tilde(&parts.remove(0));
532                Some(base(ResolvedHookKind::Command(path, parts)))
533            }
534            HookCommandType::Http => {
535                let url = cmd.url.clone()?;
536                Some(base(ResolvedHookKind::Http {
537                    url,
538                    headers: None,
539                    allowed_env_vars: None,
540                }))
541            }
542            HookCommandType::Prompt | HookCommandType::Agent => {
543                let prompt = cmd.prompt.clone()?;
544                Some(base(ResolvedHookKind::Prompt { prompt }))
545            }
546        }
547    }
548
549    /// Number of registered hooks.
550    pub fn len(&self) -> usize {
551        self.hooks.len()
552    }
553
554    /// True when no hooks are registered.
555    pub fn is_empty(&self) -> bool {
556        self.hooks.is_empty()
557    }
558
559    /// Dispatch an event to all matching hooks.
560    ///
561    /// Returns the first non-`Continue` `HookResult`. Hooks that fail,
562    /// timeout, or return unparseable output are silently skipped (fail-open).
563    pub async fn dispatch(&self, input: &HookInput) -> HookResult {
564        // Claude SDK host callbacks take precedence when registered.
565        if let Some(fwd) = &self.sdk_forwarder {
566            if let Some(result) = fwd.forward(input).await {
567                return result;
568            }
569        }
570        let event_str = serde_json::to_string(&input.event)
571            .unwrap_or_default()
572            .trim_matches('"')
573            .to_string();
574        let tool_name = input.tool_name.as_deref().unwrap_or("");
575        let empty_args = serde_json::Value::Object(Default::default());
576        let args = input.args.as_ref().unwrap_or(&empty_args);
577
578        for (idx, hook) in self.hooks.iter().enumerate() {
579            // Filter by event name (if registered for a specific event).
580            if let Some(ref ev) = hook.event_name {
581                if !event_names_match(ev, &event_str) {
582                    continue;
583                }
584            }
585            // Filter by tool/arg matcher.
586            if !matches_hook(&hook.matcher, tool_name, args) {
587                continue;
588            }
589            // Skip once-hooks that already ran.
590            if hook.once {
591                let already_ran = {
592                    let guard = self.executed_once.lock().unwrap_or_else(|e| e.into_inner());
593                    guard.contains(&idx)
594                };
595                if already_ran {
596                    continue;
597                }
598            }
599
600            // Async fire-and-forget: spawn and return Continue immediately.
601            if hook.r#async && !hook.async_rewake {
602                let hook_clone = hook.clone();
603                let input_clone = input.clone();
604                let self_clone = self.clone();
605                tokio::spawn(async move {
606                    if let Err(e) = self_clone.run_hook(&hook_clone, idx, &input_clone).await {
607                        tracing::warn!("async hook error: {e}");
608                    }
609                });
610                if hook.once {
611                    self.executed_once
612                        .lock()
613                        .unwrap_or_else(|e| e.into_inner())
614                        .insert(idx);
615                }
616                continue;
617            }
618
619            // asyncRewake: spawn, cancel Agent if hook exits with code 2.
620            if hook.async_rewake {
621                let hook_clone = hook.clone();
622                let input_clone = input.clone();
623                let self_clone = self.clone();
624                let cancel = self.cancel_token.clone();
625                tokio::spawn(async move {
626                    let exit_code = self_clone
627                        .run_command_exit_code(&hook_clone, idx, &input_clone)
628                        .await
629                        .unwrap_or(None);
630                    if exit_code == Some(2) {
631                        tracing::warn!("asyncRewake hook exited with code 2 — cancelling agent");
632                        if let Some(token) = cancel {
633                            token.cancel();
634                        }
635                    }
636                });
637                if hook.once {
638                    self.executed_once
639                        .lock()
640                        .unwrap_or_else(|e| e.into_inner())
641                        .insert(idx);
642                }
643                continue;
644            }
645
646            // Synchronous execution.
647            let hook_display_name = match &hook.kind {
648                ResolvedHookKind::Command(path, _) => path
649                    .file_name()
650                    .map(|n| n.to_string_lossy().into_owned())
651                    .unwrap_or_else(|| "hook".to_string()),
652                ResolvedHookKind::Http { url, .. } => url.clone(),
653                ResolvedHookKind::Prompt { .. } => "prompt-hook".to_string(),
654            };
655            self.emit(AgentEvent::HookStarted {
656                hook_event: event_str.clone(),
657                hook_name: hook_display_name.clone(),
658                status_message: None,
659            });
660            let start = std::time::Instant::now();
661            if let Ok(result) = self.run_hook(hook, idx, input).await {
662                if hook.once {
663                    self.executed_once
664                        .lock()
665                        .unwrap_or_else(|e| e.into_inner())
666                        .insert(idx);
667                }
668                let duration_ms = start.elapsed().as_millis() as u64;
669                let outcome = match &result.action {
670                    HookAction::Continue => "continue".to_string(),
671                    HookAction::Skip => "skip".to_string(),
672                    HookAction::Error(reason) => format!("error: {reason}"),
673                };
674                if let Some(msg) = &result.system_message {
675                    self.emit(AgentEvent::HookSystemMessage { text: msg.clone() });
676                }
677                self.emit(AgentEvent::HookFinished {
678                    hook_event: event_str.clone(),
679                    hook_name: hook_display_name.clone(),
680                    outcome,
681                    duration_ms,
682                });
683                if !matches!(result.action, HookAction::Continue) {
684                    return result;
685                }
686            }
687        }
688        HookResult::continue_default()
689    }
690
691    /// Run a single hook entry and return a `HookResult`.
692    async fn run_hook(
693        &self,
694        hook: &ResolvedHook,
695        #[cfg_attr(not(test), allow(unused_variables))] hook_idx: usize,
696        input: &HookInput,
697    ) -> Result<HookResult> {
698        // In test builds, short-circuit with the pre-canned result if set.
699        #[cfg(test)]
700        if let Some(Some(result)) = self.mock_results.get(hook_idx) {
701            return Ok(result.clone());
702        }
703
704        let hook_timeout = hook
705            .timeout_secs
706            .map(Duration::from_secs)
707            .unwrap_or_else(default_hook_timeout);
708
709        match &hook.kind {
710            ResolvedHookKind::Http {
711                url,
712                headers,
713                allowed_env_vars,
714            } => {
715                let result = run_http_hook(
716                    url,
717                    input,
718                    hook_timeout.as_secs(),
719                    headers.as_ref(),
720                    allowed_env_vars.as_deref(),
721                    hook.fail_mode,
722                )
723                .await;
724                Ok(result)
725            }
726            ResolvedHookKind::Prompt { prompt } => {
727                if let Some(llm) = &self.llm {
728                    let result =
729                        run_prompt_hook(llm.as_ref(), prompt, input, hook_timeout, hook.fail_mode)
730                            .await;
731                    Ok(result)
732                } else {
733                    // No LLM configured — fail-open with a warning.
734                    tracing::warn!(
735                        "prompt hook configured but no LLM provider available; skipping"
736                    );
737                    Ok(HookResult::continue_default())
738                }
739            }
740            ResolvedHookKind::Command(path, args) => {
741                self.run_command_hook(path, args, input, hook_timeout, hook.fail_mode)
742                    .await
743            }
744        }
745    }
746
747    /// Run a command hook and return only the exit code (for asyncRewake).
748    async fn run_command_exit_code(
749        &self,
750        hook: &ResolvedHook,
751        #[cfg_attr(not(test), allow(unused_variables))] hook_idx: usize,
752        input: &HookInput,
753    ) -> Result<Option<i32>> {
754        // In test builds, short-circuit with the pre-canned exit code if set.
755        #[cfg(test)]
756        if let Some(maybe_code) = self.mock_exit_codes.get(hook_idx) {
757            return Ok(*maybe_code);
758        }
759
760        let ResolvedHookKind::Command(path, args) = &hook.kind else {
761            return Ok(None);
762        };
763        let hook_timeout = hook
764            .timeout_secs
765            .map(Duration::from_secs)
766            .unwrap_or_else(default_hook_timeout);
767        let input_json = serde_json::to_string(input).map_err(|e| Error::Config {
768            message: format!("hook input serialize: {e}"),
769        })?;
770        let mut child = Command::new(path)
771            .args(args)
772            .stdin(std::process::Stdio::piped())
773            .stdout(std::process::Stdio::null())
774            .stderr(std::process::Stdio::null())
775            .spawn()
776            .map_err(|e| Error::Config {
777                message: format!("hook spawn {}: {e}", path.display()),
778            })?;
779        let status = timeout(hook_timeout, async {
780            use tokio::io::AsyncWriteExt;
781            if let Some(stdin) = child.stdin.as_mut() {
782                let _ = stdin.write_all(input_json.as_bytes()).await;
783                let _ = stdin.shutdown().await;
784            }
785            child.wait().await
786        })
787        .await;
788        match status {
789            Ok(Ok(s)) => Ok(s.code()),
790            _ => Ok(None),
791        }
792    }
793
794    /// Run a single hook command executable and return a `HookResult`.
795    async fn run_command_hook(
796        &self,
797        path: &PathBuf,
798        args: &[String],
799        input: &HookInput,
800        hook_timeout: Duration,
801        fail_mode: HookFailMode,
802    ) -> Result<HookResult> {
803        let input_json = match serde_json::to_string(input) {
804            Ok(j) => j,
805            Err(e) => {
806                return Ok(HookResult::from_fail_mode(
807                    fail_mode,
808                    &format!("hook input serialize: {e}"),
809                    &input.event,
810                ));
811            }
812        };
813
814        let mut child = match Command::new(path)
815            .args(args)
816            .stdin(std::process::Stdio::piped())
817            .stdout(std::process::Stdio::piped())
818            .stderr(std::process::Stdio::null())
819            .spawn()
820        {
821            Ok(c) => c,
822            Err(e) => {
823                return Ok(HookResult::from_fail_mode(
824                    fail_mode,
825                    &format!("hook spawn {}: {e}", path.display()),
826                    &input.event,
827                ));
828            }
829        };
830
831        // Write stdin then wait for output, respecting per-hook timeout.
832        let output = timeout(hook_timeout, async {
833            use tokio::io::AsyncWriteExt;
834            if let Some(stdin) = child.stdin.as_mut() {
835                let _ = stdin.write_all(input_json.as_bytes()).await;
836                // Close stdin so the child knows input is done.
837                let _ = stdin.shutdown().await;
838            }
839            child.wait_with_output().await
840        })
841        .await;
842
843        let output = match output {
844            Err(_elapsed) => {
845                return Ok(HookResult::from_fail_mode(
846                    fail_mode,
847                    "hook timed out",
848                    &input.event,
849                ));
850            }
851            Ok(Err(e)) => {
852                return Ok(HookResult::from_fail_mode(
853                    fail_mode,
854                    &format!("hook wait {}: {e}", path.display()),
855                    &input.event,
856                ));
857            }
858            Ok(Ok(o)) => o,
859        };
860
861        if !output.status.success() {
862            return Ok(HookResult::from_fail_mode(
863                fail_mode,
864                &format!("hook exited with code {:?}", output.status.code()),
865                &input.event,
866            ));
867        }
868
869        let stdout = String::from_utf8_lossy(&output.stdout);
870        match serde_json::from_str::<HookOutput>(stdout.trim()) {
871            Ok(parsed) => Ok(parsed.into_hook_result()),
872            Err(e) => Ok(HookResult::from_fail_mode(
873                fail_mode,
874                &format!("hook output parse {}: {e}", path.display()),
875                &input.event,
876            )),
877        }
878    }
879}
880
881/// Expand a leading `~` to the home directory.
882fn expand_tilde(path: &str) -> PathBuf {
883    if let Some(rest) = path.strip_prefix("~/") {
884        if let Some(home) = std::env::var_os("HOME") {
885            return PathBuf::from(home).join(rest);
886        }
887    }
888    PathBuf::from(path)
889}
890
891/// Compare event names case-insensitively (config may use PascalCase, wire uses camelCase).
892fn event_names_match(config_name: &str, wire_name: &str) -> bool {
893    config_name.to_lowercase() == wire_name.to_lowercase()
894}
895
896// ── Prompt hook ────────────────────────────────────────────────────
897
898/// Evaluate a prompt template via `llm`, replacing `$ARGUMENTS` with
899/// the serialised `HookInput`. Respects `fail_mode` on timeout or LLM error.
900async fn run_prompt_hook(
901    llm: &dyn ChatProvider,
902    prompt_template: &str,
903    input: &HookInput,
904    hook_timeout: Duration,
905    fail_mode: HookFailMode,
906) -> HookResult {
907    let args_json = serde_json::to_string(input).unwrap_or_else(|_| "{}".to_string());
908    let prompt = prompt_template.replace("$ARGUMENTS", &args_json);
909
910    let messages = [Message::user(prompt)];
911    let completion_future = llm.complete(&messages, &[]);
912    let completion = match timeout(hook_timeout, completion_future).await {
913        Ok(Ok(c)) => c,
914        Ok(Err(e)) => {
915            tracing::warn!("prompt hook LLM error: {e}");
916            return HookResult::from_fail_mode(
917                fail_mode,
918                &format!("prompt hook LLM error: {e}"),
919                &input.event,
920            );
921        }
922        Err(_) => {
923            tracing::warn!("prompt hook timeout");
924            return HookResult::from_fail_mode(fail_mode, "prompt hook timeout", &input.event);
925        }
926    };
927
928    match serde_json::from_str::<HookOutput>(completion.content.trim()) {
929        Ok(output) => output.into_hook_result(),
930        Err(_) => {
931            // Non-JSON or empty response — use fail_mode.
932            HookResult::from_fail_mode(fail_mode, "prompt hook returned non-JSON", &input.event)
933        }
934    }
935}
936
937// ── HTTP hook ──────────────────────────────────────────────────────
938
939/// POST `input` as JSON to `url` and parse the response as `HookOutput`.
940///
941/// Respects `fail_mode`: returns `Continue` on failure when Open,
942/// returns `Error(reason)` on failure when Closed (for gating events).
943pub(crate) async fn run_http_hook(
944    url: &str,
945    input: &HookInput,
946    timeout_secs: u64,
947    headers: Option<&std::collections::HashMap<String, String>>,
948    allowed_env_vars: Option<&[String]>,
949    fail_mode: HookFailMode,
950) -> HookResult {
951    let client_result = reqwest::Client::builder()
952        .timeout(Duration::from_secs(timeout_secs))
953        .connect_timeout(Duration::from_secs(timeout_secs))
954        .build();
955
956    let client = match client_result {
957        Ok(c) => c,
958        Err(_) => {
959            return HookResult::from_fail_mode(
960                fail_mode,
961                "http hook client build failed",
962                &input.event,
963            );
964        }
965    };
966
967    let mut builder = client.post(url).json(input);
968
969    if let Some(headers_map) = headers {
970        for (k, v) in headers_map {
971            let interpolated = interpolate_env_vars(v, allowed_env_vars);
972            builder = builder.header(k.as_str(), interpolated);
973        }
974    }
975
976    let response = match builder.send().await {
977        Ok(r) => r,
978        Err(e) => {
979            tracing::warn!("http hook request failed: {e}");
980            return HookResult::from_fail_mode(
981                fail_mode,
982                &format!("http hook request failed: {e}"),
983                &input.event,
984            );
985        }
986    };
987
988    let body = match response.text().await {
989        Ok(b) => b,
990        Err(e) => {
991            tracing::warn!("http hook response read failed: {e}");
992            return HookResult::from_fail_mode(
993                fail_mode,
994                &format!("http hook response read failed: {e}"),
995                &input.event,
996            );
997        }
998    };
999
1000    match serde_json::from_str::<HookOutput>(body.trim()) {
1001        Ok(output) => output.into_hook_result(),
1002        Err(e) => {
1003            tracing::warn!("http hook response parse failed: {e}");
1004            HookResult::from_fail_mode(
1005                fail_mode,
1006                &format!("http hook response parse failed: {e}"),
1007                &input.event,
1008            )
1009        }
1010    }
1011}
1012
1013/// Interpolate `$VAR` and `${VAR}` patterns in a string, replacing only
1014/// variables in `allowed_env_vars`. Non-allowed variables are replaced with
1015/// empty string.
1016fn interpolate_env_vars(value: &str, allowed: Option<&[String]>) -> String {
1017    let mut result = value.to_string();
1018
1019    // Replace ${VAR} form first, then $VAR form.
1020    for (pattern, var_name) in find_env_var_refs(&result) {
1021        let replacement = if is_allowed(&var_name, allowed) {
1022            std::env::var(&var_name).unwrap_or_default()
1023        } else {
1024            String::new()
1025        };
1026        result = result.replacen(&pattern, &replacement, 1);
1027    }
1028    result
1029}
1030
1031/// Find all `${VAR}` and `$VAR` patterns in `s`, returning `(full_pattern, var_name)` pairs.
1032fn find_env_var_refs(s: &str) -> Vec<(String, String)> {
1033    let mut refs = Vec::new();
1034    let bytes = s.as_bytes();
1035    let mut i = 0;
1036    while i < bytes.len() {
1037        if bytes[i] == b'$' {
1038            if i + 1 < bytes.len() && bytes[i + 1] == b'{' {
1039                // ${VAR} form
1040                if let Some(end) = s[i + 2..].find('}') {
1041                    let var_name = &s[i + 2..i + 2 + end];
1042                    refs.push((format!("${{{var_name}}}"), var_name.to_string()));
1043                    i += 3 + end;
1044                    continue;
1045                }
1046            } else {
1047                // $VAR form — collect alphanumeric + underscore
1048                let start = i + 1;
1049                let mut end = start;
1050                while end < bytes.len()
1051                    && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_')
1052                {
1053                    end += 1;
1054                }
1055                if end > start {
1056                    let var_name = &s[start..end];
1057                    refs.push((format!("${var_name}"), var_name.to_string()));
1058                    i = end;
1059                    continue;
1060                }
1061            }
1062        }
1063        i += 1;
1064    }
1065    refs
1066}
1067
1068fn is_allowed(var: &str, allowed: Option<&[String]>) -> bool {
1069    let Some(list) = allowed else { return true };
1070    list.iter().any(|a| a == var)
1071}
1072
1073// ── helpers ────────────────────────────────────────────────────────
1074
1075/// Check whether `path` is a regular file with an executable bit set.
1076///
1077/// On Unix/macOS this checks the owner/group/world execute permission
1078/// bits. On Windows this function always returns `false` because the
1079/// concept of an executable bit doesn't exist on that platform.
1080fn is_executable(path: &std::path::Path) -> bool {
1081    #[cfg(unix)]
1082    {
1083        use std::os::unix::fs::PermissionsExt;
1084        path.is_file()
1085            && std::fs::metadata(path)
1086                .map(|m| m.permissions().mode() & 0o111 != 0)
1087                .unwrap_or(false)
1088    }
1089    #[cfg(not(unix))]
1090    {
1091        // On non-Unix platforms we fall back to checking the extension.
1092        let _ = path;
1093        false
1094    }
1095}
1096
1097// ── tests ──────────────────────────────────────────────────────────
1098
1099#[cfg(test)]
1100mod tests {
1101    use super::*;
1102
1103    /// Convenience helper: build a tool-call `HookInput` for tests.
1104    fn make_tool_input(event: HookEvent, tool: &str, args: serde_json::Value) -> HookInput {
1105        HookInput {
1106            event,
1107            tool_name: Some(tool.to_string()),
1108            args: Some(args),
1109            mode: "ask".to_string(),
1110            content: None,
1111            message: None,
1112            depth: None,
1113            reason: None,
1114            error: None,
1115        }
1116    }
1117
1118    // ── JSON parsing ────────────────────────────────────────────
1119
1120    #[test]
1121    fn hook_output_parse_continue() {
1122        let json = r#"{"action":"continue"}"#;
1123        let out: HookOutput = serde_json::from_str(json).unwrap();
1124        assert!(matches!(out.action, Some(JsonAction::Continue)));
1125        assert!(out.message.is_none());
1126    }
1127
1128    #[test]
1129    fn hook_output_parse_skip() {
1130        let json = r#"{"action":"skip","message":"blocked"}"#;
1131        let out: HookOutput = serde_json::from_str(json).unwrap();
1132        assert!(matches!(out.action, Some(JsonAction::Skip)));
1133        assert_eq!(out.message.as_deref(), Some("blocked"));
1134    }
1135
1136    #[test]
1137    fn hook_output_parse_error() {
1138        let json = r#"{"action":"error","message":"not allowed"}"#;
1139        let out: HookOutput = serde_json::from_str(json).unwrap();
1140        assert!(matches!(out.action, Some(JsonAction::Error)));
1141        assert_eq!(out.message.as_deref(), Some("not allowed"));
1142    }
1143
1144    #[test]
1145    fn hook_output_parse_camel_case() {
1146        let json = r#"{"action":"continue"}"#;
1147        let out: HookOutput = serde_json::from_str(json).unwrap();
1148        assert!(matches!(out.action, Some(JsonAction::Continue)));
1149
1150        let json = r#"{"action":"skip","message":"nope"}"#;
1151        let out: HookOutput = serde_json::from_str(json).unwrap();
1152        assert!(matches!(out.action, Some(JsonAction::Skip)));
1153    }
1154
1155    #[test]
1156    fn hook_output_parse_missing_message() {
1157        // message is optional
1158        let json = r#"{"action":"error"}"#;
1159        let out: HookOutput = serde_json::from_str(json).unwrap();
1160        assert!(matches!(out.action, Some(JsonAction::Error)));
1161        assert!(out.message.is_none());
1162    }
1163
1164    #[test]
1165    fn hook_output_into_hook_action_continue() {
1166        let json = r#"{"action":"continue"}"#;
1167        let out: HookOutput = serde_json::from_str(json).unwrap();
1168        let result = out.into_hook_result();
1169        assert!(matches!(result.action, HookAction::Continue));
1170    }
1171
1172    #[test]
1173    fn hook_output_into_hook_action_skip() {
1174        let json = r#"{"action":"skip","message":"blocked"}"#;
1175        let out: HookOutput = serde_json::from_str(json).unwrap();
1176        let result = out.into_hook_result();
1177        assert!(matches!(result.action, HookAction::Skip));
1178    }
1179
1180    #[test]
1181    fn hook_output_into_hook_action_error_with_message() {
1182        let json = r#"{"action":"error","message":"not allowed"}"#;
1183        let out: HookOutput = serde_json::from_str(json).unwrap();
1184        let result = out.into_hook_result();
1185        assert!(matches!(result.action, HookAction::Error(ref msg) if msg == "not allowed"));
1186    }
1187
1188    #[test]
1189    fn hook_output_into_hook_action_error_without_message() {
1190        let json = r#"{"action":"error"}"#;
1191        let out: HookOutput = serde_json::from_str(json).unwrap();
1192        let result = out.into_hook_result();
1193        assert!(
1194            matches!(result.action, HookAction::Error(ref msg) if msg == "external hook blocked")
1195        );
1196    }
1197
1198    // ── Runner semantics ────────────────────────────────────────
1199
1200    #[test]
1201    fn empty_runner_returns_continue() {
1202        let runner = ExternalHookRunner::discover(&[]);
1203        assert!(runner.is_empty());
1204        assert_eq!(runner.len(), 0);
1205    }
1206
1207    #[test]
1208    fn discover_skips_non_executable() {
1209        // Create a temp dir with a non-executable file.
1210        let tmp = tempfile::tempdir().unwrap();
1211        let non_exec = tmp.path().join("script.sh");
1212        std::fs::write(&non_exec, "echo hello").unwrap();
1213        // Ensure it's not executable.
1214        #[cfg(unix)]
1215        {
1216            use std::os::unix::fs::PermissionsExt;
1217            let mut perms = std::fs::metadata(&non_exec).unwrap().permissions();
1218            perms.set_mode(0o644);
1219            std::fs::set_permissions(&non_exec, perms).unwrap();
1220        }
1221        let runner = ExternalHookRunner::discover(&[tmp.path().to_path_buf()]);
1222        assert!(runner.is_empty());
1223    }
1224
1225    #[test]
1226    #[cfg(unix)]
1227    fn discover_collects_executable() {
1228        use std::os::unix::fs::PermissionsExt;
1229        let tmp = tempfile::tempdir().unwrap();
1230        let exec = tmp.path().join("hook.sh");
1231        std::fs::write(&exec, "#!/bin/sh\necho '{\"action\":\"continue\"}'").unwrap();
1232        let mut perms = std::fs::metadata(&exec).unwrap().permissions();
1233        perms.set_mode(0o755);
1234        std::fs::set_permissions(&exec, perms).unwrap();
1235        let runner = ExternalHookRunner::discover(&[tmp.path().to_path_buf()]);
1236        assert_eq!(runner.len(), 1);
1237    }
1238
1239    #[test]
1240    fn is_executable_rejects_directory() {
1241        let tmp = tempfile::tempdir().unwrap();
1242        let dir = tmp.path().join("subdir");
1243        std::fs::create_dir(&dir).unwrap();
1244        // Even if the directory has the x bit, is_executable requires is_file().
1245        assert!(!is_executable(&dir));
1246    }
1247
1248    #[test]
1249    fn is_executable_rejects_nonexistent() {
1250        assert!(!is_executable(std::path::Path::new(
1251            "/nonexistent/path/script"
1252        )));
1253    }
1254
1255    // ── Integration tests: run a compiled hook binary ───────────
1256    //
1257    // We use the `hook_echo` compiled Rust binary (tests/bin/hook_echo.rs)
1258    // instead of shell scripts to avoid /bin/sh startup latency on
1259    // CPU-starved systems (e.g. while cargo-mutants workers hold all cores).
1260    // `hook_echo <json>` prints <json> to stdout and exits 0.
1261    // `hook_echo --exit <n>` exits with code n without output.
1262
1263    #[tokio::test]
1264    async fn dispatch_runs_executable_hook_and_returns_decision() {
1265        use crate::hooks::config::{HookCommand, HookCommandType, HookMatcher, HooksConfig};
1266        // Use a mock result so this test doesn't spawn any OS process.
1267        // The dispatch logic under test: runner finds the hook, calls run_hook,
1268        // returns the result.  Mock replaces the real process execution.
1269        let cfg = HooksConfig {
1270            events: std::collections::HashMap::from([(
1271                "PreToolCall".to_string(),
1272                vec![HookMatcher {
1273                    matcher: None,
1274                    hooks: vec![HookCommand {
1275                        r#type: HookCommandType::Command,
1276                        command: Some("/bin/true".to_string()), // never actually spawned
1277                        timeout: 5,
1278                        ..Default::default()
1279                    }],
1280                }],
1281            )]),
1282        };
1283        let mock_skip = HookResult {
1284            action: HookAction::Skip,
1285            system_message: Some("blocked by test hook".to_string()),
1286            ..Default::default()
1287        };
1288        let runner = ExternalHookRunner::from_config(cfg).with_mock_results(vec![Some(mock_skip)]);
1289        let input = make_tool_input(
1290            HookEvent::PreToolCall,
1291            "Bash",
1292            serde_json::json!({"command": "ls"}),
1293        );
1294        let result = runner.dispatch(&input).await;
1295        assert!(
1296            matches!(result.action, HookAction::Skip),
1297            "expected Skip; got {:?}",
1298            result.action
1299        );
1300    }
1301
1302    #[tokio::test]
1303    async fn dispatch_returns_continue_when_no_hooks() {
1304        let runner = ExternalHookRunner::discover(&[]);
1305        let input = make_tool_input(
1306            HookEvent::PreToolCall,
1307            "Read",
1308            serde_json::json!({"path": "foo.txt"}),
1309        );
1310        let result = runner.dispatch(&input).await;
1311        assert!(matches!(result.action, HookAction::Continue));
1312    }
1313
1314    #[tokio::test]
1315    #[cfg(unix)]
1316    async fn dispatch_treats_timeout_as_continue() {
1317        use std::os::unix::fs::PermissionsExt;
1318        let tmp = tempfile::tempdir().unwrap();
1319        let hook_path = tmp.path().join("hang.sh");
1320
1321        // A hook that hangs (sleeps 30s — longer than the 5s timeout).
1322        let script = "#!/bin/sh\nsleep 30\n";
1323        std::fs::write(&hook_path, script).unwrap();
1324        let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
1325        perms.set_mode(0o755);
1326        std::fs::set_permissions(&hook_path, perms).unwrap();
1327
1328        let runner = ExternalHookRunner::discover(&[tmp.path().to_path_buf()]);
1329        assert_eq!(runner.len(), 1);
1330        let input = make_tool_input(
1331            HookEvent::PreToolCall,
1332            "Bash",
1333            serde_json::json!({"command": "ls"}),
1334        );
1335        let result = runner.dispatch(&input).await;
1336        // Timeout → fail-open → Continue.
1337        assert!(matches!(result.action, HookAction::Continue));
1338    }
1339
1340    #[tokio::test]
1341    #[cfg(unix)]
1342    async fn dispatch_treats_bad_output_as_continue() {
1343        use std::os::unix::fs::PermissionsExt;
1344        let tmp = tempfile::tempdir().unwrap();
1345        let hook_path = tmp.path().join("bad.sh");
1346
1347        // A hook that outputs invalid JSON.
1348        let script = "#!/bin/sh\necho 'not json'\n";
1349        std::fs::write(&hook_path, script).unwrap();
1350        let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
1351        perms.set_mode(0o755);
1352        std::fs::set_permissions(&hook_path, perms).unwrap();
1353
1354        let runner = ExternalHookRunner::discover(&[tmp.path().to_path_buf()]);
1355        assert_eq!(runner.len(), 1);
1356        let input = make_tool_input(
1357            HookEvent::PreToolCall,
1358            "Bash",
1359            serde_json::json!({"command": "ls"}),
1360        );
1361        let result = runner.dispatch(&input).await;
1362        // Bad output → fail-open → Continue.
1363        assert!(matches!(result.action, HookAction::Continue));
1364    }
1365
1366    #[tokio::test]
1367    async fn dispatch_short_circuits_on_first_non_continue() {
1368        use crate::hooks::config::{HookCommand, HookCommandType, HookMatcher, HooksConfig};
1369        // Two hooks registered for PreToolCall.
1370        // hook0 (mock) → Skip  → dispatch should short-circuit and NOT call hook1.
1371        // hook1 (mock) → Continue (should NOT be reached).
1372        let cfg = HooksConfig {
1373            events: std::collections::HashMap::from([(
1374                "PreToolCall".to_string(),
1375                vec![HookMatcher {
1376                    matcher: None,
1377                    hooks: vec![
1378                        HookCommand {
1379                            r#type: HookCommandType::Command,
1380                            command: Some("/bin/true".to_string()),
1381                            timeout: 5,
1382                            ..Default::default()
1383                        },
1384                        HookCommand {
1385                            r#type: HookCommandType::Command,
1386                            command: Some("/bin/true".to_string()),
1387                            timeout: 5,
1388                            ..Default::default()
1389                        },
1390                    ],
1391                }],
1392            )]),
1393        };
1394        let mock_skip = HookResult {
1395            action: HookAction::Skip,
1396            system_message: Some("first".to_string()),
1397            ..Default::default()
1398        };
1399        // Index 0 → Skip; index 1 → Continue. The dispatch must stop at index 0.
1400        let runner =
1401            ExternalHookRunner::from_config(cfg).with_mock_results(vec![Some(mock_skip), None]);
1402        let input = make_tool_input(
1403            HookEvent::PreToolCall,
1404            "Write",
1405            serde_json::json!({"path": "test.txt"}),
1406        );
1407        let result = runner.dispatch(&input).await;
1408        assert!(matches!(result.action, HookAction::Skip));
1409    }
1410
1411    // ── Goal 204 new tests ──────────────────────────────────────
1412
1413    #[test]
1414    fn new_hook_events_serialize_camel_case() {
1415        let cases: &[(&str, HookEvent)] = &[
1416            ("postToolCallFailure", HookEvent::PostToolCallFailure),
1417            ("permissionDenied", HookEvent::PermissionDenied),
1418            ("sessionStart", HookEvent::SessionStart),
1419            ("sessionEnd", HookEvent::SessionEnd),
1420            ("userPromptSubmit", HookEvent::UserPromptSubmit),
1421            ("stop", HookEvent::Stop),
1422            ("subagentStart", HookEvent::SubagentStart),
1423            ("subagentStop", HookEvent::SubagentStop),
1424            ("notification", HookEvent::Notification),
1425            ("setup", HookEvent::Setup),
1426        ];
1427        for (expected, event) in cases {
1428            let json = serde_json::to_string(event).unwrap();
1429            assert_eq!(
1430                json,
1431                format!("\"{expected}\""),
1432                "wrong camelCase for {expected}"
1433            );
1434        }
1435    }
1436
1437    #[test]
1438    fn hook_input_optional_fields_absent_when_none() {
1439        let input = HookInput {
1440            event: HookEvent::UserPromptSubmit,
1441            tool_name: None,
1442            args: None,
1443            mode: "ask".to_string(),
1444            content: Some("hello".to_string()),
1445            message: None,
1446            depth: None,
1447            reason: None,
1448            error: None,
1449        };
1450        let json = serde_json::to_string(&input).unwrap();
1451        // tool_name and args should be absent
1452        assert!(
1453            !json.contains("tool_name") && !json.contains("toolName"),
1454            "toolName should be absent"
1455        );
1456        assert!(!json.contains("\"args\""), "args should be absent");
1457        assert!(json.contains("\"hello\""), "content should be present");
1458    }
1459
1460    #[test]
1461    fn hook_input_tool_name_present_for_tool_events() {
1462        let input = make_tool_input(
1463            HookEvent::PreToolCall,
1464            "Bash",
1465            serde_json::json!({"command": "ls"}),
1466        );
1467        let json = serde_json::to_string(&input).unwrap();
1468        assert!(json.contains("Bash"));
1469        assert!(json.contains("command"));
1470    }
1471
1472    // ── Goal 205 tests ─────────────────────────────────────────────
1473
1474    #[test]
1475    fn hook_output_parses_additional_context() {
1476        let json = r#"{"action":"continue","additionalContext":"extra info"}"#;
1477        let output: HookOutput = serde_json::from_str(json).unwrap();
1478        let result = output.into_hook_result();
1479        assert!(matches!(result.action, HookAction::Continue));
1480        assert_eq!(result.additional_context.as_deref(), Some("extra info"));
1481    }
1482
1483    #[test]
1484    fn hook_output_parses_updated_input() {
1485        let json = r#"{"action":"continue","updatedInput":{"command":"ls -la"}}"#;
1486        let output: HookOutput = serde_json::from_str(json).unwrap();
1487        let result = output.into_hook_result();
1488        assert_eq!(
1489            result.updated_input,
1490            Some(serde_json::json!({"command": "ls -la"}))
1491        );
1492    }
1493
1494    #[test]
1495    fn hook_output_parses_permission_decision_allow() {
1496        let json = r#"{"action":"continue","permissionDecision":"allow","permissionDecisionReason":"safe"}"#;
1497        let output: HookOutput = serde_json::from_str(json).unwrap();
1498        let result = output.into_hook_result();
1499        assert_eq!(result.permission_decision, Some(PermissionDecision::Allow));
1500        assert_eq!(result.permission_decision_reason.as_deref(), Some("safe"));
1501    }
1502
1503    #[test]
1504    fn hook_output_parses_permission_decision_deny() {
1505        let json = r#"{"action":"skip","permissionDecision":"deny"}"#;
1506        let output: HookOutput = serde_json::from_str(json).unwrap();
1507        let result = output.into_hook_result();
1508        assert_eq!(result.permission_decision, Some(PermissionDecision::Deny));
1509        assert!(matches!(result.action, HookAction::Skip));
1510    }
1511
1512    #[test]
1513    fn hook_output_parses_permission_decision_passthrough() {
1514        let json = r#"{"action":"continue","permissionDecision":"passthrough"}"#;
1515        let output: HookOutput = serde_json::from_str(json).unwrap();
1516        let result = output.into_hook_result();
1517        assert_eq!(
1518            result.permission_decision,
1519            Some(PermissionDecision::Passthrough)
1520        );
1521    }
1522
1523    #[test]
1524    fn hook_result_default_is_continue_with_no_extras() {
1525        let result = HookResult::default();
1526        assert!(matches!(result.action, HookAction::Continue));
1527        assert!(result.additional_context.is_none());
1528        assert!(result.updated_input.is_none());
1529        assert!(result.system_message.is_none());
1530        assert!(!result.suppress_output);
1531        assert!(result.permission_decision.is_none());
1532        assert!(result.permission_decision_reason.is_none());
1533    }
1534
1535    #[test]
1536    fn hook_output_system_message_included() {
1537        let json = r#"{"action":"continue","systemMessage":"Please review before proceeding"}"#;
1538        let output: HookOutput = serde_json::from_str(json).unwrap();
1539        let result = output.into_hook_result();
1540        assert_eq!(
1541            result.system_message.as_deref(),
1542            Some("Please review before proceeding")
1543        );
1544    }
1545
1546    #[test]
1547    fn hook_output_suppress_output_default_false() {
1548        let json = r#"{"action":"continue"}"#;
1549        let output: HookOutput = serde_json::from_str(json).unwrap();
1550        let result = output.into_hook_result();
1551        assert!(!result.suppress_output);
1552    }
1553
1554    #[test]
1555    fn hook_output_suppress_output_true() {
1556        let json = r#"{"action":"continue","suppressOutput":true}"#;
1557        let output: HookOutput = serde_json::from_str(json).unwrap();
1558        let result = output.into_hook_result();
1559        assert!(result.suppress_output);
1560    }
1561
1562    // ── Goal 207 HTTP hook tests ───────────────────────────────────
1563
1564    fn make_non_tool_input() -> HookInput {
1565        HookInput {
1566            event: HookEvent::UserPromptSubmit,
1567            tool_name: None,
1568            args: None,
1569            mode: "ask".to_string(),
1570            content: Some("hello".to_string()),
1571            message: None,
1572            depth: None,
1573            reason: None,
1574            error: None,
1575        }
1576    }
1577
1578    #[tokio::test]
1579    async fn http_hook_posts_json_input_and_parses_response() {
1580        let mut server = mockito::Server::new_async().await;
1581        let mock = server
1582            .mock("POST", "/hook")
1583            .with_status(200)
1584            .with_header("content-type", "application/json")
1585            .with_body(r#"{"action":"skip","message":"blocked by http hook"}"#)
1586            .create_async()
1587            .await;
1588
1589        let input = make_non_tool_input();
1590        let url = format!("{}/hook", server.url());
1591        let result = run_http_hook(&url, &input, 10, None, None, HookFailMode::Open).await;
1592        assert!(matches!(result.action, HookAction::Skip));
1593        mock.assert_async().await;
1594    }
1595
1596    #[tokio::test]
1597    async fn http_hook_parses_continue_response() {
1598        let mut server = mockito::Server::new_async().await;
1599        server
1600            .mock("POST", "/hook")
1601            .with_status(200)
1602            .with_header("content-type", "application/json")
1603            .with_body(r#"{"action":"continue"}"#)
1604            .create_async()
1605            .await;
1606
1607        let input = make_non_tool_input();
1608        let url = format!("{}/hook", server.url());
1609        let result = run_http_hook(&url, &input, 10, None, None, HookFailMode::Open).await;
1610        assert!(matches!(result.action, HookAction::Continue));
1611    }
1612
1613    #[tokio::test]
1614    async fn http_hook_connection_error_returns_continue() {
1615        // Connect to a port that doesn't exist — should fail-open.
1616        let input = make_non_tool_input();
1617        let result = run_http_hook(
1618            "http://127.0.0.1:19999/hook",
1619            &input,
1620            2,
1621            None,
1622            None,
1623            HookFailMode::Open,
1624        )
1625        .await;
1626        assert!(matches!(result.action, HookAction::Continue));
1627    }
1628
1629    #[tokio::test]
1630    async fn http_hook_bad_json_response_returns_continue() {
1631        let mut server = mockito::Server::new_async().await;
1632        server
1633            .mock("POST", "/hook")
1634            .with_status(200)
1635            .with_body("not json at all")
1636            .create_async()
1637            .await;
1638
1639        let input = make_non_tool_input();
1640        let url = format!("{}/hook", server.url());
1641        let result = run_http_hook(&url, &input, 10, None, None, HookFailMode::Open).await;
1642        assert!(matches!(result.action, HookAction::Continue));
1643    }
1644
1645    #[test]
1646    fn env_var_interpolation_respects_allowlist() {
1647        std::env::set_var("MY_TOKEN", "secret123");
1648        let allowed = vec!["MY_TOKEN".to_string()];
1649        let result = interpolate_env_vars("Bearer $MY_TOKEN", Some(&allowed));
1650        assert_eq!(result, "Bearer secret123");
1651    }
1652
1653    #[test]
1654    fn env_var_interpolation_empty_for_non_allowed() {
1655        std::env::set_var("BLOCKED_VAR", "should-not-appear");
1656        let allowed = vec!["OTHER_VAR".to_string()];
1657        let result = interpolate_env_vars("Bearer $BLOCKED_VAR", Some(&allowed));
1658        assert_eq!(result, "Bearer ");
1659    }
1660
1661    #[test]
1662    fn env_var_interpolation_curly_brace_form() {
1663        std::env::set_var("API_KEY", "mykey");
1664        let allowed = vec!["API_KEY".to_string()];
1665        let result = interpolate_env_vars("key=${API_KEY}", Some(&allowed));
1666        assert_eq!(result, "key=mykey");
1667    }
1668
1669    #[test]
1670    fn env_var_interpolation_no_allowlist_replaces_all() {
1671        std::env::set_var("UNGUARDED", "value");
1672        let result = interpolate_env_vars("$UNGUARDED", None);
1673        assert_eq!(result, "value");
1674    }
1675
1676    // ── Goal 208 Prompt hook tests ─────────────────────────────────
1677
1678    #[tokio::test]
1679    async fn prompt_hook_replaces_arguments_placeholder() {
1680        use crate::llm::{Completion, MockProvider};
1681        // The mock provider will capture what prompt it received.
1682        let captured = Arc::new(std::sync::Mutex::new(String::new()));
1683        let c = captured.clone();
1684
1685        // We can't easily intercept the prompt with MockProvider, so just
1686        // verify the integration: mock returns skip JSON.
1687        let provider = MockProvider::new(vec![Completion {
1688            content: r#"{"action":"skip","message":"prompt blocked"}"#.to_string(),
1689            ..Default::default()
1690        }]);
1691        drop(c); // suppress unused warning
1692
1693        let input = make_non_tool_input();
1694        let result = run_prompt_hook(
1695            &provider,
1696            "Is this safe? $ARGUMENTS",
1697            &input,
1698            Duration::from_secs(10),
1699            HookFailMode::Open,
1700        )
1701        .await;
1702        assert!(matches!(result.action, HookAction::Skip));
1703    }
1704
1705    #[tokio::test]
1706    async fn prompt_hook_uses_llm_response_continue() {
1707        use crate::llm::{Completion, MockProvider};
1708        let provider = MockProvider::new(vec![Completion {
1709            content: r#"{"action":"continue"}"#.to_string(),
1710            ..Default::default()
1711        }]);
1712        let input = make_non_tool_input();
1713        let result = run_prompt_hook(
1714            &provider,
1715            "Check: $ARGUMENTS",
1716            &input,
1717            Duration::from_secs(10),
1718            HookFailMode::Open,
1719        )
1720        .await;
1721        assert!(matches!(result.action, HookAction::Continue));
1722    }
1723
1724    #[tokio::test]
1725    async fn prompt_hook_falls_back_on_non_json_response() {
1726        use crate::llm::{Completion, MockProvider};
1727        let provider = MockProvider::new(vec![Completion {
1728            content: "Sorry, I cannot evaluate this.".to_string(),
1729            ..Default::default()
1730        }]);
1731        let input = make_non_tool_input();
1732        let result = run_prompt_hook(
1733            &provider,
1734            "Is this safe? $ARGUMENTS",
1735            &input,
1736            Duration::from_secs(10),
1737            HookFailMode::Open,
1738        )
1739        .await;
1740        // Non-JSON → fail-open → Continue.
1741        assert!(matches!(result.action, HookAction::Continue));
1742    }
1743
1744    #[tokio::test]
1745    async fn no_llm_prompt_hook_returns_continue() {
1746        use crate::hooks::config::HooksConfig;
1747        let json = r#"{"PreToolCall":[{"hooks":[{"type":"prompt","prompt":"Is this safe? $ARGUMENTS"}]}]}"#;
1748        let cfg: HooksConfig = serde_json::from_str(json).unwrap();
1749        // No LLM provided.
1750        let runner = ExternalHookRunner::from_config_with_llm(cfg, None);
1751        assert_eq!(runner.len(), 1); // hook is registered
1752
1753        let input = make_tool_input(HookEvent::PreToolCall, "Bash", serde_json::json!({}));
1754        let result = runner.dispatch(&input).await;
1755        // Should fail-open because no LLM is configured.
1756        assert!(matches!(result.action, HookAction::Continue));
1757    }
1758
1759    // ── Goal 206 tests ─────────────────────────────────────────────
1760
1761    #[test]
1762    fn from_config_creates_hooks_from_json() {
1763        use crate::hooks::config::HooksConfig;
1764        let json = r#"{"PreToolCall":[{"matcher":"Bash","hooks":[{"type":"command","command":"/usr/bin/true"}]}]}"#;
1765        let cfg: HooksConfig = serde_json::from_str(json).unwrap();
1766        let runner = ExternalHookRunner::from_config(cfg);
1767        assert_eq!(runner.len(), 1);
1768    }
1769
1770    #[test]
1771    fn from_config_http_type_is_registered() {
1772        use crate::hooks::config::HooksConfig;
1773        // http type is now supported and should be registered
1774        let json = r#"{"PreToolCall":[{"hooks":[{"type":"http","url":"https://example.com"}]}]}"#;
1775        let cfg: HooksConfig = serde_json::from_str(json).unwrap();
1776        let runner = ExternalHookRunner::from_config(cfg);
1777        assert_eq!(runner.len(), 1);
1778    }
1779
1780    #[test]
1781    fn from_config_prompt_without_prompt_field_is_skipped() {
1782        use crate::hooks::config::HooksConfig;
1783        // prompt type without a prompt field → None from resolve_command → skipped
1784        let json = r#"{"PreToolCall":[{"hooks":[{"type":"prompt"}]}]}"#;
1785        let cfg: HooksConfig = serde_json::from_str(json).unwrap();
1786        let runner = ExternalHookRunner::from_config(cfg);
1787        assert_eq!(runner.len(), 0);
1788    }
1789
1790    #[test]
1791    fn from_config_http_without_url_is_skipped() {
1792        use crate::hooks::config::HooksConfig;
1793        // http type without a url field → None from resolve_command → skipped
1794        let json = r#"{"PreToolCall":[{"hooks":[{"type":"http"}]}]}"#;
1795        let cfg: HooksConfig = serde_json::from_str(json).unwrap();
1796        let runner = ExternalHookRunner::from_config(cfg);
1797        assert_eq!(runner.len(), 0);
1798    }
1799
1800    #[test]
1801    fn from_config_empty_config_gives_empty_runner() {
1802        use crate::hooks::config::HooksConfig;
1803        let runner = ExternalHookRunner::from_config(HooksConfig::default());
1804        assert!(runner.is_empty());
1805    }
1806
1807    #[tokio::test]
1808    async fn from_config_respects_matcher_event_filter() {
1809        use crate::hooks::config::{HookCommand, HookCommandType, HookMatcher, HooksConfig};
1810        // Hook registered for "PostToolCall" matching "Bash".
1811        // The mock result ensures we never spawn a process; we only test the event-filter logic.
1812        let cfg = HooksConfig {
1813            events: std::collections::HashMap::from([(
1814                "PostToolCall".to_string(),
1815                vec![HookMatcher {
1816                    matcher: Some("Bash".to_string()),
1817                    hooks: vec![HookCommand {
1818                        r#type: HookCommandType::Command,
1819                        command: Some("/bin/true".to_string()),
1820                        timeout: 5,
1821                        ..Default::default()
1822                    }],
1823                }],
1824            )]),
1825        };
1826        let mock_skip = HookResult {
1827            action: HookAction::Skip,
1828            ..Default::default()
1829        };
1830        let runner = ExternalHookRunner::from_config(cfg).with_mock_results(vec![Some(mock_skip)]);
1831
1832        // Dispatch PreToolCall — should NOT trigger the PostToolCall hook.
1833        let pre_input = make_tool_input(HookEvent::PreToolCall, "Bash", serde_json::json!({}));
1834        let result = runner.dispatch(&pre_input).await;
1835        assert!(
1836            matches!(result.action, HookAction::Continue),
1837            "PreToolCall should not trigger PostToolCall hook"
1838        );
1839
1840        // Dispatch PostToolCall — SHOULD trigger the mock Skip.
1841        let post_input = make_tool_input(HookEvent::PostToolCall, "Bash", serde_json::json!({}));
1842        let result = runner.dispatch(&post_input).await;
1843        assert!(
1844            matches!(result.action, HookAction::Skip),
1845            "PostToolCall should trigger hook"
1846        );
1847    }
1848
1849    // ── Goal 209 async / once tests ───────────────────────────────
1850
1851    #[tokio::test]
1852    async fn once_hook_runs_only_first_time() {
1853        use crate::hooks::config::{HookCommand, HookCommandType, HookMatcher, HooksConfig};
1854        // Hook returns "Skip" on first invocation (via mock).
1855        // With `once: true`, the second dispatch skips the hook entirely → "Continue".
1856        let cfg = HooksConfig {
1857            events: std::collections::HashMap::from([(
1858                "PreToolCall".to_string(),
1859                vec![HookMatcher {
1860                    matcher: None,
1861                    hooks: vec![HookCommand {
1862                        r#type: HookCommandType::Command,
1863                        command: Some("/bin/true".to_string()),
1864                        once: true,
1865                        timeout: 5,
1866                        ..Default::default()
1867                    }],
1868                }],
1869            )]),
1870        };
1871        let mock_skip = HookResult {
1872            action: HookAction::Skip,
1873            ..Default::default()
1874        };
1875        let runner = ExternalHookRunner::from_config(cfg).with_mock_results(vec![Some(mock_skip)]);
1876        let input = make_tool_input(HookEvent::PreToolCall, "Bash", serde_json::json!({}));
1877        let first = runner.dispatch(&input).await;
1878        let second = runner.dispatch(&input).await;
1879        assert!(
1880            matches!(first.action, HookAction::Skip),
1881            "first dispatch must run the hook → Skip"
1882        );
1883        assert!(
1884            matches!(second.action, HookAction::Continue),
1885            "second dispatch must skip the once-hook → Continue"
1886        );
1887    }
1888
1889    #[tokio::test]
1890    #[cfg(unix)]
1891    async fn async_hook_returns_continue_immediately() {
1892        use crate::hooks::config::{HookCommand, HookCommandType, HookMatcher, HooksConfig};
1893        let tmp = tempfile::tempdir().unwrap();
1894        let hook_path = tmp.path().join("slow_hook.sh");
1895        // Hook that sleeps 10s — if async works, dispatch should return immediately.
1896        // No stdin read: avoids stalling before the sleep starts under CPU load.
1897        std::fs::write(
1898            &hook_path,
1899            "#!/bin/sh\nsleep 10\necho '{\"action\":\"skip\"}'\n",
1900        )
1901        .unwrap();
1902        {
1903            use std::os::unix::fs::PermissionsExt;
1904            let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
1905            perms.set_mode(0o755);
1906            std::fs::set_permissions(&hook_path, perms).unwrap();
1907        }
1908        let cfg = HooksConfig {
1909            events: std::collections::HashMap::from([(
1910                "PreToolCall".to_string(),
1911                vec![HookMatcher {
1912                    matcher: None,
1913                    hooks: vec![HookCommand {
1914                        r#type: HookCommandType::Command,
1915                        command: Some(hook_path.to_string_lossy().to_string()),
1916                        r#async: true,
1917                        timeout: 5,
1918                        ..Default::default()
1919                    }],
1920                }],
1921            )]),
1922        };
1923        let runner = ExternalHookRunner::from_config(cfg);
1924        let input = make_tool_input(HookEvent::PreToolCall, "Bash", serde_json::json!({}));
1925        let start = std::time::Instant::now();
1926        let result = runner.dispatch(&input).await;
1927        let elapsed = start.elapsed();
1928        // Should return immediately (< 1s) even though hook sleeps 10s.
1929        assert!(
1930            elapsed.as_secs() < 1,
1931            "async hook blocked dispatch: {elapsed:?}"
1932        );
1933        // Async hooks always return Continue immediately.
1934        assert!(matches!(result.action, HookAction::Continue));
1935    }
1936
1937    #[tokio::test]
1938    async fn async_rewake_exit2_triggers_cancel() {
1939        use crate::hooks::config::{HookCommand, HookCommandType, HookMatcher, HooksConfig};
1940        // Use mock exit code 2 so no OS process is spawned.
1941        // The asyncRewake branch sees exit_code == Some(2) and cancels the token.
1942        let token = CancellationToken::new();
1943        let child_token = token.clone();
1944        let cfg = HooksConfig {
1945            events: std::collections::HashMap::from([(
1946                "PreToolCall".to_string(),
1947                vec![HookMatcher {
1948                    matcher: None,
1949                    hooks: vec![HookCommand {
1950                        r#type: HookCommandType::Command,
1951                        command: Some("/bin/true".to_string()), // never spawned
1952                        async_rewake: true,
1953                        timeout: 5,
1954                        ..Default::default()
1955                    }],
1956                }],
1957            )]),
1958        };
1959        let runner = ExternalHookRunner::from_config(cfg)
1960            .with_cancel_token(child_token)
1961            .with_mock_exit_codes(vec![Some(2)]);
1962        let input = make_tool_input(HookEvent::PreToolCall, "Bash", serde_json::json!({}));
1963        runner.dispatch(&input).await;
1964        // The background task should complete and cancel the token almost immediately.
1965        let deadline = std::time::Instant::now() + Duration::from_secs(5);
1966        while !token.is_cancelled() {
1967            if std::time::Instant::now() >= deadline {
1968                panic!("timed out waiting for asyncRewake cancellation");
1969            }
1970            tokio::time::sleep(Duration::from_millis(10)).await;
1971        }
1972        assert!(
1973            token.is_cancelled(),
1974            "exit code 2 should have triggered cancellation"
1975        );
1976    }
1977
1978    #[tokio::test]
1979    #[cfg(unix)]
1980    async fn async_rewake_exit0_no_cancel() {
1981        use crate::hooks::config::{HookCommand, HookCommandType, HookMatcher, HooksConfig};
1982        let tmp = tempfile::tempdir().unwrap();
1983        let hook_path = tmp.path().join("exit0.sh");
1984        // Hook that exits with code 0 (success). No stdin read to avoid stall under load.
1985        std::fs::write(&hook_path, "#!/bin/sh\nexit 0\n").unwrap();
1986        {
1987            use std::os::unix::fs::PermissionsExt;
1988            let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
1989            perms.set_mode(0o755);
1990            std::fs::set_permissions(&hook_path, perms).unwrap();
1991        }
1992        let token = CancellationToken::new();
1993        let child_token = token.clone();
1994        let cfg = HooksConfig {
1995            events: std::collections::HashMap::from([(
1996                "PreToolCall".to_string(),
1997                vec![HookMatcher {
1998                    matcher: None,
1999                    hooks: vec![HookCommand {
2000                        r#type: HookCommandType::Command,
2001                        command: Some(hook_path.to_string_lossy().to_string()),
2002                        async_rewake: true,
2003                        timeout: 5,
2004                        ..Default::default()
2005                    }],
2006                }],
2007            )]),
2008        };
2009        let runner = ExternalHookRunner::from_config(cfg).with_cancel_token(child_token);
2010        let input = make_tool_input(HookEvent::PreToolCall, "Bash", serde_json::json!({}));
2011        runner.dispatch(&input).await;
2012        tokio::time::sleep(Duration::from_millis(300)).await;
2013        assert!(
2014            !token.is_cancelled(),
2015            "exit code 0 should NOT trigger cancellation"
2016        );
2017    }
2018
2019    // ── Goal 281: fail_mode tests ──────────────────────────────────
2020
2021    #[tokio::test]
2022    #[cfg(unix)]
2023    async fn open_hook_times_out_returns_continue() {
2024        use crate::hooks::config::{HookCommand, HookCommandType, HookMatcher, HooksConfig};
2025        let tmp = tempfile::tempdir().unwrap();
2026        let hook_path = tmp.path().join("open_hang.sh");
2027        let script = "#!/bin/sh\nsleep 30\n";
2028        std::fs::write(&hook_path, script).unwrap();
2029        {
2030            use std::os::unix::fs::PermissionsExt;
2031            let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
2032            perms.set_mode(0o755);
2033            std::fs::set_permissions(&hook_path, perms).unwrap();
2034        }
2035        let cfg = HooksConfig {
2036            events: std::collections::HashMap::from([(
2037                "PreToolCall".to_string(),
2038                vec![HookMatcher {
2039                    matcher: None,
2040                    hooks: vec![HookCommand {
2041                        r#type: HookCommandType::Command,
2042                        command: Some(hook_path.to_string_lossy().to_string()),
2043                        timeout: 1,
2044                        fail_mode: HookFailMode::Open,
2045                        ..Default::default()
2046                    }],
2047                }],
2048            )]),
2049        };
2050        let runner = ExternalHookRunner::from_config(cfg);
2051        let input = make_tool_input(
2052            HookEvent::PreToolCall,
2053            "Bash",
2054            serde_json::json!({"command": "ls"}),
2055        );
2056        let result = runner.dispatch(&input).await;
2057        // Fail-open: timeout → Continue.
2058        assert!(
2059            matches!(result.action, HookAction::Continue),
2060            "Open hook timeout should return Continue, got {:?}",
2061            result.action
2062        );
2063    }
2064
2065    #[tokio::test]
2066    #[cfg(unix)]
2067    async fn closed_hook_times_out_returns_error() {
2068        use crate::hooks::config::{HookCommand, HookCommandType, HookMatcher, HooksConfig};
2069        let tmp = tempfile::tempdir().unwrap();
2070        let hook_path = tmp.path().join("closed_hang.sh");
2071        let script = "#!/bin/sh\nsleep 30\n";
2072        std::fs::write(&hook_path, script).unwrap();
2073        {
2074            use std::os::unix::fs::PermissionsExt;
2075            let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
2076            perms.set_mode(0o755);
2077            std::fs::set_permissions(&hook_path, perms).unwrap();
2078        }
2079        let cfg = HooksConfig {
2080            events: std::collections::HashMap::from([(
2081                "PreToolCall".to_string(),
2082                vec![HookMatcher {
2083                    matcher: None,
2084                    hooks: vec![HookCommand {
2085                        r#type: HookCommandType::Command,
2086                        command: Some(hook_path.to_string_lossy().to_string()),
2087                        timeout: 1,
2088                        fail_mode: HookFailMode::Closed,
2089                        ..Default::default()
2090                    }],
2091                }],
2092            )]),
2093        };
2094        let runner = ExternalHookRunner::from_config(cfg);
2095        let input = make_tool_input(
2096            HookEvent::PreToolCall,
2097            "Bash",
2098            serde_json::json!({"command": "ls"}),
2099        );
2100        let result = runner.dispatch(&input).await;
2101        // Fail-closed on PreToolCall: timeout → Error.
2102        assert!(
2103            matches!(result.action, HookAction::Error(_)),
2104            "Closed hook timeout should return Error, got {:?}",
2105            result.action
2106        );
2107    }
2108
2109    #[tokio::test]
2110    #[cfg(unix)]
2111    async fn closed_hook_exits_nonzero_returns_error() {
2112        use crate::hooks::config::{HookCommand, HookCommandType, HookMatcher, HooksConfig};
2113        let tmp = tempfile::tempdir().unwrap();
2114        let hook_path = tmp.path().join("closed_exit1.sh");
2115        let script = "#!/bin/sh\nexit 1\n";
2116        std::fs::write(&hook_path, script).unwrap();
2117        {
2118            use std::os::unix::fs::PermissionsExt;
2119            let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
2120            perms.set_mode(0o755);
2121            std::fs::set_permissions(&hook_path, perms).unwrap();
2122        }
2123        let cfg = HooksConfig {
2124            events: std::collections::HashMap::from([(
2125                "PreToolCall".to_string(),
2126                vec![HookMatcher {
2127                    matcher: None,
2128                    hooks: vec![HookCommand {
2129                        r#type: HookCommandType::Command,
2130                        command: Some(hook_path.to_string_lossy().to_string()),
2131                        timeout: 5,
2132                        fail_mode: HookFailMode::Closed,
2133                        ..Default::default()
2134                    }],
2135                }],
2136            )]),
2137        };
2138        let runner = ExternalHookRunner::from_config(cfg);
2139        let input = make_tool_input(
2140            HookEvent::PreToolCall,
2141            "Bash",
2142            serde_json::json!({"command": "rm -rf /"}),
2143        );
2144        let result = runner.dispatch(&input).await;
2145        // Fail-closed on PreToolCall: non-zero exit → Error.
2146        assert!(
2147            matches!(result.action, HookAction::Error(_)),
2148            "Closed hook non-zero exit should return Error, got {:?}",
2149            result.action
2150        );
2151    }
2152
2153    #[tokio::test]
2154    #[cfg(unix)]
2155    async fn closed_hook_post_tool_call_timeout_returns_continue() {
2156        use crate::hooks::config::{HookCommand, HookCommandType, HookMatcher, HooksConfig};
2157        let tmp = tempfile::tempdir().unwrap();
2158        let hook_path = tmp.path().join("closed_post_hang.sh");
2159        let script = "#!/bin/sh\nsleep 30\n";
2160        std::fs::write(&hook_path, script).unwrap();
2161        {
2162            use std::os::unix::fs::PermissionsExt;
2163            let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
2164            perms.set_mode(0o755);
2165            std::fs::set_permissions(&hook_path, perms).unwrap();
2166        }
2167        let cfg = HooksConfig {
2168            events: std::collections::HashMap::from([(
2169                "PostToolCall".to_string(),
2170                vec![HookMatcher {
2171                    matcher: None,
2172                    hooks: vec![HookCommand {
2173                        r#type: HookCommandType::Command,
2174                        command: Some(hook_path.to_string_lossy().to_string()),
2175                        timeout: 1,
2176                        fail_mode: HookFailMode::Closed,
2177                        ..Default::default()
2178                    }],
2179                }],
2180            )]),
2181        };
2182        let runner = ExternalHookRunner::from_config(cfg);
2183        let input = make_tool_input(
2184            HookEvent::PostToolCall,
2185            "Bash",
2186            serde_json::json!({"command": "ls"}),
2187        );
2188        let result = runner.dispatch(&input).await;
2189        // Closed on PostToolCall is a no-op: timeout → Continue.
2190        assert!(
2191            matches!(result.action, HookAction::Continue),
2192            "Closed PostToolCall timeout should return Continue, got {:?}",
2193            result.action
2194        );
2195    }
2196}