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