1use std::collections::HashMap;
44use std::hash::BuildHasher;
45use std::time::Duration;
46
47use thiserror::Error;
48use tokio::process::Command;
49use tokio::time::timeout;
50
51pub use zeph_config::{HookAction, HookDef, HookMatcher, SubagentHooks};
52
53pub trait McpDispatch: Send + Sync {
66 fn call_tool<'a>(
68 &'a self,
69 server: &'a str,
70 tool: &'a str,
71 args: serde_json::Value,
72 ) -> std::pin::Pin<
73 Box<dyn std::future::Future<Output = Result<serde_json::Value, String>> + Send + 'a>,
74 >;
75}
76
77#[derive(Debug, Error)]
81pub enum HookError {
82 #[error("hook command failed (exit code {code}): {command}")]
84 NonZeroExit { command: String, code: i32 },
85
86 #[error("hook command timed out after {timeout_secs}s: {command}")]
88 Timeout { command: String, timeout_secs: u64 },
89
90 #[error("hook I/O error for command '{command}': {source}")]
92 Io {
93 command: String,
94 #[source]
95 source: std::io::Error,
96 },
97
98 #[error(
100 "mcp_tool hook requires an MCP manager but none was provided (server={server}, tool={tool})"
101 )]
102 McpUnavailable { server: String, tool: String },
103
104 #[error("mcp_tool hook failed (server={server}, tool={tool}): {reason}")]
106 McpToolFailed {
107 server: String,
108 tool: String,
109 reason: String,
110 },
111}
112
113#[must_use]
134pub fn matching_hooks<'a>(matchers: &'a [HookMatcher], tool_name: &str) -> Vec<&'a HookDef> {
135 let mut result = Vec::new();
136 for m in matchers {
137 let matched = m
138 .matcher
139 .split('|')
140 .filter(|token| !token.is_empty())
141 .any(|token| tool_name.contains(token));
142 if matched {
143 result.extend(m.hooks.iter());
144 }
145 }
146 result
147}
148
149pub async fn fire_hooks<S: BuildHasher>(
166 hooks: &[HookDef],
167 env: &HashMap<String, String, S>,
168 mcp: Option<&dyn McpDispatch>,
169) -> Result<(), HookError> {
170 for hook in hooks {
171 let result = fire_single_hook(hook, env, mcp).await;
172 match result {
173 Ok(()) => {}
174 Err(e) if hook.fail_closed => {
175 tracing::error!(
176 error = %e,
177 "fail-closed hook failed — aborting"
178 );
179 return Err(e);
180 }
181 Err(e) => {
182 tracing::warn!(
183 error = %e,
184 "hook failed (fail_open) — continuing"
185 );
186 }
187 }
188 }
189 Ok(())
190}
191
192async fn fire_single_hook<S: BuildHasher>(
193 hook: &HookDef,
194 env: &HashMap<String, String, S>,
195 mcp: Option<&dyn McpDispatch>,
196) -> Result<(), HookError> {
197 match &hook.action {
198 HookAction::Command { command } => fire_shell_hook(command, hook.timeout_secs, env).await,
199 HookAction::McpTool { server, tool, args } => {
200 let dispatcher = mcp.ok_or_else(|| HookError::McpUnavailable {
201 server: server.clone(),
202 tool: tool.clone(),
203 })?;
204 let call_fut = dispatcher.call_tool(server, tool, args.clone());
205 match timeout(Duration::from_secs(hook.timeout_secs), call_fut).await {
206 Ok(Ok(_)) => Ok(()),
207 Ok(Err(reason)) => Err(HookError::McpToolFailed {
208 server: server.clone(),
209 tool: tool.clone(),
210 reason,
211 }),
212 Err(_) => Err(HookError::Timeout {
213 command: format!("mcp_tool:{server}/{tool}"),
214 timeout_secs: hook.timeout_secs,
215 }),
216 }
217 }
218 }
219}
220
221async fn fire_shell_hook<S: BuildHasher>(
222 command: &str,
223 timeout_secs: u64,
224 env: &HashMap<String, String, S>,
225) -> Result<(), HookError> {
226 let mut cmd = Command::new("sh");
227 cmd.arg("-c").arg(command);
228 cmd.env_clear();
230 if let Ok(path) = std::env::var("PATH") {
232 cmd.env("PATH", path);
233 }
234 for (k, v) in env {
235 cmd.env(k, v);
236 }
237 cmd.stdout(std::process::Stdio::null());
239 cmd.stderr(std::process::Stdio::null());
240
241 let mut child = cmd.spawn().map_err(|e| HookError::Io {
242 command: command.to_owned(),
243 source: e,
244 })?;
245
246 let result = timeout(Duration::from_secs(timeout_secs), child.wait()).await;
247
248 match result {
249 Ok(Ok(status)) if status.success() => Ok(()),
250 Ok(Ok(status)) => Err(HookError::NonZeroExit {
251 command: command.to_owned(),
252 code: status.code().unwrap_or(-1),
253 }),
254 Ok(Err(e)) => Err(HookError::Io {
255 command: command.to_owned(),
256 source: e,
257 }),
258 Err(_) => {
259 let _ = child.kill().await;
261 Err(HookError::Timeout {
262 command: command.to_owned(),
263 timeout_secs,
264 })
265 }
266 }
267}
268
269#[cfg(test)]
272mod tests {
273 use super::*;
274
275 fn cmd_hook(command: &str, fail_closed: bool, timeout_secs: u64) -> HookDef {
276 HookDef {
277 action: HookAction::Command {
278 command: command.to_owned(),
279 },
280 timeout_secs,
281 fail_closed,
282 }
283 }
284
285 fn make_matcher(matcher: &str, hooks: Vec<HookDef>) -> HookMatcher {
286 HookMatcher {
287 matcher: matcher.to_owned(),
288 hooks,
289 }
290 }
291
292 #[test]
295 fn matching_hooks_exact_name() {
296 let hook = cmd_hook("echo hi", false, 30);
297 let matchers = vec![make_matcher("Edit", vec![hook.clone()])];
298 let result = matching_hooks(&matchers, "Edit");
299 assert_eq!(result.len(), 1);
300 assert!(
301 matches!(&result[0].action, HookAction::Command { command } if command == "echo hi")
302 );
303 }
304
305 #[test]
306 fn matching_hooks_substring() {
307 let hook = cmd_hook("echo sub", false, 30);
308 let matchers = vec![make_matcher("Edit", vec![hook.clone()])];
309 let result = matching_hooks(&matchers, "EditFile");
310 assert_eq!(result.len(), 1);
311 }
312
313 #[test]
314 fn matching_hooks_pipe_separated() {
315 let h1 = cmd_hook("echo e", false, 30);
316 let h2 = cmd_hook("echo w", false, 30);
317 let matchers = vec![
318 make_matcher("Edit|Write", vec![h1.clone()]),
319 make_matcher("Shell", vec![h2.clone()]),
320 ];
321 let result_edit = matching_hooks(&matchers, "Edit");
322 assert_eq!(result_edit.len(), 1);
323
324 let result_shell = matching_hooks(&matchers, "Shell");
325 assert_eq!(result_shell.len(), 1);
326
327 let result_none = matching_hooks(&matchers, "Read");
328 assert!(result_none.is_empty());
329 }
330
331 #[test]
332 fn matching_hooks_no_match() {
333 let hook = cmd_hook("echo nope", false, 30);
334 let matchers = vec![make_matcher("Edit", vec![hook])];
335 let result = matching_hooks(&matchers, "Shell");
336 assert!(result.is_empty());
337 }
338
339 #[test]
340 fn matching_hooks_empty_token_ignored() {
341 let hook = cmd_hook("echo empty", false, 30);
342 let matchers = vec![make_matcher("|Edit|", vec![hook])];
343 let result = matching_hooks(&matchers, "Edit");
344 assert_eq!(result.len(), 1);
345 }
346
347 #[test]
348 fn matching_hooks_multiple_matchers_both_match() {
349 let h1 = cmd_hook("echo 1", false, 30);
350 let h2 = cmd_hook("echo 2", false, 30);
351 let matchers = vec![
352 make_matcher("Shell", vec![h1]),
353 make_matcher("Shell", vec![h2]),
354 ];
355 let result = matching_hooks(&matchers, "Shell");
356 assert_eq!(result.len(), 2);
357 }
358
359 #[tokio::test]
362 async fn fire_hooks_success() {
363 let hooks = vec![cmd_hook("true", false, 5)];
364 let env = HashMap::new();
365 assert!(fire_hooks(&hooks, &env, None).await.is_ok());
366 }
367
368 #[tokio::test]
369 async fn fire_hooks_fail_open_continues() {
370 let hooks = vec![
371 cmd_hook("false", false, 5), cmd_hook("true", false, 5), ];
374 let env = HashMap::new();
375 assert!(fire_hooks(&hooks, &env, None).await.is_ok());
376 }
377
378 #[tokio::test]
379 async fn fire_hooks_fail_closed_returns_err() {
380 let hooks = vec![cmd_hook("false", true, 5)];
381 let env = HashMap::new();
382 let result = fire_hooks(&hooks, &env, None).await;
383 assert!(result.is_err());
384 let err = result.unwrap_err();
385 assert!(matches!(err, HookError::NonZeroExit { .. }));
386 }
387
388 #[tokio::test]
389 async fn fire_hooks_timeout() {
390 let hooks = vec![cmd_hook("sleep 10", true, 1)];
391 let env = HashMap::new();
392 let result = fire_hooks(&hooks, &env, None).await;
393 assert!(result.is_err());
394 let err = result.unwrap_err();
395 assert!(matches!(err, HookError::Timeout { .. }));
396 }
397
398 #[tokio::test]
399 async fn fire_hooks_env_passed() {
400 let hooks = vec![cmd_hook(r#"test "$ZEPH_TEST_VAR" = "hello""#, true, 5)];
401 let mut env = HashMap::new();
402 env.insert("ZEPH_TEST_VAR".to_owned(), "hello".to_owned());
403 assert!(fire_hooks(&hooks, &env, None).await.is_ok());
404 }
405
406 #[tokio::test]
407 async fn fire_hooks_empty_list_ok() {
408 let env = HashMap::new();
409 assert!(fire_hooks(&[], &env, None).await.is_ok());
410 }
411
412 #[tokio::test]
413 async fn fire_hooks_mcp_unavailable_fail_open() {
414 let hooks = vec![HookDef {
415 action: HookAction::McpTool {
416 server: "srv".into(),
417 tool: "t".into(),
418 args: serde_json::Value::Null,
419 },
420 timeout_secs: 5,
421 fail_closed: false,
422 }];
423 let env = HashMap::new();
424 assert!(fire_hooks(&hooks, &env, None).await.is_ok());
426 }
427
428 #[tokio::test]
429 async fn fire_hooks_mcp_unavailable_fail_closed() {
430 let hooks = vec![HookDef {
431 action: HookAction::McpTool {
432 server: "srv".into(),
433 tool: "t".into(),
434 args: serde_json::Value::Null,
435 },
436 timeout_secs: 5,
437 fail_closed: true,
438 }];
439 let env = HashMap::new();
440 let result = fire_hooks(&hooks, &env, None).await;
441 assert!(matches!(result, Err(HookError::McpUnavailable { .. })));
442 }
443
444 #[test]
447 fn subagent_hooks_parses_from_yaml() {
448 let yaml = r#"
449PreToolUse:
450 - matcher: "Edit|Write"
451 hooks:
452 - type: command
453 command: "echo pre"
454 timeout_secs: 10
455 fail_closed: false
456PostToolUse:
457 - matcher: "Shell"
458 hooks:
459 - type: command
460 command: "echo post"
461"#;
462 let hooks: SubagentHooks = serde_norway::from_str(yaml).unwrap();
463 assert_eq!(hooks.pre_tool_use.len(), 1);
464 assert_eq!(hooks.pre_tool_use[0].matcher, "Edit|Write");
465 assert_eq!(hooks.pre_tool_use[0].hooks.len(), 1);
466 assert!(
467 matches!(&hooks.pre_tool_use[0].hooks[0].action, HookAction::Command { command } if command == "echo pre")
468 );
469 assert_eq!(hooks.post_tool_use.len(), 1);
470 }
471
472 #[test]
473 fn subagent_hooks_defaults_timeout() {
474 let yaml = r#"
475PreToolUse:
476 - matcher: "Edit"
477 hooks:
478 - type: command
479 command: "echo hi"
480"#;
481 let hooks: SubagentHooks = serde_norway::from_str(yaml).unwrap();
482 assert_eq!(hooks.pre_tool_use[0].hooks[0].timeout_secs, 30);
483 assert!(!hooks.pre_tool_use[0].hooks[0].fail_closed);
484 }
485
486 #[test]
487 fn subagent_hooks_empty_default() {
488 let hooks = SubagentHooks::default();
489 assert!(hooks.pre_tool_use.is_empty());
490 assert!(hooks.post_tool_use.is_empty());
491 }
492}