Skip to main content

zeph_subagent/
hooks.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Lifecycle hooks for sub-agents.
5//!
6//! Hooks are shell commands or MCP tool calls executed at specific points in a
7//! sub-agent's or main agent's lifecycle. Per-agent frontmatter supports `PreToolUse`
8//! and `PostToolUse` hooks via the `hooks` section. Config-level events include
9//! `CwdChanged`, `FileChanged`, and `PermissionDenied`.
10//!
11//! # Hook actions
12//!
13//! - `type = "command"` — runs a shell command via `sh -c`.
14//! - `type = "mcp_tool"` — dispatches to an MCP server tool via [`McpDispatch`].
15//!
16//! # Security
17//!
18//! All shell hook commands are run via `sh -c` with a **cleared** environment. Only `PATH`
19//! from the parent process is preserved, and the hook-specific `ZEPH_*` variables are
20//! added explicitly. This prevents accidental secret leakage from the parent environment.
21//!
22//! # Execution order
23//!
24//! Hooks within a matcher are run sequentially. `fail_closed = true` hooks abort on the
25//! first error; `fail_closed = false` (default) log the error and continue.
26//!
27//! # `PostToolUse` stdout replacement
28//!
29//! Shell hooks for `PostToolUse` events may emit a JSON object to stdout to replace the
30//! tool output seen by the agent. The JSON must contain:
31//!
32//! ```json
33//! { "hookSpecificOutput": { "updatedToolOutput": "replacement text" } }
34//! ```
35//!
36//! If `updatedToolOutput` is `null` or absent, or stdout is empty / not valid JSON, the
37//! original tool output is preserved (backward compatible). Hook stdout is capped at 1 MiB
38//! to prevent memory exhaustion; output exceeding the cap is silently truncated and treated
39//! as if no substitution was requested.
40//!
41//! # Hook stdin (`PostToolUse`)
42//!
43//! For `PostToolUse` and `PostToolUseFailure` events, a JSON context object is written to
44//! the hook process's stdin:
45//!
46//! ```json
47//! {
48//!   "tool_name": "Shell",
49//!   "tool_args": { ... },
50//!   "session_id": "abc123",
51//!   "duration_ms": 142,
52//!   "tool_output": "command output here"
53//! }
54//! ```
55//!
56//! `tool_error` replaces `tool_output` for failure events. Hooks that do not read stdin
57//! are unaffected — the pipe is closed when the child ignores it.
58//!
59//! # Examples
60//!
61//! ```rust,no_run
62//! use std::collections::HashMap;
63//! use zeph_subagent::{HookDef, HookAction, fire_hooks};
64//!
65//! async fn run() {
66//!     let hooks = vec![HookDef {
67//!         action: HookAction::Command { command: "true".to_owned() },
68//!         timeout_secs: 5,
69//!         fail_closed: false,
70//!         r#if: None,
71//!     }];
72//!     fire_hooks(&hooks, &HashMap::new(), None, None).await.unwrap();
73//! }
74//! ```
75
76use std::collections::HashMap;
77use std::hash::BuildHasher;
78use std::time::Duration;
79
80use serde::Serialize;
81use thiserror::Error;
82use tokio::io::AsyncWriteExt as _;
83use tokio::process::Command;
84use tokio::time::timeout;
85
86pub use zeph_config::{HookAction, HookDef, HookMatcher, SubagentHooks};
87
88// ── Hook output types ─────────────────────────────────────────────────────────
89
90/// Structured output captured from a hook's stdout.
91///
92/// Only populated for shell `PostToolUse` hooks; MCP hooks always produce
93/// `updated_tool_output: None`.
94#[derive(Debug, Default)]
95pub struct HookOutput {
96    /// Replacement text for the tool output, when the hook requests a substitution.
97    ///
98    /// `None` means the original tool output is preserved.
99    pub updated_tool_output: Option<String>,
100}
101
102/// Aggregate result of executing one or more hooks in sequence.
103///
104/// Callers that do not need the output can ignore this and check only for `Err`.
105#[derive(Debug, Default)]
106pub struct HookRunResult {
107    /// Merged output from the hook sequence. When multiple hooks emit
108    /// `updatedToolOutput`, the last non-`None` value wins.
109    pub output: HookOutput,
110}
111
112// ── Hook stdin payload ────────────────────────────────────────────────────────
113
114/// Context serialized to hook stdin for `PostToolUse` and `PostToolUseFailure` events.
115///
116/// The `tool_output` field is present for success events; `tool_error` is present
117/// for failure events. Both are `Option` with `skip_serializing_if` so only the
118/// relevant field appears in the JSON written to stdin.
119///
120/// # Payload consistency contract
121///
122/// Agent context is delivered **two ways**:
123/// - **All hook events**: `ZEPH_AGENT_TYPE` and (when available) `ZEPH_AGENT_ID` env vars.
124/// - **`PostToolUse` only**: `agent_id` and `agent_type` fields in this stdin JSON object.
125///
126/// `file_changed`, `cwd_changed`, and `turn_complete` hooks receive agent context via env
127/// vars only — no stdin JSON is written for those events.
128#[derive(Debug, Serialize)]
129pub struct PostToolUseHookInput<'a> {
130    /// Name of the tool that was invoked.
131    pub tool_name: &'a str,
132    /// Arguments passed to the tool (the parsed JSON value).
133    pub tool_args: &'a serde_json::Value,
134    /// Conversation / session identifier, if available.
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub session_id: Option<&'a str>,
137    /// Wall-clock time the tool took to execute, in milliseconds.
138    pub duration_ms: u64,
139    /// Tool output text (success path). Absent for failure events.
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub tool_output: Option<&'a str>,
142    /// Tool error text (failure path). Absent for success events.
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub tool_error: Option<&'a str>,
145    /// The agent's stable identifier: `conversation_id` for the main agent,
146    /// `task_id` for sub-agents. Absent when no conversation has been bound yet.
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub agent_id: Option<&'a str>,
149    /// Discriminator for the agent that fired this hook: `"main"` or `"subagent"`.
150    pub agent_type: &'a str,
151}
152
153/// Maximum number of bytes read from hook stdout before truncation.
154const HOOK_STDOUT_CAP: usize = 1024 * 1024; // 1 MiB
155
156// ── McpDispatch ───────────────────────────────────────────────────────────────
157
158/// Abstraction over MCP tool dispatch used by hooks.
159///
160/// This trait decouples `zeph-subagent` from `zeph-mcp`, allowing the hook
161/// executor to call MCP tools without a direct crate dependency. Implementors
162/// are provided by `zeph-core` at the call site.
163///
164/// # Errors
165///
166/// Returns an error string if the tool call fails for any reason (server not
167/// found, policy violation, timeout, etc.).
168pub trait McpDispatch: Send + Sync {
169    /// Call a tool on the named MCP server with the given JSON arguments.
170    fn call_tool<'a>(
171        &'a self,
172        server: &'a str,
173        tool: &'a str,
174        args: serde_json::Value,
175    ) -> std::pin::Pin<
176        Box<dyn std::future::Future<Output = Result<serde_json::Value, String>> + Send + 'a>,
177    >;
178}
179
180// ── Conditional `if` filter ───────────────────────────────────────────────────
181
182/// Evaluate a `HookDef.r#if` condition string against the triggering tool name.
183///
184/// The condition must be a `key:value` string. Only the `tool` key is supported:
185/// `tool:<token>` matches when `tool_name` is `Some` and **contains** `<token>`.
186///
187/// Returns `false` (fail-closed) in all of these cases:
188/// - The condition has no `:` separator.
189/// - The key is not `tool`.
190/// - The token after `tool:` is empty.
191/// - `tool_name` is `None` (no tool context at this event).
192/// - `tool_name` is `Some("")` (start/stop lifecycle events pass an empty tool name).
193///
194/// A `warn!` is emitted once for unknown keys and malformed conditions so that
195/// misconfigured hooks surface in logs without aborting hook dispatch.
196///
197/// # Examples
198///
199/// ```
200/// use zeph_subagent::hook_if_matches;
201///
202/// assert!(hook_if_matches("tool:shell", Some("shell")));
203/// assert!(hook_if_matches("tool:sh", Some("shell")));  // substring rule
204/// assert!(!hook_if_matches("tool:shell", Some("python")));
205/// assert!(!hook_if_matches("tool:shell", None));       // no tool context
206/// assert!(!hook_if_matches("tool:", Some("shell")));   // empty token → fail-closed
207/// assert!(!hook_if_matches("bad", Some("shell")));     // no colon → fail-closed
208/// ```
209#[must_use]
210pub fn hook_if_matches(condition: &str, tool_name: Option<&str>) -> bool {
211    let Some((key, value)) = condition.split_once(':') else {
212        tracing::warn!(
213            condition,
214            "hook `if` condition has no `:` separator — skipping hook (fail-closed)"
215        );
216        return false;
217    };
218
219    match key {
220        "tool" => {
221            if value.is_empty() {
222                tracing::warn!(
223                    condition,
224                    "hook `if` condition has empty token after `tool:` — skipping hook (fail-closed)"
225                );
226                return false;
227            }
228            tool_name.is_some_and(|t| !t.is_empty() && t.contains(value))
229        }
230        unknown => {
231            tracing::warn!(
232                key = unknown,
233                condition,
234                "hook `if` condition uses unknown key — skipping hook (fail-closed)"
235            );
236            false
237        }
238    }
239}
240
241// ── Error ─────────────────────────────────────────────────────────────────────
242
243/// Errors that can occur when executing a lifecycle hook.
244#[non_exhaustive]
245#[derive(Debug, Error)]
246pub enum HookError {
247    /// The shell command exited with a non-zero status code.
248    #[error("hook command failed (exit code {code}): {command}")]
249    NonZeroExit { command: String, code: i32 },
250
251    /// The shell command did not complete within its configured `timeout_secs`.
252    #[error("hook command timed out after {timeout_secs}s: {command}")]
253    Timeout { command: String, timeout_secs: u64 },
254
255    /// The shell could not be spawned or an I/O error occurred while waiting.
256    #[error("hook I/O error for command '{command}': {source}")]
257    Io {
258        command: String,
259        #[source]
260        source: std::io::Error,
261    },
262
263    /// An `mcp_tool` hook was configured but no MCP manager is available.
264    #[error(
265        "mcp_tool hook requires an MCP manager but none was provided (server={server}, tool={tool})"
266    )]
267    McpUnavailable { server: String, tool: String },
268
269    /// The MCP tool call returned an error.
270    #[error("mcp_tool hook failed (server={server}, tool={tool}): {reason}")]
271    McpToolFailed {
272        server: String,
273        tool: String,
274        reason: String,
275    },
276}
277
278// ── Matching ──────────────────────────────────────────────────────────────────
279
280/// Return all hook definitions from `matchers` whose patterns match `tool_name`.
281///
282/// Matching rules:
283/// - Each [`HookMatcher`]`.matcher` is a `|`-separated list of tokens.
284/// - A token matches if `tool_name` **contains** the token (case-sensitive substring).
285/// - Empty tokens are ignored.
286///
287/// # Examples
288///
289/// ```rust
290/// use zeph_subagent::{HookDef, HookAction, HookMatcher, matching_hooks};
291///
292/// let hook = HookDef { action: HookAction::Command { command: "echo hi".to_owned() }, timeout_secs: 30, fail_closed: false, r#if: None };
293/// let matchers = vec![HookMatcher { matcher: "Edit|Write".to_owned(), hooks: vec![hook] }];
294///
295/// assert_eq!(matching_hooks(&matchers, "Edit").len(), 1);
296/// assert!(matching_hooks(&matchers, "Shell").is_empty());
297/// ```
298#[must_use]
299pub fn matching_hooks<'a>(matchers: &'a [HookMatcher], tool_name: &str) -> Vec<&'a HookDef> {
300    let mut result = Vec::new();
301    for m in matchers {
302        let matched = m
303            .matcher
304            .split('|')
305            .filter(|token| !token.is_empty())
306            .any(|token| tool_name.contains(token));
307        if matched {
308            result.extend(m.hooks.iter());
309        }
310    }
311    result
312}
313
314// ── Hook env helpers ──────────────────────────────────────────────────────────
315
316/// Maximum byte length of `ZEPH_TOOL_ARGS_JSON` to avoid `E2BIG` when spawning hook processes.
317///
318/// OS `ARG_MAX` is ~1 MB on macOS and ~2 MB on Linux; staying well below that avoids `E2BIG`.
319pub const TOOL_ARGS_JSON_LIMIT: usize = 64 * 1024;
320
321/// Build the common hook environment variables shared by all hook dispatch sites.
322///
323/// Sets `ZEPH_TOOL_NAME` and `ZEPH_TOOL_ARGS_JSON`. The serialized `tool_input` is
324/// truncated to [`TOOL_ARGS_JSON_LIMIT`] bytes at a valid UTF-8 boundary when it
325/// would exceed the OS `ARG_MAX` limit.
326///
327/// Callers should extend the returned map with site-specific variables such as
328/// `ZEPH_AGENT_ID`, `ZEPH_AGENT_NAME`, or `ZEPH_SESSION_ID`.
329///
330/// # Examples
331///
332/// ```
333/// use zeph_subagent::make_base_hook_env;
334///
335/// let env = make_base_hook_env("Edit", &serde_json::Value::Null);
336/// assert_eq!(env["ZEPH_TOOL_NAME"], "Edit");
337/// assert!(env.contains_key("ZEPH_TOOL_ARGS_JSON"));
338/// ```
339#[must_use]
340pub fn make_base_hook_env(
341    tool_name: &str,
342    tool_input: &serde_json::Value,
343) -> HashMap<String, String> {
344    let mut env = HashMap::new();
345    env.insert("ZEPH_TOOL_NAME".to_owned(), tool_name.to_owned());
346
347    let raw = serde_json::to_string(tool_input).unwrap_or_default();
348    let args_json = if raw.len() > TOOL_ARGS_JSON_LIMIT {
349        tracing::warn!(
350            tool = tool_name,
351            len = raw.len(),
352            limit = TOOL_ARGS_JSON_LIMIT,
353            "ZEPH_TOOL_ARGS_JSON truncated for hook dispatch"
354        );
355        let limit = raw.floor_char_boundary(TOOL_ARGS_JSON_LIMIT);
356        format!("{}…", &raw[..limit])
357    } else {
358        raw
359    };
360    env.insert("ZEPH_TOOL_ARGS_JSON".to_owned(), args_json);
361
362    env
363}
364
365// ── Execution ─────────────────────────────────────────────────────────────────
366
367/// Execute a list of hook definitions, setting the provided environment variables.
368///
369/// Hooks are run sequentially. If a hook has `fail_closed = true` and fails,
370/// execution stops immediately and `Err` is returned. Otherwise errors are logged
371/// and execution continues.
372///
373/// The optional `stdin_json` bytes are written to the hook process's stdin before
374/// it runs (shell hooks only). Pass `None` for hooks that do not require context
375/// on stdin (e.g., `PreToolUse`). MCP hooks never receive stdin data.
376///
377/// When multiple shell hooks run in sequence and emit `updatedToolOutput`, the last
378/// non-`None` value wins. If a fail-closed hook aborts after a previous hook already
379/// produced a replacement, the prior replacement is preserved in the returned error
380/// path — callers should discard `HookRunResult` on `Err` if appropriate for their
381/// use-case, but the struct always reflects what was captured before the abort.
382///
383/// The `mcp` parameter provides MCP tool dispatch for `type = "mcp_tool"` hooks.
384/// Pass `None` when no MCP manager is available; `mcp_tool` hooks will fail with
385/// [`HookError::McpUnavailable`] (respecting `fail_closed`).
386///
387/// # Errors
388///
389/// Returns [`HookError`] if a fail-closed hook exits non-zero, times out, or the
390/// MCP call fails.
391#[tracing::instrument(name = "subagent.hooks.fire", skip_all, fields(hook_count = hooks.len()))]
392pub async fn fire_hooks<S: BuildHasher>(
393    hooks: &[HookDef],
394    env: &HashMap<String, String, S>,
395    mcp: Option<&dyn McpDispatch>,
396    stdin_json: Option<&[u8]>,
397) -> Result<HookRunResult, HookError> {
398    let tool_name = env.get("ZEPH_TOOL_NAME").map(String::as_str);
399    let mut run_result = HookRunResult::default();
400    for hook in hooks {
401        // Evaluate the optional `if` condition before dispatching.
402        if hook.r#if.as_ref().is_some_and(|cond| {
403            let matches = hook_if_matches(cond, tool_name);
404            if !matches {
405                tracing::debug!(
406                    condition = cond.as_str(),
407                    "hook `if` condition did not match — skipping"
408                );
409            }
410            !matches
411        }) {
412            continue;
413        }
414
415        // For chaining: pass the already-replaced output as the new stdin so each
416        // subsequent hook sees the current (potentially substituted) output.
417        let effective_stdin = run_result
418            .output
419            .updated_tool_output
420            .as_deref()
421            .map(str::as_bytes)
422            .or(stdin_json);
423        let result = fire_single_hook(hook, env, mcp, effective_stdin).await;
424        match result {
425            Ok(hook_output) => {
426                if hook_output.updated_tool_output.is_some() {
427                    run_result.output.updated_tool_output = hook_output.updated_tool_output;
428                }
429            }
430            Err(e) if hook.fail_closed => {
431                tracing::error!(
432                    error = %e,
433                    "fail-closed hook failed — aborting"
434                );
435                return Err(e);
436            }
437            Err(e) => {
438                tracing::warn!(
439                    error = %e,
440                    "hook failed (fail_open) — continuing"
441                );
442            }
443        }
444    }
445    Ok(run_result)
446}
447
448#[tracing::instrument(name = "subagent.hooks.single", skip_all)]
449async fn fire_single_hook<S: BuildHasher>(
450    hook: &HookDef,
451    env: &HashMap<String, String, S>,
452    mcp: Option<&dyn McpDispatch>,
453    stdin_json: Option<&[u8]>,
454) -> Result<HookOutput, HookError> {
455    match &hook.action {
456        HookAction::Command { command } => {
457            fire_shell_hook(command, hook.timeout_secs, env, stdin_json).await
458        }
459        HookAction::McpTool { server, tool, args } => {
460            let dispatcher = mcp.ok_or_else(|| HookError::McpUnavailable {
461                server: server.clone(),
462                tool: tool.clone(),
463            })?;
464            let call_fut = dispatcher.call_tool(server, tool, args.clone());
465            match timeout(Duration::from_secs(hook.timeout_secs), call_fut).await {
466                Ok(Ok(_)) => {
467                    // MCP hooks produce no stdout — output substitution is not supported.
468                    Ok(HookOutput::default())
469                }
470                Ok(Err(reason)) => Err(HookError::McpToolFailed {
471                    server: server.clone(),
472                    tool: tool.clone(),
473                    reason,
474                }),
475                Err(_) => Err(HookError::Timeout {
476                    command: format!("mcp_tool:{server}/{tool}"),
477                    timeout_secs: hook.timeout_secs,
478                }),
479            }
480        }
481        _ => Ok(HookOutput::default()),
482    }
483}
484
485#[tracing::instrument(name = "subagent.hooks.shell", skip_all, fields(timeout_secs))]
486async fn fire_shell_hook<S: BuildHasher>(
487    command: &str,
488    timeout_secs: u64,
489    env: &HashMap<String, String, S>,
490    stdin_json: Option<&[u8]>,
491) -> Result<HookOutput, HookError> {
492    use std::process::Stdio;
493    use tokio::io::AsyncReadExt as _;
494
495    let mut cmd = Command::new("sh");
496    cmd.arg("-c").arg(command);
497    // SEC-H-002: clear inherited env to prevent secret leakage, then set only hook vars.
498    cmd.env_clear();
499    // Preserve minimal PATH so the shell can find standard tools.
500    if let Ok(path) = std::env::var("PATH") {
501        cmd.env("PATH", path);
502    }
503    for (k, v) in env {
504        cmd.env(k, v);
505    }
506    cmd.stdin(if stdin_json.is_some() {
507        Stdio::piped()
508    } else {
509        Stdio::null()
510    });
511    // Capture stdout to parse potential updatedToolOutput JSON.
512    cmd.stdout(Stdio::piped());
513    cmd.stderr(Stdio::null());
514
515    let mut child = cmd.spawn().map_err(|e| HookError::Io {
516        command: command.to_owned(),
517        source: e,
518    })?;
519
520    // Write stdin before awaiting child exit to avoid deadlock on full pipes.
521    // Drop the handle to close the pipe so the child gets EOF when it stops reading.
522    if let Some(bytes) = stdin_json
523        && let Some(mut stdin_handle) = child.stdin.take()
524        && let Err(e) = stdin_handle.write_all(bytes).await
525    {
526        tracing::warn!(
527            command,
528            error = %e,
529            "failed to write stdin to hook — continuing without stdin data"
530        );
531    }
532
533    // Wait for process exit with a timeout, then read stdout. Sequential order avoids the
534    // deadlock where read_fut blocks on EOF while kill() is gated behind join! completion.
535    let stdout_handle = child.stdout.take();
536    match timeout(Duration::from_secs(timeout_secs), child.wait()).await {
537        Ok(Ok(status)) => {
538            let mut stdout_bytes = Vec::new();
539            if let Some(handle) = stdout_handle {
540                let mut limited = handle.take(HOOK_STDOUT_CAP as u64 + 1);
541                let _ = limited.read_to_end(&mut stdout_bytes).await;
542            }
543            if status.success() {
544                Ok(parse_hook_stdout(command, &stdout_bytes))
545            } else {
546                Err(HookError::NonZeroExit {
547                    command: command.to_owned(),
548                    code: status.code().unwrap_or(-1),
549                })
550            }
551        }
552        Ok(Err(e)) => Err(HookError::Io {
553            command: command.to_owned(),
554            source: e,
555        }),
556        Err(_) => {
557            // SEC-H-004: explicitly kill child on timeout to prevent orphan processes.
558            let _ = child.kill().await;
559            Err(HookError::Timeout {
560                command: command.to_owned(),
561                timeout_secs,
562            })
563        }
564    }
565}
566
567/// Parse hook stdout bytes into a [`HookOutput`].
568///
569/// Returns a default (no substitution) value on any parse error to preserve
570/// backward compatibility with hooks that write non-JSON to stdout.
571fn parse_hook_stdout(command: &str, bytes: &[u8]) -> HookOutput {
572    if bytes.is_empty() {
573        return HookOutput::default();
574    }
575    if bytes.len() > HOOK_STDOUT_CAP {
576        tracing::warn!(
577            command,
578            bytes = bytes.len(),
579            cap = HOOK_STDOUT_CAP,
580            "hook stdout exceeds 1 MiB cap — treating as no substitution"
581        );
582        return HookOutput::default();
583    }
584    let Ok(text) = std::str::from_utf8(bytes) else {
585        tracing::warn!(command, "hook stdout is not valid UTF-8 — no substitution");
586        return HookOutput::default();
587    };
588    // Silent on JSON parse failure: backward compat — hooks may write human-readable output.
589    let Ok(json) = serde_json::from_str::<serde_json::Value>(text) else {
590        return HookOutput::default();
591    };
592    let updated = json
593        .get("hookSpecificOutput")
594        .and_then(|h| h.get("updatedToolOutput"));
595
596    match updated {
597        None | Some(serde_json::Value::Null) => HookOutput::default(),
598        Some(serde_json::Value::String(s)) => HookOutput {
599            updated_tool_output: Some(s.clone()),
600        },
601        Some(other) => {
602            tracing::warn!(
603                command,
604                kind = other
605                    .is_object()
606                    .then_some("object")
607                    .or_else(|| other.is_array().then_some("array"))
608                    .or_else(|| other.is_number().then_some("number"))
609                    .or_else(|| other.is_boolean().then_some("boolean"))
610                    .unwrap_or("unknown"),
611                "hookSpecificOutput.updatedToolOutput has unexpected type — no substitution"
612            );
613            HookOutput::default()
614        }
615    }
616}
617
618// ── Tests ─────────────────────────────────────────────────────────────────────
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623    use std::assert_matches;
624
625    fn cmd_hook(command: &str, fail_closed: bool, timeout_secs: u64) -> HookDef {
626        HookDef {
627            action: HookAction::Command {
628                command: command.to_owned(),
629            },
630            timeout_secs,
631            fail_closed,
632            r#if: None,
633        }
634    }
635
636    fn make_matcher(matcher: &str, hooks: Vec<HookDef>) -> HookMatcher {
637        HookMatcher {
638            matcher: matcher.to_owned(),
639            hooks,
640        }
641    }
642
643    // ── matching_hooks ────────────────────────────────────────────────────────
644
645    #[test]
646    fn matching_hooks_exact_name() {
647        let hook = cmd_hook("echo hi", false, 30);
648        let matchers = vec![make_matcher("Edit", vec![hook.clone()])];
649        let result = matching_hooks(&matchers, "Edit");
650        assert_eq!(result.len(), 1);
651        assert!(
652            matches!(&result[0].action, HookAction::Command { command } if command == "echo hi")
653        );
654    }
655
656    #[test]
657    fn matching_hooks_substring() {
658        let hook = cmd_hook("echo sub", false, 30);
659        let matchers = vec![make_matcher("Edit", vec![hook.clone()])];
660        let result = matching_hooks(&matchers, "EditFile");
661        assert_eq!(result.len(), 1);
662    }
663
664    #[test]
665    fn matching_hooks_pipe_separated() {
666        let h1 = cmd_hook("echo e", false, 30);
667        let h2 = cmd_hook("echo w", false, 30);
668        let matchers = vec![
669            make_matcher("Edit|Write", vec![h1.clone()]),
670            make_matcher("Shell", vec![h2.clone()]),
671        ];
672        let result_edit = matching_hooks(&matchers, "Edit");
673        assert_eq!(result_edit.len(), 1);
674
675        let result_shell = matching_hooks(&matchers, "Shell");
676        assert_eq!(result_shell.len(), 1);
677
678        let result_none = matching_hooks(&matchers, "Read");
679        assert!(result_none.is_empty());
680    }
681
682    #[test]
683    fn matching_hooks_no_match() {
684        let hook = cmd_hook("echo nope", false, 30);
685        let matchers = vec![make_matcher("Edit", vec![hook])];
686        let result = matching_hooks(&matchers, "Shell");
687        assert!(result.is_empty());
688    }
689
690    #[test]
691    fn matching_hooks_empty_token_ignored() {
692        let hook = cmd_hook("echo empty", false, 30);
693        let matchers = vec![make_matcher("|Edit|", vec![hook])];
694        let result = matching_hooks(&matchers, "Edit");
695        assert_eq!(result.len(), 1);
696    }
697
698    #[test]
699    fn matching_hooks_multiple_matchers_both_match() {
700        let h1 = cmd_hook("echo 1", false, 30);
701        let h2 = cmd_hook("echo 2", false, 30);
702        let matchers = vec![
703            make_matcher("Shell", vec![h1]),
704            make_matcher("Shell", vec![h2]),
705        ];
706        let result = matching_hooks(&matchers, "Shell");
707        assert_eq!(result.len(), 2);
708    }
709
710    // ── fire_hooks ────────────────────────────────────────────────────────────
711
712    #[tokio::test]
713    async fn fire_hooks_success() {
714        let hooks = vec![cmd_hook("true", false, 5)];
715        let env = HashMap::new();
716        assert!(fire_hooks(&hooks, &env, None, None).await.is_ok());
717    }
718
719    #[tokio::test]
720    async fn fire_hooks_fail_open_continues() {
721        let hooks = vec![
722            cmd_hook("false", false, 5), // fail open
723            cmd_hook("true", false, 5),  // should still run
724        ];
725        let env = HashMap::new();
726        assert!(fire_hooks(&hooks, &env, None, None).await.is_ok());
727    }
728
729    #[tokio::test]
730    async fn fire_hooks_fail_closed_returns_err() {
731        let hooks = vec![cmd_hook("false", true, 5)];
732        let env = HashMap::new();
733        let result = fire_hooks(&hooks, &env, None, None).await;
734        assert!(result.is_err());
735        let err = result.unwrap_err();
736        assert_matches!(err, HookError::NonZeroExit { .. });
737    }
738
739    #[tokio::test]
740    async fn fire_hooks_timeout() {
741        let hooks = vec![cmd_hook("sleep 10", true, 1)];
742        let env = HashMap::new();
743        let result = fire_hooks(&hooks, &env, None, None).await;
744        assert!(result.is_err());
745        let err = result.unwrap_err();
746        assert_matches!(err, HookError::Timeout { .. });
747    }
748
749    #[tokio::test]
750    async fn fire_hooks_env_passed() {
751        let hooks = vec![cmd_hook(r#"test "$ZEPH_TEST_VAR" = "hello""#, true, 5)];
752        let mut env = HashMap::new();
753        env.insert("ZEPH_TEST_VAR".to_owned(), "hello".to_owned());
754        assert!(fire_hooks(&hooks, &env, None, None).await.is_ok());
755    }
756
757    #[tokio::test]
758    async fn fire_hooks_empty_list_ok() {
759        let env = HashMap::new();
760        assert!(fire_hooks(&[], &env, None, None).await.is_ok());
761    }
762
763    #[tokio::test]
764    async fn fire_hooks_mcp_unavailable_fail_open() {
765        let hooks = vec![HookDef {
766            action: HookAction::McpTool {
767                server: "srv".into(),
768                tool: "t".into(),
769                args: serde_json::Value::Null,
770            },
771            timeout_secs: 5,
772            fail_closed: false,
773            r#if: None,
774        }];
775        let env = HashMap::new();
776        // fail_open: should succeed even though MCP is unavailable
777        assert!(fire_hooks(&hooks, &env, None, None).await.is_ok());
778    }
779
780    #[tokio::test]
781    async fn fire_hooks_mcp_unavailable_fail_closed() {
782        let hooks = vec![HookDef {
783            action: HookAction::McpTool {
784                server: "srv".into(),
785                tool: "t".into(),
786                args: serde_json::Value::Null,
787            },
788            timeout_secs: 5,
789            fail_closed: true,
790            r#if: None,
791        }];
792        let env = HashMap::new();
793        let result = fire_hooks(&hooks, &env, None, None).await;
794        assert_matches!(result, Err(HookError::McpUnavailable { .. }));
795    }
796
797    // ── MCP dispatch tests (#3773) ────────────────────────────────────────────
798
799    /// Stub MCP dispatch that records how many times it was called.
800    struct CountingDispatch(std::sync::Arc<std::sync::atomic::AtomicU32>);
801
802    impl McpDispatch for CountingDispatch {
803        fn call_tool<'a>(
804            &'a self,
805            _server: &'a str,
806            _tool: &'a str,
807            _args: serde_json::Value,
808        ) -> std::pin::Pin<
809            Box<dyn std::future::Future<Output = Result<serde_json::Value, String>> + Send + 'a>,
810        > {
811            self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
812            Box::pin(std::future::ready(Ok(serde_json::Value::Null)))
813        }
814    }
815
816    #[tokio::test]
817    async fn fire_hooks_mcp_dispatch_called_when_provided() {
818        let call_count = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
819        let dispatch = CountingDispatch(std::sync::Arc::clone(&call_count));
820
821        let hooks = vec![HookDef {
822            action: HookAction::McpTool {
823                server: "srv".into(),
824                tool: "t".into(),
825                args: serde_json::Value::Null,
826            },
827            timeout_secs: 5,
828            fail_closed: true,
829            r#if: None,
830        }];
831        let env = HashMap::new();
832        let result = fire_hooks(&hooks, &env, Some(&dispatch), None).await;
833        assert!(
834            result.is_ok(),
835            "fire_hooks should succeed with mcp dispatch"
836        );
837        assert_eq!(
838            call_count.load(std::sync::atomic::Ordering::SeqCst),
839            1,
840            "MCP dispatch should have been called exactly once"
841        );
842    }
843
844    // ── stdout replacement tests ──────────────────────────────────────────────
845
846    #[tokio::test]
847    async fn fire_hooks_stdout_replacement_json() {
848        let cmd = r#"printf '{"hookSpecificOutput":{"updatedToolOutput":"replaced"}}'"#;
849        let hooks = vec![cmd_hook(cmd, true, 5)];
850        let env = HashMap::new();
851        let result = fire_hooks(&hooks, &env, None, None).await.unwrap();
852        assert_eq!(
853            result.output.updated_tool_output.as_deref(),
854            Some("replaced")
855        );
856    }
857
858    #[tokio::test]
859    async fn fire_hooks_stdout_empty_no_replacement() {
860        let hooks = vec![cmd_hook("true", true, 5)];
861        let env = HashMap::new();
862        let result = fire_hooks(&hooks, &env, None, None).await.unwrap();
863        assert!(result.output.updated_tool_output.is_none());
864    }
865
866    #[tokio::test]
867    async fn fire_hooks_stdout_non_json_no_replacement() {
868        let hooks = vec![cmd_hook("echo hello", true, 5)];
869        let env = HashMap::new();
870        let result = fire_hooks(&hooks, &env, None, None).await.unwrap();
871        assert!(result.output.updated_tool_output.is_none());
872    }
873
874    #[tokio::test]
875    async fn fire_hooks_stdout_null_updatedtooloutput_no_replacement() {
876        let cmd = r#"printf '{"hookSpecificOutput":{"updatedToolOutput":null}}'"#;
877        let hooks = vec![cmd_hook(cmd, true, 5)];
878        let env = HashMap::new();
879        let result = fire_hooks(&hooks, &env, None, None).await.unwrap();
880        assert!(result.output.updated_tool_output.is_none());
881    }
882
883    #[tokio::test]
884    async fn fire_hooks_stdin_passed_to_hook() {
885        // Hook reads stdin and checks that "duration_ms" key is present in the JSON.
886        let cmd = r#"python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if 'duration_ms' in d else 1)""#;
887        let hooks = vec![cmd_hook(cmd, true, 10)];
888        let env = HashMap::new();
889        let stdin = br#"{"tool_name":"Shell","tool_args":{},"duration_ms":42}"#;
890        let result = fire_hooks(&hooks, &env, None, Some(stdin)).await;
891        assert!(
892            result.is_ok(),
893            "hook should succeed when stdin has duration_ms"
894        );
895    }
896
897    #[tokio::test]
898    async fn fire_hooks_chaining_last_replacement_wins() {
899        // First hook produces replacement "first", second produces "second" — second wins.
900        let h1 = cmd_hook(
901            r#"printf '{"hookSpecificOutput":{"updatedToolOutput":"first"}}'"#,
902            false,
903            5,
904        );
905        let h2 = cmd_hook(
906            r#"printf '{"hookSpecificOutput":{"updatedToolOutput":"second"}}'"#,
907            false,
908            5,
909        );
910        let hooks = vec![h1, h2];
911        let env = HashMap::new();
912        let result = fire_hooks(&hooks, &env, None, None).await.unwrap();
913        assert_eq!(result.output.updated_tool_output.as_deref(), Some("second"));
914    }
915
916    // ── YAML parsing ──────────────────────────────────────────────────────────
917
918    #[test]
919    fn subagent_hooks_parses_from_yaml() {
920        let yaml = r#"
921PreToolUse:
922  - matcher: "Edit|Write"
923    hooks:
924      - type: command
925        command: "echo pre"
926        timeout_secs: 10
927        fail_closed: false
928PostToolUse:
929  - matcher: "Shell"
930    hooks:
931      - type: command
932        command: "echo post"
933"#;
934        let hooks: SubagentHooks = serde_norway::from_str(yaml).unwrap();
935        assert_eq!(hooks.pre_tool_use.len(), 1);
936        assert_eq!(hooks.pre_tool_use[0].matcher, "Edit|Write");
937        assert_eq!(hooks.pre_tool_use[0].hooks.len(), 1);
938        assert!(
939            matches!(&hooks.pre_tool_use[0].hooks[0].action, HookAction::Command { command } if command == "echo pre")
940        );
941        assert_eq!(hooks.post_tool_use.len(), 1);
942    }
943
944    #[test]
945    fn subagent_hooks_defaults_timeout() {
946        let yaml = r#"
947PreToolUse:
948  - matcher: "Edit"
949    hooks:
950      - type: command
951        command: "echo hi"
952"#;
953        let hooks: SubagentHooks = serde_norway::from_str(yaml).unwrap();
954        assert_eq!(hooks.pre_tool_use[0].hooks[0].timeout_secs, 30);
955        assert!(!hooks.pre_tool_use[0].hooks[0].fail_closed);
956    }
957
958    #[test]
959    fn subagent_hooks_empty_default() {
960        let hooks = SubagentHooks::default();
961        assert!(hooks.pre_tool_use.is_empty());
962        assert!(hooks.post_tool_use.is_empty());
963    }
964
965    // ── regression: #4011 ────────────────────────────────────────────────────
966
967    /// Regression for #4011: a hook that writes to stdout and then hangs must be killed
968    /// within `timeout_secs` and return `HookError::Timeout`.  The old `tokio::join!`
969    /// implementation deadlocked here because the stdout reader blocked on EOF while
970    /// `child.kill()` was gated behind the join completing.
971    #[tokio::test]
972    async fn fire_shell_hook_timeout_with_stdout_does_not_deadlock() {
973        // Write a line to stdout, then block forever — this is the exact pattern that
974        // triggered the deadlock in the original implementation.
975        let cmd = r#"echo "some output"; sleep 60"#;
976        let hooks = vec![cmd_hook(cmd, true, 1)];
977        let env = HashMap::new();
978
979        // Must complete in bounded time (the timeout is 1 s; allow 5 s total for CI variance).
980        let result = tokio::time::timeout(
981            std::time::Duration::from_secs(5),
982            fire_hooks(&hooks, &env, None, None),
983        )
984        .await
985        .expect("fire_hooks must return within 5 s — deadlock regression #4011");
986
987        assert!(
988            matches!(result, Err(HookError::Timeout { .. })),
989            "expected HookError::Timeout, got: {result:?}"
990        );
991    }
992
993    // ── hook_if_matches ───────────────────────────────────────────────────────
994
995    #[test]
996    fn hook_if_matches_tool_positive() {
997        assert!(hook_if_matches("tool:shell", Some("shell")));
998    }
999
1000    #[test]
1001    fn hook_if_matches_tool_substring() {
1002        assert!(hook_if_matches("tool:shell", Some("subshell")));
1003    }
1004
1005    #[test]
1006    fn hook_if_matches_tool_negative() {
1007        assert!(!hook_if_matches("tool:shell", Some("python")));
1008    }
1009
1010    #[test]
1011    fn hook_if_matches_no_tool_name() {
1012        assert!(!hook_if_matches("tool:shell", None));
1013    }
1014
1015    #[test]
1016    fn hook_if_matches_empty_token_fail_closed() {
1017        // M2: empty token after `tool:` must return false (fail-closed), never fire on everything.
1018        assert!(!hook_if_matches("tool:", Some("shell")));
1019    }
1020
1021    #[test]
1022    fn hook_if_matches_empty_tool_name_fail_closed() {
1023        // M1: start/stop sites pass Some("") — must behave like "no tool context", not match.
1024        assert!(!hook_if_matches("tool:shell", Some("")));
1025    }
1026
1027    #[test]
1028    fn hook_if_matches_empty_token_with_empty_tool_fail_closed() {
1029        assert!(!hook_if_matches("tool:", Some("")));
1030    }
1031
1032    #[test]
1033    fn hook_if_matches_unknown_key_fail_closed() {
1034        assert!(!hook_if_matches("badkey:value", Some("x")));
1035    }
1036
1037    #[test]
1038    fn hook_if_matches_no_colon_fail_closed() {
1039        assert!(!hook_if_matches("no-colon", Some("x")));
1040    }
1041
1042    // ── `if` filter integration with fire_hooks ───────────────────────────────
1043
1044    #[tokio::test]
1045    async fn fire_hooks_if_condition_matches_fires() {
1046        // hook with `if = "tool:shell"` and env ZEPH_TOOL_NAME=shell → must fire
1047        let hook = HookDef {
1048            action: HookAction::Command {
1049                command: "true".to_owned(),
1050            },
1051            timeout_secs: 5,
1052            fail_closed: true,
1053            r#if: Some("tool:shell".to_owned()),
1054        };
1055        let mut env = HashMap::new();
1056        env.insert("ZEPH_TOOL_NAME".to_owned(), "shell".to_owned());
1057        assert!(fire_hooks(&[hook], &env, None, None).await.is_ok());
1058    }
1059
1060    #[tokio::test]
1061    async fn fire_hooks_if_condition_does_not_match_skips() {
1062        // hook with `if = "tool:shell"` and env ZEPH_TOOL_NAME=python → must NOT fire
1063        // Use fail_closed=true and `false` exit to prove skipping (no error = skipped).
1064        let hook = HookDef {
1065            action: HookAction::Command {
1066                command: "exit 1".to_owned(),
1067            },
1068            timeout_secs: 5,
1069            fail_closed: true,
1070            r#if: Some("tool:shell".to_owned()),
1071        };
1072        let mut env = HashMap::new();
1073        env.insert("ZEPH_TOOL_NAME".to_owned(), "python".to_owned());
1074        // Would return Err if the hook ran (fail_closed=true, exit 1). Instead it should be Ok.
1075        assert!(fire_hooks(&[hook], &env, None, None).await.is_ok());
1076    }
1077
1078    #[tokio::test]
1079    async fn fire_hooks_no_if_always_fires() {
1080        let hook = cmd_hook("true", false, 5);
1081        let env = HashMap::new();
1082        assert!(fire_hooks(&[hook], &env, None, None).await.is_ok());
1083    }
1084
1085    // ── PostToolUseHookInput agent fields ────────────────────────────────────
1086
1087    #[test]
1088    fn post_tool_use_input_serializes_agent_fields() {
1089        let input = PostToolUseHookInput {
1090            tool_name: "Shell",
1091            tool_args: &serde_json::Value::Null,
1092            session_id: None,
1093            duration_ms: 42,
1094            tool_output: Some("out"),
1095            tool_error: None,
1096            agent_id: Some("conv-1"),
1097            agent_type: "main",
1098        };
1099        let json = serde_json::to_value(&input).unwrap();
1100        assert_eq!(json["agent_type"], "main");
1101        assert_eq!(json["agent_id"], "conv-1");
1102    }
1103
1104    #[test]
1105    fn post_tool_use_input_omits_agent_id_when_none() {
1106        let input = PostToolUseHookInput {
1107            tool_name: "Shell",
1108            tool_args: &serde_json::Value::Null,
1109            session_id: None,
1110            duration_ms: 42,
1111            tool_output: None,
1112            tool_error: None,
1113            agent_id: None,
1114            agent_type: "main",
1115        };
1116        let json = serde_json::to_value(&input).unwrap();
1117        assert_eq!(json["agent_type"], "main");
1118        assert!(json.get("agent_id").is_none() || json["agent_id"].is_null());
1119        let text = serde_json::to_string(&input).unwrap();
1120        assert!(
1121            !text.contains("agent_id"),
1122            "agent_id must not appear when None"
1123        );
1124    }
1125
1126    // ── make_base_hook_env agent_type env var (subagent path) ────────────────
1127
1128    #[test]
1129    fn make_base_hook_env_sets_tool_name() {
1130        let env = make_base_hook_env("Edit", &serde_json::Value::Null);
1131        assert_eq!(env.get("ZEPH_TOOL_NAME").map(String::as_str), Some("Edit"));
1132    }
1133}