1use std::collections::HashMap;
77use std::hash::BuildHasher;
78use std::time::Duration;
79
80use serde::Serialize;
81use thiserror::Error;
82use tokio::io::AsyncWriteExt as _;
83use tokio::process::Command;
84use tokio::time::timeout;
85
86pub use zeph_config::{HookAction, HookDef, HookMatcher, SubagentHooks};
87
88#[derive(Debug, Default)]
95pub struct HookOutput {
96 pub updated_tool_output: Option<String>,
100}
101
102#[derive(Debug, Default)]
106pub struct HookRunResult {
107 pub output: HookOutput,
110}
111
112#[derive(Debug, Serialize)]
129pub struct PostToolUseHookInput<'a> {
130 pub tool_name: &'a str,
132 pub tool_args: &'a serde_json::Value,
134 #[serde(skip_serializing_if = "Option::is_none")]
136 pub session_id: Option<&'a str>,
137 pub duration_ms: u64,
139 #[serde(skip_serializing_if = "Option::is_none")]
141 pub tool_output: Option<&'a str>,
142 #[serde(skip_serializing_if = "Option::is_none")]
144 pub tool_error: Option<&'a str>,
145 #[serde(skip_serializing_if = "Option::is_none")]
148 pub agent_id: Option<&'a str>,
149 pub agent_type: &'a str,
151}
152
153const HOOK_STDOUT_CAP: usize = 1024 * 1024; pub trait McpDispatch: Send + Sync {
169 fn call_tool<'a>(
171 &'a self,
172 server: &'a str,
173 tool: &'a str,
174 args: serde_json::Value,
175 ) -> std::pin::Pin<
176 Box<dyn std::future::Future<Output = Result<serde_json::Value, String>> + Send + 'a>,
177 >;
178}
179
180#[must_use]
210pub fn hook_if_matches(condition: &str, tool_name: Option<&str>) -> bool {
211 let Some((key, value)) = condition.split_once(':') else {
212 tracing::warn!(
213 condition,
214 "hook `if` condition has no `:` separator — skipping hook (fail-closed)"
215 );
216 return false;
217 };
218
219 match key {
220 "tool" => {
221 if value.is_empty() {
222 tracing::warn!(
223 condition,
224 "hook `if` condition has empty token after `tool:` — skipping hook (fail-closed)"
225 );
226 return false;
227 }
228 tool_name.is_some_and(|t| !t.is_empty() && t.contains(value))
229 }
230 unknown => {
231 tracing::warn!(
232 key = unknown,
233 condition,
234 "hook `if` condition uses unknown key — skipping hook (fail-closed)"
235 );
236 false
237 }
238 }
239}
240
241#[non_exhaustive]
245#[derive(Debug, Error)]
246pub enum HookError {
247 #[error("hook command failed (exit code {code}): {command}")]
249 NonZeroExit { command: String, code: i32 },
250
251 #[error("hook command timed out after {timeout_secs}s: {command}")]
253 Timeout { command: String, timeout_secs: u64 },
254
255 #[error("hook I/O error for command '{command}': {source}")]
257 Io {
258 command: String,
259 #[source]
260 source: std::io::Error,
261 },
262
263 #[error(
265 "mcp_tool hook requires an MCP manager but none was provided (server={server}, tool={tool})"
266 )]
267 McpUnavailable { server: String, tool: String },
268
269 #[error("mcp_tool hook failed (server={server}, tool={tool}): {reason}")]
271 McpToolFailed {
272 server: String,
273 tool: String,
274 reason: String,
275 },
276}
277
278#[must_use]
299pub fn matching_hooks<'a>(matchers: &'a [HookMatcher], tool_name: &str) -> Vec<&'a HookDef> {
300 let mut result = Vec::new();
301 for m in matchers {
302 let matched = m
303 .matcher
304 .split('|')
305 .filter(|token| !token.is_empty())
306 .any(|token| tool_name.contains(token));
307 if matched {
308 result.extend(m.hooks.iter());
309 }
310 }
311 result
312}
313
314pub const TOOL_ARGS_JSON_LIMIT: usize = 64 * 1024;
320
321#[must_use]
340pub fn make_base_hook_env(
341 tool_name: &str,
342 tool_input: &serde_json::Value,
343) -> HashMap<String, String> {
344 let mut env = HashMap::new();
345 env.insert("ZEPH_TOOL_NAME".to_owned(), tool_name.to_owned());
346
347 let raw = serde_json::to_string(tool_input).unwrap_or_default();
348 let args_json = if raw.len() > TOOL_ARGS_JSON_LIMIT {
349 tracing::warn!(
350 tool = tool_name,
351 len = raw.len(),
352 limit = TOOL_ARGS_JSON_LIMIT,
353 "ZEPH_TOOL_ARGS_JSON truncated for hook dispatch"
354 );
355 let limit = raw.floor_char_boundary(TOOL_ARGS_JSON_LIMIT);
356 format!("{}…", &raw[..limit])
357 } else {
358 raw
359 };
360 env.insert("ZEPH_TOOL_ARGS_JSON".to_owned(), args_json);
361
362 env
363}
364
365#[tracing::instrument(name = "subagent.hooks.fire", skip_all, fields(hook_count = hooks.len()))]
392pub async fn fire_hooks<S: BuildHasher>(
393 hooks: &[HookDef],
394 env: &HashMap<String, String, S>,
395 mcp: Option<&dyn McpDispatch>,
396 stdin_json: Option<&[u8]>,
397) -> Result<HookRunResult, HookError> {
398 let tool_name = env.get("ZEPH_TOOL_NAME").map(String::as_str);
399 let mut run_result = HookRunResult::default();
400 for hook in hooks {
401 if hook.r#if.as_ref().is_some_and(|cond| {
403 let matches = hook_if_matches(cond, tool_name);
404 if !matches {
405 tracing::debug!(
406 condition = cond.as_str(),
407 "hook `if` condition did not match — skipping"
408 );
409 }
410 !matches
411 }) {
412 continue;
413 }
414
415 let effective_stdin = run_result
418 .output
419 .updated_tool_output
420 .as_deref()
421 .map(str::as_bytes)
422 .or(stdin_json);
423 let result = fire_single_hook(hook, env, mcp, effective_stdin).await;
424 match result {
425 Ok(hook_output) => {
426 if hook_output.updated_tool_output.is_some() {
427 run_result.output.updated_tool_output = hook_output.updated_tool_output;
428 }
429 }
430 Err(e) if hook.fail_closed => {
431 tracing::error!(
432 error = %e,
433 "fail-closed hook failed — aborting"
434 );
435 return Err(e);
436 }
437 Err(e) => {
438 tracing::warn!(
439 error = %e,
440 "hook failed (fail_open) — continuing"
441 );
442 }
443 }
444 }
445 Ok(run_result)
446}
447
448#[tracing::instrument(name = "subagent.hooks.single", skip_all)]
449async fn fire_single_hook<S: BuildHasher>(
450 hook: &HookDef,
451 env: &HashMap<String, String, S>,
452 mcp: Option<&dyn McpDispatch>,
453 stdin_json: Option<&[u8]>,
454) -> Result<HookOutput, HookError> {
455 match &hook.action {
456 HookAction::Command { command } => {
457 fire_shell_hook(command, hook.timeout_secs, env, stdin_json).await
458 }
459 HookAction::McpTool { server, tool, args } => {
460 let dispatcher = mcp.ok_or_else(|| HookError::McpUnavailable {
461 server: server.clone(),
462 tool: tool.clone(),
463 })?;
464 let call_fut = dispatcher.call_tool(server, tool, args.clone());
465 match timeout(Duration::from_secs(hook.timeout_secs), call_fut).await {
466 Ok(Ok(_)) => {
467 Ok(HookOutput::default())
469 }
470 Ok(Err(reason)) => Err(HookError::McpToolFailed {
471 server: server.clone(),
472 tool: tool.clone(),
473 reason,
474 }),
475 Err(_) => Err(HookError::Timeout {
476 command: format!("mcp_tool:{server}/{tool}"),
477 timeout_secs: hook.timeout_secs,
478 }),
479 }
480 }
481 _ => Ok(HookOutput::default()),
482 }
483}
484
485#[tracing::instrument(name = "subagent.hooks.shell", skip_all, fields(timeout_secs))]
486async fn fire_shell_hook<S: BuildHasher>(
487 command: &str,
488 timeout_secs: u64,
489 env: &HashMap<String, String, S>,
490 stdin_json: Option<&[u8]>,
491) -> Result<HookOutput, HookError> {
492 use std::process::Stdio;
493 use tokio::io::AsyncReadExt as _;
494
495 let mut cmd = Command::new("sh");
496 cmd.arg("-c").arg(command);
497 cmd.env_clear();
499 if let Ok(path) = std::env::var("PATH") {
501 cmd.env("PATH", path);
502 }
503 for (k, v) in env {
504 cmd.env(k, v);
505 }
506 cmd.stdin(if stdin_json.is_some() {
507 Stdio::piped()
508 } else {
509 Stdio::null()
510 });
511 cmd.stdout(Stdio::piped());
513 cmd.stderr(Stdio::null());
514
515 let mut child = cmd.spawn().map_err(|e| HookError::Io {
516 command: command.to_owned(),
517 source: e,
518 })?;
519
520 if let Some(bytes) = stdin_json
523 && let Some(mut stdin_handle) = child.stdin.take()
524 && let Err(e) = stdin_handle.write_all(bytes).await
525 {
526 tracing::warn!(
527 command,
528 error = %e,
529 "failed to write stdin to hook — continuing without stdin data"
530 );
531 }
532
533 let stdout_handle = child.stdout.take();
536 match timeout(Duration::from_secs(timeout_secs), child.wait()).await {
537 Ok(Ok(status)) => {
538 let mut stdout_bytes = Vec::new();
539 if let Some(handle) = stdout_handle {
540 let mut limited = handle.take(HOOK_STDOUT_CAP as u64 + 1);
541 let _ = limited.read_to_end(&mut stdout_bytes).await;
542 }
543 if status.success() {
544 Ok(parse_hook_stdout(command, &stdout_bytes))
545 } else {
546 Err(HookError::NonZeroExit {
547 command: command.to_owned(),
548 code: status.code().unwrap_or(-1),
549 })
550 }
551 }
552 Ok(Err(e)) => Err(HookError::Io {
553 command: command.to_owned(),
554 source: e,
555 }),
556 Err(_) => {
557 let _ = child.kill().await;
559 Err(HookError::Timeout {
560 command: command.to_owned(),
561 timeout_secs,
562 })
563 }
564 }
565}
566
567fn parse_hook_stdout(command: &str, bytes: &[u8]) -> HookOutput {
572 if bytes.is_empty() {
573 return HookOutput::default();
574 }
575 if bytes.len() > HOOK_STDOUT_CAP {
576 tracing::warn!(
577 command,
578 bytes = bytes.len(),
579 cap = HOOK_STDOUT_CAP,
580 "hook stdout exceeds 1 MiB cap — treating as no substitution"
581 );
582 return HookOutput::default();
583 }
584 let Ok(text) = std::str::from_utf8(bytes) else {
585 tracing::warn!(command, "hook stdout is not valid UTF-8 — no substitution");
586 return HookOutput::default();
587 };
588 let Ok(json) = serde_json::from_str::<serde_json::Value>(text) else {
590 return HookOutput::default();
591 };
592 let updated = json
593 .get("hookSpecificOutput")
594 .and_then(|h| h.get("updatedToolOutput"));
595
596 match updated {
597 None | Some(serde_json::Value::Null) => HookOutput::default(),
598 Some(serde_json::Value::String(s)) => HookOutput {
599 updated_tool_output: Some(s.clone()),
600 },
601 Some(other) => {
602 tracing::warn!(
603 command,
604 kind = other
605 .is_object()
606 .then_some("object")
607 .or_else(|| other.is_array().then_some("array"))
608 .or_else(|| other.is_number().then_some("number"))
609 .or_else(|| other.is_boolean().then_some("boolean"))
610 .unwrap_or("unknown"),
611 "hookSpecificOutput.updatedToolOutput has unexpected type — no substitution"
612 );
613 HookOutput::default()
614 }
615 }
616}
617
618#[cfg(test)]
621mod tests {
622 use super::*;
623 use std::assert_matches;
624
625 fn cmd_hook(command: &str, fail_closed: bool, timeout_secs: u64) -> HookDef {
626 HookDef {
627 action: HookAction::Command {
628 command: command.to_owned(),
629 },
630 timeout_secs,
631 fail_closed,
632 r#if: None,
633 }
634 }
635
636 fn make_matcher(matcher: &str, hooks: Vec<HookDef>) -> HookMatcher {
637 HookMatcher {
638 matcher: matcher.to_owned(),
639 hooks,
640 }
641 }
642
643 #[test]
646 fn matching_hooks_exact_name() {
647 let hook = cmd_hook("echo hi", false, 30);
648 let matchers = vec![make_matcher("Edit", vec![hook.clone()])];
649 let result = matching_hooks(&matchers, "Edit");
650 assert_eq!(result.len(), 1);
651 assert!(
652 matches!(&result[0].action, HookAction::Command { command } if command == "echo hi")
653 );
654 }
655
656 #[test]
657 fn matching_hooks_substring() {
658 let hook = cmd_hook("echo sub", false, 30);
659 let matchers = vec![make_matcher("Edit", vec![hook.clone()])];
660 let result = matching_hooks(&matchers, "EditFile");
661 assert_eq!(result.len(), 1);
662 }
663
664 #[test]
665 fn matching_hooks_pipe_separated() {
666 let h1 = cmd_hook("echo e", false, 30);
667 let h2 = cmd_hook("echo w", false, 30);
668 let matchers = vec![
669 make_matcher("Edit|Write", vec![h1.clone()]),
670 make_matcher("Shell", vec![h2.clone()]),
671 ];
672 let result_edit = matching_hooks(&matchers, "Edit");
673 assert_eq!(result_edit.len(), 1);
674
675 let result_shell = matching_hooks(&matchers, "Shell");
676 assert_eq!(result_shell.len(), 1);
677
678 let result_none = matching_hooks(&matchers, "Read");
679 assert!(result_none.is_empty());
680 }
681
682 #[test]
683 fn matching_hooks_no_match() {
684 let hook = cmd_hook("echo nope", false, 30);
685 let matchers = vec![make_matcher("Edit", vec![hook])];
686 let result = matching_hooks(&matchers, "Shell");
687 assert!(result.is_empty());
688 }
689
690 #[test]
691 fn matching_hooks_empty_token_ignored() {
692 let hook = cmd_hook("echo empty", false, 30);
693 let matchers = vec![make_matcher("|Edit|", vec![hook])];
694 let result = matching_hooks(&matchers, "Edit");
695 assert_eq!(result.len(), 1);
696 }
697
698 #[test]
699 fn matching_hooks_multiple_matchers_both_match() {
700 let h1 = cmd_hook("echo 1", false, 30);
701 let h2 = cmd_hook("echo 2", false, 30);
702 let matchers = vec![
703 make_matcher("Shell", vec![h1]),
704 make_matcher("Shell", vec![h2]),
705 ];
706 let result = matching_hooks(&matchers, "Shell");
707 assert_eq!(result.len(), 2);
708 }
709
710 #[tokio::test]
713 async fn fire_hooks_success() {
714 let hooks = vec![cmd_hook("true", false, 5)];
715 let env = HashMap::new();
716 assert!(fire_hooks(&hooks, &env, None, None).await.is_ok());
717 }
718
719 #[tokio::test]
720 async fn fire_hooks_fail_open_continues() {
721 let hooks = vec![
722 cmd_hook("false", false, 5), cmd_hook("true", false, 5), ];
725 let env = HashMap::new();
726 assert!(fire_hooks(&hooks, &env, None, None).await.is_ok());
727 }
728
729 #[tokio::test]
730 async fn fire_hooks_fail_closed_returns_err() {
731 let hooks = vec![cmd_hook("false", true, 5)];
732 let env = HashMap::new();
733 let result = fire_hooks(&hooks, &env, None, None).await;
734 assert!(result.is_err());
735 let err = result.unwrap_err();
736 assert_matches!(err, HookError::NonZeroExit { .. });
737 }
738
739 #[tokio::test]
740 async fn fire_hooks_timeout() {
741 let hooks = vec![cmd_hook("sleep 10", true, 1)];
742 let env = HashMap::new();
743 let result = fire_hooks(&hooks, &env, None, None).await;
744 assert!(result.is_err());
745 let err = result.unwrap_err();
746 assert_matches!(err, HookError::Timeout { .. });
747 }
748
749 #[tokio::test]
750 async fn fire_hooks_env_passed() {
751 let hooks = vec![cmd_hook(r#"test "$ZEPH_TEST_VAR" = "hello""#, true, 5)];
752 let mut env = HashMap::new();
753 env.insert("ZEPH_TEST_VAR".to_owned(), "hello".to_owned());
754 assert!(fire_hooks(&hooks, &env, None, None).await.is_ok());
755 }
756
757 #[tokio::test]
758 async fn fire_hooks_empty_list_ok() {
759 let env = HashMap::new();
760 assert!(fire_hooks(&[], &env, None, None).await.is_ok());
761 }
762
763 #[tokio::test]
764 async fn fire_hooks_mcp_unavailable_fail_open() {
765 let hooks = vec![HookDef {
766 action: HookAction::McpTool {
767 server: "srv".into(),
768 tool: "t".into(),
769 args: serde_json::Value::Null,
770 },
771 timeout_secs: 5,
772 fail_closed: false,
773 r#if: None,
774 }];
775 let env = HashMap::new();
776 assert!(fire_hooks(&hooks, &env, None, None).await.is_ok());
778 }
779
780 #[tokio::test]
781 async fn fire_hooks_mcp_unavailable_fail_closed() {
782 let hooks = vec![HookDef {
783 action: HookAction::McpTool {
784 server: "srv".into(),
785 tool: "t".into(),
786 args: serde_json::Value::Null,
787 },
788 timeout_secs: 5,
789 fail_closed: true,
790 r#if: None,
791 }];
792 let env = HashMap::new();
793 let result = fire_hooks(&hooks, &env, None, None).await;
794 assert_matches!(result, Err(HookError::McpUnavailable { .. }));
795 }
796
797 struct CountingDispatch(std::sync::Arc<std::sync::atomic::AtomicU32>);
801
802 impl McpDispatch for CountingDispatch {
803 fn call_tool<'a>(
804 &'a self,
805 _server: &'a str,
806 _tool: &'a str,
807 _args: serde_json::Value,
808 ) -> std::pin::Pin<
809 Box<dyn std::future::Future<Output = Result<serde_json::Value, String>> + Send + 'a>,
810 > {
811 self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
812 Box::pin(std::future::ready(Ok(serde_json::Value::Null)))
813 }
814 }
815
816 #[tokio::test]
817 async fn fire_hooks_mcp_dispatch_called_when_provided() {
818 let call_count = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
819 let dispatch = CountingDispatch(std::sync::Arc::clone(&call_count));
820
821 let hooks = vec![HookDef {
822 action: HookAction::McpTool {
823 server: "srv".into(),
824 tool: "t".into(),
825 args: serde_json::Value::Null,
826 },
827 timeout_secs: 5,
828 fail_closed: true,
829 r#if: None,
830 }];
831 let env = HashMap::new();
832 let result = fire_hooks(&hooks, &env, Some(&dispatch), None).await;
833 assert!(
834 result.is_ok(),
835 "fire_hooks should succeed with mcp dispatch"
836 );
837 assert_eq!(
838 call_count.load(std::sync::atomic::Ordering::SeqCst),
839 1,
840 "MCP dispatch should have been called exactly once"
841 );
842 }
843
844 #[tokio::test]
847 async fn fire_hooks_stdout_replacement_json() {
848 let cmd = r#"printf '{"hookSpecificOutput":{"updatedToolOutput":"replaced"}}'"#;
849 let hooks = vec![cmd_hook(cmd, true, 5)];
850 let env = HashMap::new();
851 let result = fire_hooks(&hooks, &env, None, None).await.unwrap();
852 assert_eq!(
853 result.output.updated_tool_output.as_deref(),
854 Some("replaced")
855 );
856 }
857
858 #[tokio::test]
859 async fn fire_hooks_stdout_empty_no_replacement() {
860 let hooks = vec![cmd_hook("true", true, 5)];
861 let env = HashMap::new();
862 let result = fire_hooks(&hooks, &env, None, None).await.unwrap();
863 assert!(result.output.updated_tool_output.is_none());
864 }
865
866 #[tokio::test]
867 async fn fire_hooks_stdout_non_json_no_replacement() {
868 let hooks = vec![cmd_hook("echo hello", true, 5)];
869 let env = HashMap::new();
870 let result = fire_hooks(&hooks, &env, None, None).await.unwrap();
871 assert!(result.output.updated_tool_output.is_none());
872 }
873
874 #[tokio::test]
875 async fn fire_hooks_stdout_null_updatedtooloutput_no_replacement() {
876 let cmd = r#"printf '{"hookSpecificOutput":{"updatedToolOutput":null}}'"#;
877 let hooks = vec![cmd_hook(cmd, true, 5)];
878 let env = HashMap::new();
879 let result = fire_hooks(&hooks, &env, None, None).await.unwrap();
880 assert!(result.output.updated_tool_output.is_none());
881 }
882
883 #[tokio::test]
884 async fn fire_hooks_stdin_passed_to_hook() {
885 let cmd = r#"python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if 'duration_ms' in d else 1)""#;
887 let hooks = vec![cmd_hook(cmd, true, 10)];
888 let env = HashMap::new();
889 let stdin = br#"{"tool_name":"Shell","tool_args":{},"duration_ms":42}"#;
890 let result = fire_hooks(&hooks, &env, None, Some(stdin)).await;
891 assert!(
892 result.is_ok(),
893 "hook should succeed when stdin has duration_ms"
894 );
895 }
896
897 #[tokio::test]
898 async fn fire_hooks_chaining_last_replacement_wins() {
899 let h1 = cmd_hook(
901 r#"printf '{"hookSpecificOutput":{"updatedToolOutput":"first"}}'"#,
902 false,
903 5,
904 );
905 let h2 = cmd_hook(
906 r#"printf '{"hookSpecificOutput":{"updatedToolOutput":"second"}}'"#,
907 false,
908 5,
909 );
910 let hooks = vec![h1, h2];
911 let env = HashMap::new();
912 let result = fire_hooks(&hooks, &env, None, None).await.unwrap();
913 assert_eq!(result.output.updated_tool_output.as_deref(), Some("second"));
914 }
915
916 #[test]
919 fn subagent_hooks_parses_from_yaml() {
920 let yaml = r#"
921PreToolUse:
922 - matcher: "Edit|Write"
923 hooks:
924 - type: command
925 command: "echo pre"
926 timeout_secs: 10
927 fail_closed: false
928PostToolUse:
929 - matcher: "Shell"
930 hooks:
931 - type: command
932 command: "echo post"
933"#;
934 let hooks: SubagentHooks = serde_norway::from_str(yaml).unwrap();
935 assert_eq!(hooks.pre_tool_use.len(), 1);
936 assert_eq!(hooks.pre_tool_use[0].matcher, "Edit|Write");
937 assert_eq!(hooks.pre_tool_use[0].hooks.len(), 1);
938 assert!(
939 matches!(&hooks.pre_tool_use[0].hooks[0].action, HookAction::Command { command } if command == "echo pre")
940 );
941 assert_eq!(hooks.post_tool_use.len(), 1);
942 }
943
944 #[test]
945 fn subagent_hooks_defaults_timeout() {
946 let yaml = r#"
947PreToolUse:
948 - matcher: "Edit"
949 hooks:
950 - type: command
951 command: "echo hi"
952"#;
953 let hooks: SubagentHooks = serde_norway::from_str(yaml).unwrap();
954 assert_eq!(hooks.pre_tool_use[0].hooks[0].timeout_secs, 30);
955 assert!(!hooks.pre_tool_use[0].hooks[0].fail_closed);
956 }
957
958 #[test]
959 fn subagent_hooks_empty_default() {
960 let hooks = SubagentHooks::default();
961 assert!(hooks.pre_tool_use.is_empty());
962 assert!(hooks.post_tool_use.is_empty());
963 }
964
965 #[tokio::test]
972 async fn fire_shell_hook_timeout_with_stdout_does_not_deadlock() {
973 let cmd = r#"echo "some output"; sleep 60"#;
976 let hooks = vec![cmd_hook(cmd, true, 1)];
977 let env = HashMap::new();
978
979 let result = tokio::time::timeout(
981 std::time::Duration::from_secs(5),
982 fire_hooks(&hooks, &env, None, None),
983 )
984 .await
985 .expect("fire_hooks must return within 5 s — deadlock regression #4011");
986
987 assert!(
988 matches!(result, Err(HookError::Timeout { .. })),
989 "expected HookError::Timeout, got: {result:?}"
990 );
991 }
992
993 #[test]
996 fn hook_if_matches_tool_positive() {
997 assert!(hook_if_matches("tool:shell", Some("shell")));
998 }
999
1000 #[test]
1001 fn hook_if_matches_tool_substring() {
1002 assert!(hook_if_matches("tool:shell", Some("subshell")));
1003 }
1004
1005 #[test]
1006 fn hook_if_matches_tool_negative() {
1007 assert!(!hook_if_matches("tool:shell", Some("python")));
1008 }
1009
1010 #[test]
1011 fn hook_if_matches_no_tool_name() {
1012 assert!(!hook_if_matches("tool:shell", None));
1013 }
1014
1015 #[test]
1016 fn hook_if_matches_empty_token_fail_closed() {
1017 assert!(!hook_if_matches("tool:", Some("shell")));
1019 }
1020
1021 #[test]
1022 fn hook_if_matches_empty_tool_name_fail_closed() {
1023 assert!(!hook_if_matches("tool:shell", Some("")));
1025 }
1026
1027 #[test]
1028 fn hook_if_matches_empty_token_with_empty_tool_fail_closed() {
1029 assert!(!hook_if_matches("tool:", Some("")));
1030 }
1031
1032 #[test]
1033 fn hook_if_matches_unknown_key_fail_closed() {
1034 assert!(!hook_if_matches("badkey:value", Some("x")));
1035 }
1036
1037 #[test]
1038 fn hook_if_matches_no_colon_fail_closed() {
1039 assert!(!hook_if_matches("no-colon", Some("x")));
1040 }
1041
1042 #[tokio::test]
1045 async fn fire_hooks_if_condition_matches_fires() {
1046 let hook = HookDef {
1048 action: HookAction::Command {
1049 command: "true".to_owned(),
1050 },
1051 timeout_secs: 5,
1052 fail_closed: true,
1053 r#if: Some("tool:shell".to_owned()),
1054 };
1055 let mut env = HashMap::new();
1056 env.insert("ZEPH_TOOL_NAME".to_owned(), "shell".to_owned());
1057 assert!(fire_hooks(&[hook], &env, None, None).await.is_ok());
1058 }
1059
1060 #[tokio::test]
1061 async fn fire_hooks_if_condition_does_not_match_skips() {
1062 let hook = HookDef {
1065 action: HookAction::Command {
1066 command: "exit 1".to_owned(),
1067 },
1068 timeout_secs: 5,
1069 fail_closed: true,
1070 r#if: Some("tool:shell".to_owned()),
1071 };
1072 let mut env = HashMap::new();
1073 env.insert("ZEPH_TOOL_NAME".to_owned(), "python".to_owned());
1074 assert!(fire_hooks(&[hook], &env, None, None).await.is_ok());
1076 }
1077
1078 #[tokio::test]
1079 async fn fire_hooks_no_if_always_fires() {
1080 let hook = cmd_hook("true", false, 5);
1081 let env = HashMap::new();
1082 assert!(fire_hooks(&[hook], &env, None, None).await.is_ok());
1083 }
1084
1085 #[test]
1088 fn post_tool_use_input_serializes_agent_fields() {
1089 let input = PostToolUseHookInput {
1090 tool_name: "Shell",
1091 tool_args: &serde_json::Value::Null,
1092 session_id: None,
1093 duration_ms: 42,
1094 tool_output: Some("out"),
1095 tool_error: None,
1096 agent_id: Some("conv-1"),
1097 agent_type: "main",
1098 };
1099 let json = serde_json::to_value(&input).unwrap();
1100 assert_eq!(json["agent_type"], "main");
1101 assert_eq!(json["agent_id"], "conv-1");
1102 }
1103
1104 #[test]
1105 fn post_tool_use_input_omits_agent_id_when_none() {
1106 let input = PostToolUseHookInput {
1107 tool_name: "Shell",
1108 tool_args: &serde_json::Value::Null,
1109 session_id: None,
1110 duration_ms: 42,
1111 tool_output: None,
1112 tool_error: None,
1113 agent_id: None,
1114 agent_type: "main",
1115 };
1116 let json = serde_json::to_value(&input).unwrap();
1117 assert_eq!(json["agent_type"], "main");
1118 assert!(json.get("agent_id").is_none() || json["agent_id"].is_null());
1119 let text = serde_json::to_string(&input).unwrap();
1120 assert!(
1121 !text.contains("agent_id"),
1122 "agent_id must not appear when None"
1123 );
1124 }
1125
1126 #[test]
1129 fn make_base_hook_env_sets_tool_name() {
1130 let env = make_base_hook_env("Edit", &serde_json::Value::Null);
1131 assert_eq!(env.get("ZEPH_TOOL_NAME").map(String::as_str), Some("Edit"));
1132 }
1133}