use std::collections::HashMap;
use std::hash::BuildHasher;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tokio::process::Command;
use tokio::time::timeout;
#[derive(Debug, Error)]
pub enum HookError {
#[error("hook command failed (exit code {code}): {command}")]
NonZeroExit { command: String, code: i32 },
#[error("hook command timed out after {timeout_secs}s: {command}")]
Timeout { command: String, timeout_secs: u64 },
#[error("hook I/O error for command '{command}': {source}")]
Io {
command: String,
#[source]
source: std::io::Error,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum HookType {
Command,
}
fn default_hook_timeout() -> u64 {
30
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookDef {
#[serde(rename = "type")]
pub hook_type: HookType,
pub command: String,
#[serde(default = "default_hook_timeout")]
pub timeout_secs: u64,
#[serde(default)]
pub fail_closed: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookMatcher {
pub matcher: String,
pub hooks: Vec<HookDef>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct SubagentHooks {
#[serde(default)]
pub pre_tool_use: Vec<HookMatcher>,
#[serde(default)]
pub post_tool_use: Vec<HookMatcher>,
}
#[must_use]
pub fn matching_hooks<'a>(matchers: &'a [HookMatcher], tool_name: &str) -> Vec<&'a HookDef> {
let mut result = Vec::new();
for m in matchers {
let matched = m
.matcher
.split('|')
.filter(|token| !token.is_empty())
.any(|token| tool_name.contains(token));
if matched {
result.extend(m.hooks.iter());
}
}
result
}
pub async fn fire_hooks<S: BuildHasher>(
hooks: &[HookDef],
env: &HashMap<String, String, S>,
) -> Result<(), HookError> {
for hook in hooks {
let result = fire_single_hook(hook, env).await;
match result {
Ok(()) => {}
Err(e) if hook.fail_closed => {
tracing::error!(
command = %hook.command,
error = %e,
"fail-closed hook failed — aborting"
);
return Err(e);
}
Err(e) => {
tracing::warn!(
command = %hook.command,
error = %e,
"hook failed (fail_open) — continuing"
);
}
}
}
Ok(())
}
async fn fire_single_hook<S: BuildHasher>(
hook: &HookDef,
env: &HashMap<String, String, S>,
) -> Result<(), HookError> {
let mut cmd = Command::new("sh");
cmd.arg("-c").arg(&hook.command);
cmd.env_clear();
if let Ok(path) = std::env::var("PATH") {
cmd.env("PATH", path);
}
for (k, v) in env {
cmd.env(k, v);
}
cmd.stdout(std::process::Stdio::null());
cmd.stderr(std::process::Stdio::null());
let mut child = cmd.spawn().map_err(|e| HookError::Io {
command: hook.command.clone(),
source: e,
})?;
let result = timeout(Duration::from_secs(hook.timeout_secs), child.wait()).await;
match result {
Ok(Ok(status)) if status.success() => Ok(()),
Ok(Ok(status)) => Err(HookError::NonZeroExit {
command: hook.command.clone(),
code: status.code().unwrap_or(-1),
}),
Ok(Err(e)) => Err(HookError::Io {
command: hook.command.clone(),
source: e,
}),
Err(_) => {
let _ = child.kill().await;
Err(HookError::Timeout {
command: hook.command.clone(),
timeout_secs: hook.timeout_secs,
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_hook(command: &str, fail_closed: bool, timeout_secs: u64) -> HookDef {
HookDef {
hook_type: HookType::Command,
command: command.to_owned(),
timeout_secs,
fail_closed,
}
}
fn make_matcher(matcher: &str, hooks: Vec<HookDef>) -> HookMatcher {
HookMatcher {
matcher: matcher.to_owned(),
hooks,
}
}
#[test]
fn matching_hooks_exact_name() {
let hook = make_hook("echo hi", false, 30);
let matchers = vec![make_matcher("Edit", vec![hook.clone()])];
let result = matching_hooks(&matchers, "Edit");
assert_eq!(result.len(), 1);
assert_eq!(result[0].command, "echo hi");
}
#[test]
fn matching_hooks_substring() {
let hook = make_hook("echo sub", false, 30);
let matchers = vec![make_matcher("Edit", vec![hook.clone()])];
let result = matching_hooks(&matchers, "EditFile");
assert_eq!(result.len(), 1);
}
#[test]
fn matching_hooks_pipe_separated() {
let h1 = make_hook("echo e", false, 30);
let h2 = make_hook("echo w", false, 30);
let matchers = vec![
make_matcher("Edit|Write", vec![h1.clone()]),
make_matcher("Shell", vec![h2.clone()]),
];
let result_edit = matching_hooks(&matchers, "Edit");
assert_eq!(result_edit.len(), 1);
assert_eq!(result_edit[0].command, "echo e");
let result_shell = matching_hooks(&matchers, "Shell");
assert_eq!(result_shell.len(), 1);
assert_eq!(result_shell[0].command, "echo w");
let result_none = matching_hooks(&matchers, "Read");
assert!(result_none.is_empty());
}
#[test]
fn matching_hooks_no_match() {
let hook = make_hook("echo nope", false, 30);
let matchers = vec![make_matcher("Edit", vec![hook])];
let result = matching_hooks(&matchers, "Shell");
assert!(result.is_empty());
}
#[test]
fn matching_hooks_empty_token_ignored() {
let hook = make_hook("echo empty", false, 30);
let matchers = vec![make_matcher("|Edit|", vec![hook])];
let result = matching_hooks(&matchers, "Edit");
assert_eq!(result.len(), 1);
}
#[test]
fn matching_hooks_multiple_matchers_both_match() {
let h1 = make_hook("echo 1", false, 30);
let h2 = make_hook("echo 2", false, 30);
let matchers = vec![
make_matcher("Shell", vec![h1]),
make_matcher("Shell", vec![h2]),
];
let result = matching_hooks(&matchers, "Shell");
assert_eq!(result.len(), 2);
}
#[tokio::test]
async fn fire_hooks_success() {
let hooks = vec![make_hook("true", false, 5)];
let env = HashMap::new();
assert!(fire_hooks(&hooks, &env).await.is_ok());
}
#[tokio::test]
async fn fire_hooks_fail_open_continues() {
let hooks = vec![
make_hook("false", false, 5), make_hook("true", false, 5), ];
let env = HashMap::new();
assert!(fire_hooks(&hooks, &env).await.is_ok());
}
#[tokio::test]
async fn fire_hooks_fail_closed_returns_err() {
let hooks = vec![make_hook("false", true, 5)];
let env = HashMap::new();
let result = fire_hooks(&hooks, &env).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, HookError::NonZeroExit { .. }));
}
#[tokio::test]
async fn fire_hooks_timeout() {
let hooks = vec![make_hook("sleep 10", true, 1)];
let env = HashMap::new();
let result = fire_hooks(&hooks, &env).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, HookError::Timeout { .. }));
}
#[tokio::test]
async fn fire_hooks_env_passed() {
let hooks = vec![make_hook(r#"test "$ZEPH_TEST_VAR" = "hello""#, true, 5)];
let mut env = HashMap::new();
env.insert("ZEPH_TEST_VAR".to_owned(), "hello".to_owned());
assert!(fire_hooks(&hooks, &env).await.is_ok());
}
#[tokio::test]
async fn fire_hooks_empty_list_ok() {
let env = HashMap::new();
assert!(fire_hooks(&[], &env).await.is_ok());
}
#[test]
fn subagent_hooks_parses_from_yaml() {
let yaml = r#"
PreToolUse:
- matcher: "Edit|Write"
hooks:
- type: command
command: "echo pre"
timeout_secs: 10
fail_closed: false
PostToolUse:
- matcher: "Shell"
hooks:
- type: command
command: "echo post"
"#;
let hooks: SubagentHooks = serde_norway::from_str(yaml).unwrap();
assert_eq!(hooks.pre_tool_use.len(), 1);
assert_eq!(hooks.pre_tool_use[0].matcher, "Edit|Write");
assert_eq!(hooks.pre_tool_use[0].hooks.len(), 1);
assert_eq!(hooks.pre_tool_use[0].hooks[0].command, "echo pre");
assert_eq!(hooks.post_tool_use.len(), 1);
}
#[test]
fn subagent_hooks_defaults_timeout() {
let yaml = r#"
PreToolUse:
- matcher: "Edit"
hooks:
- type: command
command: "echo hi"
"#;
let hooks: SubagentHooks = serde_norway::from_str(yaml).unwrap();
assert_eq!(hooks.pre_tool_use[0].hooks[0].timeout_secs, 30);
assert!(!hooks.pre_tool_use[0].hooks[0].fail_closed);
}
#[test]
fn subagent_hooks_empty_default() {
let hooks = SubagentHooks::default();
assert!(hooks.pre_tool_use.is_empty());
assert!(hooks.post_tool_use.is_empty());
}
}