1use 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#[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#[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#[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 #[serde(default)]
61 pub fail_closed: bool,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct HookMatcher {
71 pub matcher: String,
72 pub hooks: Vec<HookDef>,
73}
74
75#[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#[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
111pub 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 cmd.env_clear();
158 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 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 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#[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 #[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 #[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), make_hook("true", false, 5), ];
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 #[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}