Skip to main content

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