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//! # Examples
28//!
29//! ```rust,no_run
30//! use std::collections::HashMap;
31//! use zeph_subagent::{HookDef, HookAction, fire_hooks};
32//!
33//! async fn run() {
34//!     let hooks = vec![HookDef {
35//!         action: HookAction::Command { command: "true".to_owned() },
36//!         timeout_secs: 5,
37//!         fail_closed: false,
38//!     }];
39//!     fire_hooks(&hooks, &HashMap::new(), None).await.unwrap();
40//! }
41//! ```
42
43use std::collections::HashMap;
44use std::hash::BuildHasher;
45use std::time::Duration;
46
47use thiserror::Error;
48use tokio::process::Command;
49use tokio::time::timeout;
50
51pub use zeph_config::{HookAction, HookDef, HookMatcher, SubagentHooks};
52
53// ── McpDispatch ───────────────────────────────────────────────────────────────
54
55/// Abstraction over MCP tool dispatch used by hooks.
56///
57/// This trait decouples `zeph-subagent` from `zeph-mcp`, allowing the hook
58/// executor to call MCP tools without a direct crate dependency. Implementors
59/// are provided by `zeph-core` at the call site.
60///
61/// # Errors
62///
63/// Returns an error string if the tool call fails for any reason (server not
64/// found, policy violation, timeout, etc.).
65pub trait McpDispatch: Send + Sync {
66    /// Call a tool on the named MCP server with the given JSON arguments.
67    fn call_tool<'a>(
68        &'a self,
69        server: &'a str,
70        tool: &'a str,
71        args: serde_json::Value,
72    ) -> std::pin::Pin<
73        Box<dyn std::future::Future<Output = Result<serde_json::Value, String>> + Send + 'a>,
74    >;
75}
76
77// ── Error ─────────────────────────────────────────────────────────────────────
78
79/// Errors that can occur when executing a lifecycle hook.
80#[derive(Debug, Error)]
81pub enum HookError {
82    /// The shell command exited with a non-zero status code.
83    #[error("hook command failed (exit code {code}): {command}")]
84    NonZeroExit { command: String, code: i32 },
85
86    /// The shell command did not complete within its configured `timeout_secs`.
87    #[error("hook command timed out after {timeout_secs}s: {command}")]
88    Timeout { command: String, timeout_secs: u64 },
89
90    /// The shell could not be spawned or an I/O error occurred while waiting.
91    #[error("hook I/O error for command '{command}': {source}")]
92    Io {
93        command: String,
94        #[source]
95        source: std::io::Error,
96    },
97
98    /// An `mcp_tool` hook was configured but no MCP manager is available.
99    #[error(
100        "mcp_tool hook requires an MCP manager but none was provided (server={server}, tool={tool})"
101    )]
102    McpUnavailable { server: String, tool: String },
103
104    /// The MCP tool call returned an error.
105    #[error("mcp_tool hook failed (server={server}, tool={tool}): {reason}")]
106    McpToolFailed {
107        server: String,
108        tool: String,
109        reason: String,
110    },
111}
112
113// ── Matching ──────────────────────────────────────────────────────────────────
114
115/// Return all hook definitions from `matchers` whose patterns match `tool_name`.
116///
117/// Matching rules:
118/// - Each [`HookMatcher`]`.matcher` is a `|`-separated list of tokens.
119/// - A token matches if `tool_name` **contains** the token (case-sensitive substring).
120/// - Empty tokens are ignored.
121///
122/// # Examples
123///
124/// ```rust
125/// use zeph_subagent::{HookDef, HookAction, HookMatcher, matching_hooks};
126///
127/// let hook = HookDef { action: HookAction::Command { command: "echo hi".to_owned() }, timeout_secs: 30, fail_closed: false };
128/// let matchers = vec![HookMatcher { matcher: "Edit|Write".to_owned(), hooks: vec![hook] }];
129///
130/// assert_eq!(matching_hooks(&matchers, "Edit").len(), 1);
131/// assert!(matching_hooks(&matchers, "Shell").is_empty());
132/// ```
133#[must_use]
134pub fn matching_hooks<'a>(matchers: &'a [HookMatcher], tool_name: &str) -> Vec<&'a HookDef> {
135    let mut result = Vec::new();
136    for m in matchers {
137        let matched = m
138            .matcher
139            .split('|')
140            .filter(|token| !token.is_empty())
141            .any(|token| tool_name.contains(token));
142        if matched {
143            result.extend(m.hooks.iter());
144        }
145    }
146    result
147}
148
149// ── Execution ─────────────────────────────────────────────────────────────────
150
151/// Execute a list of hook definitions, setting the provided environment variables.
152///
153/// Hooks are run sequentially. If a hook has `fail_closed = true` and fails,
154/// execution stops immediately and `Err` is returned. Otherwise errors are logged
155/// and execution continues.
156///
157/// The `mcp` parameter provides MCP tool dispatch for `type = "mcp_tool"` hooks.
158/// Pass `None` when no MCP manager is available; `mcp_tool` hooks will fail with
159/// [`HookError::McpUnavailable`] (respecting `fail_closed`).
160///
161/// # Errors
162///
163/// Returns [`HookError`] if a fail-closed hook exits non-zero, times out, or the
164/// MCP call fails.
165pub async fn fire_hooks<S: BuildHasher>(
166    hooks: &[HookDef],
167    env: &HashMap<String, String, S>,
168    mcp: Option<&dyn McpDispatch>,
169) -> Result<(), HookError> {
170    for hook in hooks {
171        let result = fire_single_hook(hook, env, mcp).await;
172        match result {
173            Ok(()) => {}
174            Err(e) if hook.fail_closed => {
175                tracing::error!(
176                    error = %e,
177                    "fail-closed hook failed — aborting"
178                );
179                return Err(e);
180            }
181            Err(e) => {
182                tracing::warn!(
183                    error = %e,
184                    "hook failed (fail_open) — continuing"
185                );
186            }
187        }
188    }
189    Ok(())
190}
191
192async fn fire_single_hook<S: BuildHasher>(
193    hook: &HookDef,
194    env: &HashMap<String, String, S>,
195    mcp: Option<&dyn McpDispatch>,
196) -> Result<(), HookError> {
197    match &hook.action {
198        HookAction::Command { command } => fire_shell_hook(command, hook.timeout_secs, env).await,
199        HookAction::McpTool { server, tool, args } => {
200            let dispatcher = mcp.ok_or_else(|| HookError::McpUnavailable {
201                server: server.clone(),
202                tool: tool.clone(),
203            })?;
204            let call_fut = dispatcher.call_tool(server, tool, args.clone());
205            match timeout(Duration::from_secs(hook.timeout_secs), call_fut).await {
206                Ok(Ok(_)) => Ok(()),
207                Ok(Err(reason)) => Err(HookError::McpToolFailed {
208                    server: server.clone(),
209                    tool: tool.clone(),
210                    reason,
211                }),
212                Err(_) => Err(HookError::Timeout {
213                    command: format!("mcp_tool:{server}/{tool}"),
214                    timeout_secs: hook.timeout_secs,
215                }),
216            }
217        }
218    }
219}
220
221async fn fire_shell_hook<S: BuildHasher>(
222    command: &str,
223    timeout_secs: u64,
224    env: &HashMap<String, String, S>,
225) -> Result<(), HookError> {
226    let mut cmd = Command::new("sh");
227    cmd.arg("-c").arg(command);
228    // SEC-H-002: clear inherited env to prevent secret leakage, then set only hook vars.
229    cmd.env_clear();
230    // Preserve minimal PATH so the shell can find standard tools.
231    if let Ok(path) = std::env::var("PATH") {
232        cmd.env("PATH", path);
233    }
234    for (k, v) in env {
235        cmd.env(k, v);
236    }
237    // Suppress stdout/stderr to prevent hook output flooding the agent.
238    cmd.stdout(std::process::Stdio::null());
239    cmd.stderr(std::process::Stdio::null());
240
241    let mut child = cmd.spawn().map_err(|e| HookError::Io {
242        command: command.to_owned(),
243        source: e,
244    })?;
245
246    let result = timeout(Duration::from_secs(timeout_secs), child.wait()).await;
247
248    match result {
249        Ok(Ok(status)) if status.success() => Ok(()),
250        Ok(Ok(status)) => Err(HookError::NonZeroExit {
251            command: command.to_owned(),
252            code: status.code().unwrap_or(-1),
253        }),
254        Ok(Err(e)) => Err(HookError::Io {
255            command: command.to_owned(),
256            source: e,
257        }),
258        Err(_) => {
259            // SEC-H-004: explicitly kill child on timeout to prevent orphan processes.
260            let _ = child.kill().await;
261            Err(HookError::Timeout {
262                command: command.to_owned(),
263                timeout_secs,
264            })
265        }
266    }
267}
268
269// ── Tests ─────────────────────────────────────────────────────────────────────
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    fn cmd_hook(command: &str, fail_closed: bool, timeout_secs: u64) -> HookDef {
276        HookDef {
277            action: HookAction::Command {
278                command: command.to_owned(),
279            },
280            timeout_secs,
281            fail_closed,
282        }
283    }
284
285    fn make_matcher(matcher: &str, hooks: Vec<HookDef>) -> HookMatcher {
286        HookMatcher {
287            matcher: matcher.to_owned(),
288            hooks,
289        }
290    }
291
292    // ── matching_hooks ────────────────────────────────────────────────────────
293
294    #[test]
295    fn matching_hooks_exact_name() {
296        let hook = cmd_hook("echo hi", false, 30);
297        let matchers = vec![make_matcher("Edit", vec![hook.clone()])];
298        let result = matching_hooks(&matchers, "Edit");
299        assert_eq!(result.len(), 1);
300        assert!(
301            matches!(&result[0].action, HookAction::Command { command } if command == "echo hi")
302        );
303    }
304
305    #[test]
306    fn matching_hooks_substring() {
307        let hook = cmd_hook("echo sub", false, 30);
308        let matchers = vec![make_matcher("Edit", vec![hook.clone()])];
309        let result = matching_hooks(&matchers, "EditFile");
310        assert_eq!(result.len(), 1);
311    }
312
313    #[test]
314    fn matching_hooks_pipe_separated() {
315        let h1 = cmd_hook("echo e", false, 30);
316        let h2 = cmd_hook("echo w", false, 30);
317        let matchers = vec![
318            make_matcher("Edit|Write", vec![h1.clone()]),
319            make_matcher("Shell", vec![h2.clone()]),
320        ];
321        let result_edit = matching_hooks(&matchers, "Edit");
322        assert_eq!(result_edit.len(), 1);
323
324        let result_shell = matching_hooks(&matchers, "Shell");
325        assert_eq!(result_shell.len(), 1);
326
327        let result_none = matching_hooks(&matchers, "Read");
328        assert!(result_none.is_empty());
329    }
330
331    #[test]
332    fn matching_hooks_no_match() {
333        let hook = cmd_hook("echo nope", false, 30);
334        let matchers = vec![make_matcher("Edit", vec![hook])];
335        let result = matching_hooks(&matchers, "Shell");
336        assert!(result.is_empty());
337    }
338
339    #[test]
340    fn matching_hooks_empty_token_ignored() {
341        let hook = cmd_hook("echo empty", false, 30);
342        let matchers = vec![make_matcher("|Edit|", vec![hook])];
343        let result = matching_hooks(&matchers, "Edit");
344        assert_eq!(result.len(), 1);
345    }
346
347    #[test]
348    fn matching_hooks_multiple_matchers_both_match() {
349        let h1 = cmd_hook("echo 1", false, 30);
350        let h2 = cmd_hook("echo 2", false, 30);
351        let matchers = vec![
352            make_matcher("Shell", vec![h1]),
353            make_matcher("Shell", vec![h2]),
354        ];
355        let result = matching_hooks(&matchers, "Shell");
356        assert_eq!(result.len(), 2);
357    }
358
359    // ── fire_hooks ────────────────────────────────────────────────────────────
360
361    #[tokio::test]
362    async fn fire_hooks_success() {
363        let hooks = vec![cmd_hook("true", false, 5)];
364        let env = HashMap::new();
365        assert!(fire_hooks(&hooks, &env, None).await.is_ok());
366    }
367
368    #[tokio::test]
369    async fn fire_hooks_fail_open_continues() {
370        let hooks = vec![
371            cmd_hook("false", false, 5), // fail open
372            cmd_hook("true", false, 5),  // should still run
373        ];
374        let env = HashMap::new();
375        assert!(fire_hooks(&hooks, &env, None).await.is_ok());
376    }
377
378    #[tokio::test]
379    async fn fire_hooks_fail_closed_returns_err() {
380        let hooks = vec![cmd_hook("false", true, 5)];
381        let env = HashMap::new();
382        let result = fire_hooks(&hooks, &env, None).await;
383        assert!(result.is_err());
384        let err = result.unwrap_err();
385        assert!(matches!(err, HookError::NonZeroExit { .. }));
386    }
387
388    #[tokio::test]
389    async fn fire_hooks_timeout() {
390        let hooks = vec![cmd_hook("sleep 10", true, 1)];
391        let env = HashMap::new();
392        let result = fire_hooks(&hooks, &env, None).await;
393        assert!(result.is_err());
394        let err = result.unwrap_err();
395        assert!(matches!(err, HookError::Timeout { .. }));
396    }
397
398    #[tokio::test]
399    async fn fire_hooks_env_passed() {
400        let hooks = vec![cmd_hook(r#"test "$ZEPH_TEST_VAR" = "hello""#, true, 5)];
401        let mut env = HashMap::new();
402        env.insert("ZEPH_TEST_VAR".to_owned(), "hello".to_owned());
403        assert!(fire_hooks(&hooks, &env, None).await.is_ok());
404    }
405
406    #[tokio::test]
407    async fn fire_hooks_empty_list_ok() {
408        let env = HashMap::new();
409        assert!(fire_hooks(&[], &env, None).await.is_ok());
410    }
411
412    #[tokio::test]
413    async fn fire_hooks_mcp_unavailable_fail_open() {
414        let hooks = vec![HookDef {
415            action: HookAction::McpTool {
416                server: "srv".into(),
417                tool: "t".into(),
418                args: serde_json::Value::Null,
419            },
420            timeout_secs: 5,
421            fail_closed: false,
422        }];
423        let env = HashMap::new();
424        // fail_open: should succeed even though MCP is unavailable
425        assert!(fire_hooks(&hooks, &env, None).await.is_ok());
426    }
427
428    #[tokio::test]
429    async fn fire_hooks_mcp_unavailable_fail_closed() {
430        let hooks = vec![HookDef {
431            action: HookAction::McpTool {
432                server: "srv".into(),
433                tool: "t".into(),
434                args: serde_json::Value::Null,
435            },
436            timeout_secs: 5,
437            fail_closed: true,
438        }];
439        let env = HashMap::new();
440        let result = fire_hooks(&hooks, &env, None).await;
441        assert!(matches!(result, Err(HookError::McpUnavailable { .. })));
442    }
443
444    // ── YAML parsing ──────────────────────────────────────────────────────────
445
446    #[test]
447    fn subagent_hooks_parses_from_yaml() {
448        let yaml = r#"
449PreToolUse:
450  - matcher: "Edit|Write"
451    hooks:
452      - type: command
453        command: "echo pre"
454        timeout_secs: 10
455        fail_closed: false
456PostToolUse:
457  - matcher: "Shell"
458    hooks:
459      - type: command
460        command: "echo post"
461"#;
462        let hooks: SubagentHooks = serde_norway::from_str(yaml).unwrap();
463        assert_eq!(hooks.pre_tool_use.len(), 1);
464        assert_eq!(hooks.pre_tool_use[0].matcher, "Edit|Write");
465        assert_eq!(hooks.pre_tool_use[0].hooks.len(), 1);
466        assert!(
467            matches!(&hooks.pre_tool_use[0].hooks[0].action, HookAction::Command { command } if command == "echo pre")
468        );
469        assert_eq!(hooks.post_tool_use.len(), 1);
470    }
471
472    #[test]
473    fn subagent_hooks_defaults_timeout() {
474        let yaml = r#"
475PreToolUse:
476  - matcher: "Edit"
477    hooks:
478      - type: command
479        command: "echo hi"
480"#;
481        let hooks: SubagentHooks = serde_norway::from_str(yaml).unwrap();
482        assert_eq!(hooks.pre_tool_use[0].hooks[0].timeout_secs, 30);
483        assert!(!hooks.pre_tool_use[0].hooks[0].fail_closed);
484    }
485
486    #[test]
487    fn subagent_hooks_empty_default() {
488        let hooks = SubagentHooks::default();
489        assert!(hooks.pre_tool_use.is_empty());
490        assert!(hooks.post_tool_use.is_empty());
491    }
492}