1use 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#[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#[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
62pub 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 cmd.env_clear();
109 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 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 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#[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 #[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 #[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), make_hook("true", false, 5), ];
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 #[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}