1use 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#[derive(Debug, Error)]
52pub enum HookError {
53 #[error("hook command failed (exit code {code}): {command}")]
55 NonZeroExit { command: String, code: i32 },
56
57 #[error("hook command timed out after {timeout_secs}s: {command}")]
59 Timeout { command: String, timeout_secs: u64 },
60
61 #[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#[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
106pub 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 cmd.env_clear();
153 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 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 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#[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 #[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 #[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), make_hook("true", false, 5), ];
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 #[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}