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 executed at specific points in a sub-agent's lifecycle.
7//! Per-agent frontmatter supports `PreToolUse` and `PostToolUse` hooks via the
8//! `hooks` section. `SubagentStart` and `SubagentStop` are config-level events.
9
10use std::collections::HashMap;
11use std::hash::BuildHasher;
12use std::time::Duration;
13
14use thiserror::Error;
15use tokio::process::Command;
16use tokio::time::timeout;
17
18pub use zeph_config::{HookDef, HookMatcher, HookType, SubagentHooks};
19
20// ── Error ─────────────────────────────────────────────────────────────────────
21
22#[derive(Debug, Error)]
23pub enum HookError {
24    #[error("hook command failed (exit code {code}): {command}")]
25    NonZeroExit { command: String, code: i32 },
26
27    #[error("hook command timed out after {timeout_secs}s: {command}")]
28    Timeout { command: String, timeout_secs: u64 },
29
30    #[error("hook I/O error for command '{command}': {source}")]
31    Io {
32        command: String,
33        #[source]
34        source: std::io::Error,
35    },
36}
37
38// ── Matching ──────────────────────────────────────────────────────────────────
39
40/// Return all hook definitions whose matchers match `tool_name`.
41///
42/// Matching rules:
43/// - Each `HookMatcher.matcher` is a `|`-separated list of tokens.
44/// - A token matches if `tool_name` contains the token (case-sensitive substring).
45/// - Empty tokens are ignored.
46#[must_use]
47pub fn matching_hooks<'a>(matchers: &'a [HookMatcher], tool_name: &str) -> Vec<&'a HookDef> {
48    let mut result = Vec::new();
49    for m in matchers {
50        let matched = m
51            .matcher
52            .split('|')
53            .filter(|token| !token.is_empty())
54            .any(|token| tool_name.contains(token));
55        if matched {
56            result.extend(m.hooks.iter());
57        }
58    }
59    result
60}
61
62// ── Execution ─────────────────────────────────────────────────────────────────
63
64/// Execute a list of hook definitions, setting the provided environment variables.
65///
66/// Hooks are run sequentially. If a hook has `fail_closed = true` and fails,
67/// execution stops immediately and `Err` is returned. Otherwise errors are logged
68/// and execution continues.
69///
70/// # Errors
71///
72/// Returns [`HookError`] if a fail-closed hook exits non-zero or times out.
73pub async fn fire_hooks<S: BuildHasher>(
74    hooks: &[HookDef],
75    env: &HashMap<String, String, S>,
76) -> Result<(), HookError> {
77    for hook in hooks {
78        let result = fire_single_hook(hook, env).await;
79        match result {
80            Ok(()) => {}
81            Err(e) if hook.fail_closed => {
82                tracing::error!(
83                    command = %hook.command,
84                    error = %e,
85                    "fail-closed hook failed — aborting"
86                );
87                return Err(e);
88            }
89            Err(e) => {
90                tracing::warn!(
91                    command = %hook.command,
92                    error = %e,
93                    "hook failed (fail_open) — continuing"
94                );
95            }
96        }
97    }
98    Ok(())
99}
100
101async fn fire_single_hook<S: BuildHasher>(
102    hook: &HookDef,
103    env: &HashMap<String, String, S>,
104) -> Result<(), HookError> {
105    let mut cmd = Command::new("sh");
106    cmd.arg("-c").arg(&hook.command);
107    // SEC-H-002: clear inherited env to prevent secret leakage, then set only hook vars.
108    cmd.env_clear();
109    // Preserve minimal PATH so the shell can find standard tools.
110    if let Ok(path) = std::env::var("PATH") {
111        cmd.env("PATH", path);
112    }
113    for (k, v) in env {
114        cmd.env(k, v);
115    }
116    // Suppress stdout/stderr to prevent hook output flooding the agent.
117    cmd.stdout(std::process::Stdio::null());
118    cmd.stderr(std::process::Stdio::null());
119
120    let mut child = cmd.spawn().map_err(|e| HookError::Io {
121        command: hook.command.clone(),
122        source: e,
123    })?;
124
125    let result = timeout(Duration::from_secs(hook.timeout_secs), child.wait()).await;
126
127    match result {
128        Ok(Ok(status)) if status.success() => Ok(()),
129        Ok(Ok(status)) => Err(HookError::NonZeroExit {
130            command: hook.command.clone(),
131            code: status.code().unwrap_or(-1),
132        }),
133        Ok(Err(e)) => Err(HookError::Io {
134            command: hook.command.clone(),
135            source: e,
136        }),
137        Err(_) => {
138            // SEC-H-004: explicitly kill child on timeout to prevent orphan processes.
139            let _ = child.kill().await;
140            Err(HookError::Timeout {
141                command: hook.command.clone(),
142                timeout_secs: hook.timeout_secs,
143            })
144        }
145    }
146}
147
148// ── Tests ─────────────────────────────────────────────────────────────────────
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    fn make_hook(command: &str, fail_closed: bool, timeout_secs: u64) -> HookDef {
155        HookDef {
156            hook_type: HookType::Command,
157            command: command.to_owned(),
158            timeout_secs,
159            fail_closed,
160        }
161    }
162
163    fn make_matcher(matcher: &str, hooks: Vec<HookDef>) -> HookMatcher {
164        HookMatcher {
165            matcher: matcher.to_owned(),
166            hooks,
167        }
168    }
169
170    // ── matching_hooks ────────────────────────────────────────────────────────
171
172    #[test]
173    fn matching_hooks_exact_name() {
174        let hook = make_hook("echo hi", false, 30);
175        let matchers = vec![make_matcher("Edit", vec![hook.clone()])];
176        let result = matching_hooks(&matchers, "Edit");
177        assert_eq!(result.len(), 1);
178        assert_eq!(result[0].command, "echo hi");
179    }
180
181    #[test]
182    fn matching_hooks_substring() {
183        let hook = make_hook("echo sub", false, 30);
184        let matchers = vec![make_matcher("Edit", vec![hook.clone()])];
185        let result = matching_hooks(&matchers, "EditFile");
186        assert_eq!(result.len(), 1);
187    }
188
189    #[test]
190    fn matching_hooks_pipe_separated() {
191        let h1 = make_hook("echo e", false, 30);
192        let h2 = make_hook("echo w", false, 30);
193        let matchers = vec![
194            make_matcher("Edit|Write", vec![h1.clone()]),
195            make_matcher("Shell", vec![h2.clone()]),
196        ];
197        let result_edit = matching_hooks(&matchers, "Edit");
198        assert_eq!(result_edit.len(), 1);
199        assert_eq!(result_edit[0].command, "echo e");
200
201        let result_shell = matching_hooks(&matchers, "Shell");
202        assert_eq!(result_shell.len(), 1);
203        assert_eq!(result_shell[0].command, "echo w");
204
205        let result_none = matching_hooks(&matchers, "Read");
206        assert!(result_none.is_empty());
207    }
208
209    #[test]
210    fn matching_hooks_no_match() {
211        let hook = make_hook("echo nope", false, 30);
212        let matchers = vec![make_matcher("Edit", vec![hook])];
213        let result = matching_hooks(&matchers, "Shell");
214        assert!(result.is_empty());
215    }
216
217    #[test]
218    fn matching_hooks_empty_token_ignored() {
219        let hook = make_hook("echo empty", false, 30);
220        let matchers = vec![make_matcher("|Edit|", vec![hook])];
221        let result = matching_hooks(&matchers, "Edit");
222        assert_eq!(result.len(), 1);
223    }
224
225    #[test]
226    fn matching_hooks_multiple_matchers_both_match() {
227        let h1 = make_hook("echo 1", false, 30);
228        let h2 = make_hook("echo 2", false, 30);
229        let matchers = vec![
230            make_matcher("Shell", vec![h1]),
231            make_matcher("Shell", vec![h2]),
232        ];
233        let result = matching_hooks(&matchers, "Shell");
234        assert_eq!(result.len(), 2);
235    }
236
237    // ── fire_hooks ────────────────────────────────────────────────────────────
238
239    #[tokio::test]
240    async fn fire_hooks_success() {
241        let hooks = vec![make_hook("true", false, 5)];
242        let env = HashMap::new();
243        assert!(fire_hooks(&hooks, &env).await.is_ok());
244    }
245
246    #[tokio::test]
247    async fn fire_hooks_fail_open_continues() {
248        let hooks = vec![
249            make_hook("false", false, 5), // fail open
250            make_hook("true", false, 5),  // should still run
251        ];
252        let env = HashMap::new();
253        assert!(fire_hooks(&hooks, &env).await.is_ok());
254    }
255
256    #[tokio::test]
257    async fn fire_hooks_fail_closed_returns_err() {
258        let hooks = vec![make_hook("false", true, 5)];
259        let env = HashMap::new();
260        let result = fire_hooks(&hooks, &env).await;
261        assert!(result.is_err());
262        let err = result.unwrap_err();
263        assert!(matches!(err, HookError::NonZeroExit { .. }));
264    }
265
266    #[tokio::test]
267    async fn fire_hooks_timeout() {
268        let hooks = vec![make_hook("sleep 10", true, 1)];
269        let env = HashMap::new();
270        let result = fire_hooks(&hooks, &env).await;
271        assert!(result.is_err());
272        let err = result.unwrap_err();
273        assert!(matches!(err, HookError::Timeout { .. }));
274    }
275
276    #[tokio::test]
277    async fn fire_hooks_env_passed() {
278        let hooks = vec![make_hook(r#"test "$ZEPH_TEST_VAR" = "hello""#, true, 5)];
279        let mut env = HashMap::new();
280        env.insert("ZEPH_TEST_VAR".to_owned(), "hello".to_owned());
281        assert!(fire_hooks(&hooks, &env).await.is_ok());
282    }
283
284    #[tokio::test]
285    async fn fire_hooks_empty_list_ok() {
286        let env = HashMap::new();
287        assert!(fire_hooks(&[], &env).await.is_ok());
288    }
289
290    // ── YAML parsing ──────────────────────────────────────────────────────────
291
292    #[test]
293    fn subagent_hooks_parses_from_yaml() {
294        let yaml = r#"
295PreToolUse:
296  - matcher: "Edit|Write"
297    hooks:
298      - type: command
299        command: "echo pre"
300        timeout_secs: 10
301        fail_closed: false
302PostToolUse:
303  - matcher: "Shell"
304    hooks:
305      - type: command
306        command: "echo post"
307"#;
308        let hooks: SubagentHooks = serde_norway::from_str(yaml).unwrap();
309        assert_eq!(hooks.pre_tool_use.len(), 1);
310        assert_eq!(hooks.pre_tool_use[0].matcher, "Edit|Write");
311        assert_eq!(hooks.pre_tool_use[0].hooks.len(), 1);
312        assert_eq!(hooks.pre_tool_use[0].hooks[0].command, "echo pre");
313        assert_eq!(hooks.post_tool_use.len(), 1);
314    }
315
316    #[test]
317    fn subagent_hooks_defaults_timeout() {
318        let yaml = r#"
319PreToolUse:
320  - matcher: "Edit"
321    hooks:
322      - type: command
323        command: "echo hi"
324"#;
325        let hooks: SubagentHooks = serde_norway::from_str(yaml).unwrap();
326        assert_eq!(hooks.pre_tool_use[0].hooks[0].timeout_secs, 30);
327        assert!(!hooks.pre_tool_use[0].hooks[0].fail_closed);
328    }
329
330    #[test]
331    fn subagent_hooks_empty_default() {
332        let hooks = SubagentHooks::default();
333        assert!(hooks.pre_tool_use.is_empty());
334        assert!(hooks.post_tool_use.is_empty());
335    }
336}