1use std::collections::HashSet;
28use std::sync::{Arc, Mutex};
29
30use crate::error::{Error, Result};
31use crate::event::AgentEvent;
32use crate::hooks::config::{matches_hook, HookCommand, HookCommandType, HookFailMode, HooksConfig};
33use crate::llm::ChatProvider;
34use crate::message::Message;
35use serde::{Deserialize, Serialize};
36use std::path::PathBuf;
37use std::time::Duration;
38use tokio::process::Command;
39use tokio::sync::mpsc;
40use tokio::time::timeout;
41use tokio_util::sync::CancellationToken;
42
43pub use crate::hooks::HookAction;
44
45const HOOK_TIMEOUT: Duration = Duration::from_secs(5);
49
50fn default_hook_timeout() -> Duration {
51 std::env::var("RECURSIVE_HOOK_TIMEOUT_SECS")
52 .ok()
53 .and_then(|s| s.parse::<u64>().ok())
54 .map(Duration::from_secs)
55 .unwrap_or(HOOK_TIMEOUT)
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "camelCase")]
63pub enum HookEvent {
64 PreToolCall,
66 PostToolCall,
67 PermissionRequest,
68 PostToolCallFailure,
70 PermissionDenied,
71 SessionStart,
72 SessionEnd,
73 UserPromptSubmit,
74 Stop,
75 SubagentStart,
76 SubagentStop,
77 Notification,
78 Setup,
79}
80
81#[derive(Debug, Clone, Serialize)]
83#[serde(rename_all = "camelCase")]
84pub struct HookInput {
85 pub event: HookEvent,
86 #[serde(skip_serializing_if = "Option::is_none")]
88 pub tool_name: Option<String>,
89 #[serde(skip_serializing_if = "Option::is_none")]
91 pub args: Option<serde_json::Value>,
92 pub mode: String,
93 #[serde(skip_serializing_if = "Option::is_none")]
96 pub content: Option<String>,
97 #[serde(skip_serializing_if = "Option::is_none")]
99 pub message: Option<String>,
100 #[serde(skip_serializing_if = "Option::is_none")]
102 pub depth: Option<usize>,
103 #[serde(skip_serializing_if = "Option::is_none")]
105 pub reason: Option<String>,
106 #[serde(skip_serializing_if = "Option::is_none")]
108 pub error: Option<String>,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
113#[serde(rename_all = "camelCase")]
114enum JsonAction {
115 Continue,
116 Skip,
117 Error,
118}
119
120#[derive(Debug, Clone, Deserialize, Default)]
122#[serde(rename_all = "camelCase")]
123struct HookOutput {
124 #[serde(default)]
125 action: Option<JsonAction>,
126 #[serde(default)]
127 message: Option<String>,
128
129 #[serde(default)]
132 additional_context: Option<String>,
133 #[serde(default)]
135 updated_input: Option<serde_json::Value>,
136 #[serde(default)]
138 system_message: Option<String>,
139 #[serde(default)]
141 suppress_output: bool,
142 #[serde(default)]
144 permission_decision: Option<String>,
145 #[serde(default)]
147 permission_decision_reason: Option<String>,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq)]
152pub enum PermissionDecision {
153 Allow,
154 Deny,
155 Passthrough,
156}
157
158impl PermissionDecision {
159 fn from_str(s: &str) -> Option<Self> {
160 match s {
161 "allow" => Some(Self::Allow),
162 "deny" => Some(Self::Deny),
163 "passthrough" => Some(Self::Passthrough),
164 _ => None,
165 }
166 }
167}
168
169#[derive(Debug, Clone)]
172pub struct HookResult {
173 pub action: HookAction,
175 pub additional_context: Option<String>,
177 pub updated_input: Option<serde_json::Value>,
179 pub system_message: Option<String>,
181 pub suppress_output: bool,
183 pub permission_decision: Option<PermissionDecision>,
185 pub permission_decision_reason: Option<String>,
187}
188
189impl Default for HookResult {
190 fn default() -> Self {
191 Self {
192 action: HookAction::Continue,
193 additional_context: None,
194 updated_input: None,
195 system_message: None,
196 suppress_output: false,
197 permission_decision: None,
198 permission_decision_reason: None,
199 }
200 }
201}
202
203impl HookResult {
204 pub fn continue_default() -> Self {
206 Self::default()
207 }
208
209 pub fn from_fail_mode(mode: HookFailMode, reason: &str, event: &HookEvent) -> Self {
219 match mode {
220 HookFailMode::Open => Self::continue_default(),
221 HookFailMode::Closed => {
222 let action = match event {
223 HookEvent::PreToolCall
224 | HookEvent::UserPromptSubmit
225 | HookEvent::PermissionRequest => HookAction::Error(reason.to_string()),
226 _ => HookAction::Continue,
227 };
228 Self {
229 action,
230 ..Self::default()
231 }
232 }
233 }
234 }
235}
236
237impl HookOutput {
238 fn into_hook_result(self) -> HookResult {
240 let action = match self.action.unwrap_or(JsonAction::Continue) {
241 JsonAction::Continue => HookAction::Continue,
242 JsonAction::Skip => HookAction::Skip,
243 JsonAction::Error => HookAction::Error(
244 self.message
245 .clone()
246 .unwrap_or_else(|| "external hook blocked".to_string()),
247 ),
248 };
249 let permission_decision = self
250 .permission_decision
251 .as_deref()
252 .and_then(PermissionDecision::from_str);
253 HookResult {
254 action,
255 additional_context: self.additional_context,
256 updated_input: self.updated_input,
257 system_message: self.system_message,
258 suppress_output: self.suppress_output,
259 permission_decision,
260 permission_decision_reason: self.permission_decision_reason,
261 }
262 }
263}
264
265#[derive(Clone, Debug)]
269enum ResolvedHookKind {
270 Command(PathBuf, Vec<String>),
275 Http {
277 url: String,
278 headers: Option<std::collections::HashMap<String, String>>,
279 allowed_env_vars: Option<Vec<String>>,
280 },
281 Prompt {
283 prompt: String,
285 },
286}
287
288#[derive(Clone, Debug)]
290struct ResolvedHook {
291 kind: ResolvedHookKind,
293 timeout_secs: Option<u64>,
295 event_name: Option<String>,
297 matcher: Option<String>,
299 once: bool,
301 r#async: bool,
303 async_rewake: bool,
305 fail_mode: HookFailMode,
307}
308
309#[async_trait::async_trait]
323pub trait SdkHookForwarder: Send + Sync {
324 async fn forward(&self, input: &HookInput) -> Option<HookResult>;
327}
328
329#[derive(Clone)]
330pub struct ExternalHookRunner {
331 hooks: Vec<ResolvedHook>,
332 llm: Option<Arc<dyn ChatProvider>>,
334 executed_once: Arc<Mutex<HashSet<usize>>>,
336 #[cfg(test)]
340 mock_results: Vec<Option<HookResult>>,
341 #[cfg(test)]
344 mock_exit_codes: Vec<Option<i32>>,
345 pub cancel_token: Option<CancellationToken>,
346 event_tx: Option<mpsc::UnboundedSender<AgentEvent>>,
348 sdk_forwarder: Option<Arc<dyn SdkHookForwarder>>,
350}
351
352impl ExternalHookRunner {
353 pub fn discover(dirs: &[PathBuf]) -> Self {
358 let mut hooks = Vec::new();
359 for dir in dirs {
360 if let Ok(entries) = std::fs::read_dir(dir) {
361 for entry in entries.flatten() {
362 let path = entry.path();
363 if is_executable(&path) {
364 hooks.push(ResolvedHook {
365 kind: ResolvedHookKind::Command(path, vec![]),
366 timeout_secs: None,
367 event_name: None,
368 matcher: None,
369 once: false,
370 r#async: false,
371 async_rewake: false,
372 fail_mode: HookFailMode::Open,
373 });
374 }
375 }
376 }
377 }
378 #[cfg(not(test))]
379 return Self {
380 hooks,
381 llm: None,
382 executed_once: Arc::new(Mutex::new(HashSet::new())),
383 cancel_token: None,
384 event_tx: None,
385 sdk_forwarder: None,
386 };
387 #[cfg(test)]
388 Self {
389 hooks,
390 llm: None,
391 executed_once: Arc::new(Mutex::new(HashSet::new())),
392 cancel_token: None,
393 event_tx: None,
394 sdk_forwarder: None,
395 mock_results: vec![],
396 mock_exit_codes: vec![],
397 }
398 }
399
400 pub fn from_config(config: HooksConfig) -> Self {
405 Self::from_config_with_llm(config, None)
406 }
407
408 pub fn from_config_with_llm(config: HooksConfig, llm: Option<Arc<dyn ChatProvider>>) -> Self {
411 let mut hooks = Vec::new();
412 for (event_name, matchers) in config.events {
413 for matcher_entry in matchers {
414 for cmd in &matcher_entry.hooks {
415 if let Some(resolved) = Self::resolve_command(
416 cmd,
417 Some(event_name.clone()),
418 matcher_entry.matcher.clone(),
419 ) {
420 hooks.push(resolved);
421 }
422 }
423 }
424 }
425 #[cfg(not(test))]
426 return Self {
427 hooks,
428 llm,
429 executed_once: Arc::new(Mutex::new(HashSet::new())),
430 cancel_token: None,
431 event_tx: None,
432 sdk_forwarder: None,
433 };
434 #[cfg(test)]
435 Self {
436 hooks,
437 llm,
438 executed_once: Arc::new(Mutex::new(HashSet::new())),
439 cancel_token: None,
440 event_tx: None,
441 sdk_forwarder: None,
442 mock_results: vec![],
443 mock_exit_codes: vec![],
444 }
445 }
446
447 pub fn with_cancel_token(mut self, token: CancellationToken) -> Self {
449 self.cancel_token = Some(token);
450 self
451 }
452
453 pub fn with_event_tx(mut self, tx: mpsc::UnboundedSender<AgentEvent>) -> Self {
455 self.event_tx = Some(tx);
456 self
457 }
458
459 pub fn with_sdk_forwarder(mut self, forwarder: Arc<dyn SdkHookForwarder>) -> Self {
461 self.sdk_forwarder = Some(forwarder);
462 self
463 }
464
465 pub fn set_sdk_forwarder(&mut self, forwarder: Option<Arc<dyn SdkHookForwarder>>) {
467 self.sdk_forwarder = forwarder;
468 }
469
470 #[cfg(test)]
480 fn with_mock_results(mut self, results: Vec<Option<HookResult>>) -> Self {
481 self.mock_results = results;
482 self
483 }
484
485 #[cfg(test)]
489 fn with_mock_exit_codes(mut self, codes: Vec<Option<i32>>) -> Self {
490 self.mock_exit_codes = codes;
491 self
492 }
493
494 fn emit(&self, event: AgentEvent) {
496 if let Some(tx) = &self.event_tx {
497 let _ = tx.send(event);
498 }
499 }
500
501 fn resolve_command(
505 cmd: &HookCommand,
506 event_name: Option<String>,
507 matcher: Option<String>,
508 ) -> Option<ResolvedHook> {
509 let base = |kind: ResolvedHookKind| ResolvedHook {
510 kind,
511 timeout_secs: Some(cmd.timeout),
512 event_name,
513 matcher,
514 once: cmd.once,
515 r#async: cmd.r#async,
516 async_rewake: cmd.async_rewake,
517 fail_mode: cmd.fail_mode,
518 };
519
520 match cmd.r#type {
521 HookCommandType::Command => {
522 let command_str = cmd.command.as_deref()?;
523 let mut parts = shell_words::split(command_str)
527 .unwrap_or_else(|_| vec![command_str.to_string()]);
528 if parts.is_empty() {
529 return None;
530 }
531 let path = expand_tilde(&parts.remove(0));
532 Some(base(ResolvedHookKind::Command(path, parts)))
533 }
534 HookCommandType::Http => {
535 let url = cmd.url.clone()?;
536 Some(base(ResolvedHookKind::Http {
537 url,
538 headers: None,
539 allowed_env_vars: None,
540 }))
541 }
542 HookCommandType::Prompt | HookCommandType::Agent => {
543 let prompt = cmd.prompt.clone()?;
544 Some(base(ResolvedHookKind::Prompt { prompt }))
545 }
546 }
547 }
548
549 pub fn len(&self) -> usize {
551 self.hooks.len()
552 }
553
554 pub fn is_empty(&self) -> bool {
556 self.hooks.is_empty()
557 }
558
559 pub async fn dispatch(&self, input: &HookInput) -> HookResult {
564 if let Some(fwd) = &self.sdk_forwarder {
566 if let Some(result) = fwd.forward(input).await {
567 return result;
568 }
569 }
570 let event_str = serde_json::to_string(&input.event)
571 .unwrap_or_default()
572 .trim_matches('"')
573 .to_string();
574 let tool_name = input.tool_name.as_deref().unwrap_or("");
575 let empty_args = serde_json::Value::Object(Default::default());
576 let args = input.args.as_ref().unwrap_or(&empty_args);
577
578 for (idx, hook) in self.hooks.iter().enumerate() {
579 if let Some(ref ev) = hook.event_name {
581 if !event_names_match(ev, &event_str) {
582 continue;
583 }
584 }
585 if !matches_hook(&hook.matcher, tool_name, args) {
587 continue;
588 }
589 if hook.once {
591 let already_ran = {
592 let guard = self.executed_once.lock().unwrap_or_else(|e| e.into_inner());
593 guard.contains(&idx)
594 };
595 if already_ran {
596 continue;
597 }
598 }
599
600 if hook.r#async && !hook.async_rewake {
602 let hook_clone = hook.clone();
603 let input_clone = input.clone();
604 let self_clone = self.clone();
605 tokio::spawn(async move {
606 if let Err(e) = self_clone.run_hook(&hook_clone, idx, &input_clone).await {
607 tracing::warn!("async hook error: {e}");
608 }
609 });
610 if hook.once {
611 self.executed_once
612 .lock()
613 .unwrap_or_else(|e| e.into_inner())
614 .insert(idx);
615 }
616 continue;
617 }
618
619 if hook.async_rewake {
621 let hook_clone = hook.clone();
622 let input_clone = input.clone();
623 let self_clone = self.clone();
624 let cancel = self.cancel_token.clone();
625 tokio::spawn(async move {
626 let exit_code = self_clone
627 .run_command_exit_code(&hook_clone, idx, &input_clone)
628 .await
629 .unwrap_or(None);
630 if exit_code == Some(2) {
631 tracing::warn!("asyncRewake hook exited with code 2 — cancelling agent");
632 if let Some(token) = cancel {
633 token.cancel();
634 }
635 }
636 });
637 if hook.once {
638 self.executed_once
639 .lock()
640 .unwrap_or_else(|e| e.into_inner())
641 .insert(idx);
642 }
643 continue;
644 }
645
646 let hook_display_name = match &hook.kind {
648 ResolvedHookKind::Command(path, _) => path
649 .file_name()
650 .map(|n| n.to_string_lossy().into_owned())
651 .unwrap_or_else(|| "hook".to_string()),
652 ResolvedHookKind::Http { url, .. } => url.clone(),
653 ResolvedHookKind::Prompt { .. } => "prompt-hook".to_string(),
654 };
655 self.emit(AgentEvent::HookStarted {
656 hook_event: event_str.clone(),
657 hook_name: hook_display_name.clone(),
658 status_message: None,
659 });
660 let start = std::time::Instant::now();
661 if let Ok(result) = self.run_hook(hook, idx, input).await {
662 if hook.once {
663 self.executed_once
664 .lock()
665 .unwrap_or_else(|e| e.into_inner())
666 .insert(idx);
667 }
668 let duration_ms = start.elapsed().as_millis() as u64;
669 let outcome = match &result.action {
670 HookAction::Continue => "continue".to_string(),
671 HookAction::Skip => "skip".to_string(),
672 HookAction::Error(reason) => format!("error: {reason}"),
673 };
674 if let Some(msg) = &result.system_message {
675 self.emit(AgentEvent::HookSystemMessage { text: msg.clone() });
676 }
677 self.emit(AgentEvent::HookFinished {
678 hook_event: event_str.clone(),
679 hook_name: hook_display_name.clone(),
680 outcome,
681 duration_ms,
682 });
683 if !matches!(result.action, HookAction::Continue) {
684 return result;
685 }
686 }
687 }
688 HookResult::continue_default()
689 }
690
691 async fn run_hook(
693 &self,
694 hook: &ResolvedHook,
695 #[cfg_attr(not(test), allow(unused_variables))] hook_idx: usize,
696 input: &HookInput,
697 ) -> Result<HookResult> {
698 #[cfg(test)]
700 if let Some(Some(result)) = self.mock_results.get(hook_idx) {
701 return Ok(result.clone());
702 }
703
704 let hook_timeout = hook
705 .timeout_secs
706 .map(Duration::from_secs)
707 .unwrap_or_else(default_hook_timeout);
708
709 match &hook.kind {
710 ResolvedHookKind::Http {
711 url,
712 headers,
713 allowed_env_vars,
714 } => {
715 let result = run_http_hook(
716 url,
717 input,
718 hook_timeout.as_secs(),
719 headers.as_ref(),
720 allowed_env_vars.as_deref(),
721 hook.fail_mode,
722 )
723 .await;
724 Ok(result)
725 }
726 ResolvedHookKind::Prompt { prompt } => {
727 if let Some(llm) = &self.llm {
728 let result =
729 run_prompt_hook(llm.as_ref(), prompt, input, hook_timeout, hook.fail_mode)
730 .await;
731 Ok(result)
732 } else {
733 tracing::warn!(
735 "prompt hook configured but no LLM provider available; skipping"
736 );
737 Ok(HookResult::continue_default())
738 }
739 }
740 ResolvedHookKind::Command(path, args) => {
741 self.run_command_hook(path, args, input, hook_timeout, hook.fail_mode)
742 .await
743 }
744 }
745 }
746
747 async fn run_command_exit_code(
749 &self,
750 hook: &ResolvedHook,
751 #[cfg_attr(not(test), allow(unused_variables))] hook_idx: usize,
752 input: &HookInput,
753 ) -> Result<Option<i32>> {
754 #[cfg(test)]
756 if let Some(maybe_code) = self.mock_exit_codes.get(hook_idx) {
757 return Ok(*maybe_code);
758 }
759
760 let ResolvedHookKind::Command(path, args) = &hook.kind else {
761 return Ok(None);
762 };
763 let hook_timeout = hook
764 .timeout_secs
765 .map(Duration::from_secs)
766 .unwrap_or_else(default_hook_timeout);
767 let input_json = serde_json::to_string(input).map_err(|e| Error::Config {
768 message: format!("hook input serialize: {e}"),
769 })?;
770 let mut child = Command::new(path)
771 .args(args)
772 .stdin(std::process::Stdio::piped())
773 .stdout(std::process::Stdio::null())
774 .stderr(std::process::Stdio::null())
775 .spawn()
776 .map_err(|e| Error::Config {
777 message: format!("hook spawn {}: {e}", path.display()),
778 })?;
779 let status = timeout(hook_timeout, async {
780 use tokio::io::AsyncWriteExt;
781 if let Some(stdin) = child.stdin.as_mut() {
782 let _ = stdin.write_all(input_json.as_bytes()).await;
783 let _ = stdin.shutdown().await;
784 }
785 child.wait().await
786 })
787 .await;
788 match status {
789 Ok(Ok(s)) => Ok(s.code()),
790 _ => Ok(None),
791 }
792 }
793
794 async fn run_command_hook(
796 &self,
797 path: &PathBuf,
798 args: &[String],
799 input: &HookInput,
800 hook_timeout: Duration,
801 fail_mode: HookFailMode,
802 ) -> Result<HookResult> {
803 let input_json = match serde_json::to_string(input) {
804 Ok(j) => j,
805 Err(e) => {
806 return Ok(HookResult::from_fail_mode(
807 fail_mode,
808 &format!("hook input serialize: {e}"),
809 &input.event,
810 ));
811 }
812 };
813
814 let mut child = match Command::new(path)
815 .args(args)
816 .stdin(std::process::Stdio::piped())
817 .stdout(std::process::Stdio::piped())
818 .stderr(std::process::Stdio::null())
819 .spawn()
820 {
821 Ok(c) => c,
822 Err(e) => {
823 return Ok(HookResult::from_fail_mode(
824 fail_mode,
825 &format!("hook spawn {}: {e}", path.display()),
826 &input.event,
827 ));
828 }
829 };
830
831 let output = timeout(hook_timeout, async {
833 use tokio::io::AsyncWriteExt;
834 if let Some(stdin) = child.stdin.as_mut() {
835 let _ = stdin.write_all(input_json.as_bytes()).await;
836 let _ = stdin.shutdown().await;
838 }
839 child.wait_with_output().await
840 })
841 .await;
842
843 let output = match output {
844 Err(_elapsed) => {
845 return Ok(HookResult::from_fail_mode(
846 fail_mode,
847 "hook timed out",
848 &input.event,
849 ));
850 }
851 Ok(Err(e)) => {
852 return Ok(HookResult::from_fail_mode(
853 fail_mode,
854 &format!("hook wait {}: {e}", path.display()),
855 &input.event,
856 ));
857 }
858 Ok(Ok(o)) => o,
859 };
860
861 if !output.status.success() {
862 return Ok(HookResult::from_fail_mode(
863 fail_mode,
864 &format!("hook exited with code {:?}", output.status.code()),
865 &input.event,
866 ));
867 }
868
869 let stdout = String::from_utf8_lossy(&output.stdout);
870 match serde_json::from_str::<HookOutput>(stdout.trim()) {
871 Ok(parsed) => Ok(parsed.into_hook_result()),
872 Err(e) => Ok(HookResult::from_fail_mode(
873 fail_mode,
874 &format!("hook output parse {}: {e}", path.display()),
875 &input.event,
876 )),
877 }
878 }
879}
880
881fn expand_tilde(path: &str) -> PathBuf {
883 if let Some(rest) = path.strip_prefix("~/") {
884 if let Some(home) = std::env::var_os("HOME") {
885 return PathBuf::from(home).join(rest);
886 }
887 }
888 PathBuf::from(path)
889}
890
891fn event_names_match(config_name: &str, wire_name: &str) -> bool {
893 config_name.to_lowercase() == wire_name.to_lowercase()
894}
895
896async fn run_prompt_hook(
901 llm: &dyn ChatProvider,
902 prompt_template: &str,
903 input: &HookInput,
904 hook_timeout: Duration,
905 fail_mode: HookFailMode,
906) -> HookResult {
907 let args_json = serde_json::to_string(input).unwrap_or_else(|_| "{}".to_string());
908 let prompt = prompt_template.replace("$ARGUMENTS", &args_json);
909
910 let messages = [Message::user(prompt)];
911 let completion_future = llm.complete(&messages, &[]);
912 let completion = match timeout(hook_timeout, completion_future).await {
913 Ok(Ok(c)) => c,
914 Ok(Err(e)) => {
915 tracing::warn!("prompt hook LLM error: {e}");
916 return HookResult::from_fail_mode(
917 fail_mode,
918 &format!("prompt hook LLM error: {e}"),
919 &input.event,
920 );
921 }
922 Err(_) => {
923 tracing::warn!("prompt hook timeout");
924 return HookResult::from_fail_mode(fail_mode, "prompt hook timeout", &input.event);
925 }
926 };
927
928 match serde_json::from_str::<HookOutput>(completion.content.trim()) {
929 Ok(output) => output.into_hook_result(),
930 Err(_) => {
931 HookResult::from_fail_mode(fail_mode, "prompt hook returned non-JSON", &input.event)
933 }
934 }
935}
936
937pub(crate) async fn run_http_hook(
944 url: &str,
945 input: &HookInput,
946 timeout_secs: u64,
947 headers: Option<&std::collections::HashMap<String, String>>,
948 allowed_env_vars: Option<&[String]>,
949 fail_mode: HookFailMode,
950) -> HookResult {
951 let client_result = reqwest::Client::builder()
952 .timeout(Duration::from_secs(timeout_secs))
953 .connect_timeout(Duration::from_secs(timeout_secs))
954 .build();
955
956 let client = match client_result {
957 Ok(c) => c,
958 Err(_) => {
959 return HookResult::from_fail_mode(
960 fail_mode,
961 "http hook client build failed",
962 &input.event,
963 );
964 }
965 };
966
967 let mut builder = client.post(url).json(input);
968
969 if let Some(headers_map) = headers {
970 for (k, v) in headers_map {
971 let interpolated = interpolate_env_vars(v, allowed_env_vars);
972 builder = builder.header(k.as_str(), interpolated);
973 }
974 }
975
976 let response = match builder.send().await {
977 Ok(r) => r,
978 Err(e) => {
979 tracing::warn!("http hook request failed: {e}");
980 return HookResult::from_fail_mode(
981 fail_mode,
982 &format!("http hook request failed: {e}"),
983 &input.event,
984 );
985 }
986 };
987
988 let body = match response.text().await {
989 Ok(b) => b,
990 Err(e) => {
991 tracing::warn!("http hook response read failed: {e}");
992 return HookResult::from_fail_mode(
993 fail_mode,
994 &format!("http hook response read failed: {e}"),
995 &input.event,
996 );
997 }
998 };
999
1000 match serde_json::from_str::<HookOutput>(body.trim()) {
1001 Ok(output) => output.into_hook_result(),
1002 Err(e) => {
1003 tracing::warn!("http hook response parse failed: {e}");
1004 HookResult::from_fail_mode(
1005 fail_mode,
1006 &format!("http hook response parse failed: {e}"),
1007 &input.event,
1008 )
1009 }
1010 }
1011}
1012
1013fn interpolate_env_vars(value: &str, allowed: Option<&[String]>) -> String {
1017 let mut result = value.to_string();
1018
1019 for (pattern, var_name) in find_env_var_refs(&result) {
1021 let replacement = if is_allowed(&var_name, allowed) {
1022 std::env::var(&var_name).unwrap_or_default()
1023 } else {
1024 String::new()
1025 };
1026 result = result.replacen(&pattern, &replacement, 1);
1027 }
1028 result
1029}
1030
1031fn find_env_var_refs(s: &str) -> Vec<(String, String)> {
1033 let mut refs = Vec::new();
1034 let bytes = s.as_bytes();
1035 let mut i = 0;
1036 while i < bytes.len() {
1037 if bytes[i] == b'$' {
1038 if i + 1 < bytes.len() && bytes[i + 1] == b'{' {
1039 if let Some(end) = s[i + 2..].find('}') {
1041 let var_name = &s[i + 2..i + 2 + end];
1042 refs.push((format!("${{{var_name}}}"), var_name.to_string()));
1043 i += 3 + end;
1044 continue;
1045 }
1046 } else {
1047 let start = i + 1;
1049 let mut end = start;
1050 while end < bytes.len()
1051 && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_')
1052 {
1053 end += 1;
1054 }
1055 if end > start {
1056 let var_name = &s[start..end];
1057 refs.push((format!("${var_name}"), var_name.to_string()));
1058 i = end;
1059 continue;
1060 }
1061 }
1062 }
1063 i += 1;
1064 }
1065 refs
1066}
1067
1068fn is_allowed(var: &str, allowed: Option<&[String]>) -> bool {
1069 let Some(list) = allowed else { return true };
1070 list.iter().any(|a| a == var)
1071}
1072
1073fn is_executable(path: &std::path::Path) -> bool {
1081 #[cfg(unix)]
1082 {
1083 use std::os::unix::fs::PermissionsExt;
1084 path.is_file()
1085 && std::fs::metadata(path)
1086 .map(|m| m.permissions().mode() & 0o111 != 0)
1087 .unwrap_or(false)
1088 }
1089 #[cfg(not(unix))]
1090 {
1091 let _ = path;
1093 false
1094 }
1095}
1096
1097#[cfg(test)]
1100mod tests {
1101 use super::*;
1102
1103 fn make_tool_input(event: HookEvent, tool: &str, args: serde_json::Value) -> HookInput {
1105 HookInput {
1106 event,
1107 tool_name: Some(tool.to_string()),
1108 args: Some(args),
1109 mode: "ask".to_string(),
1110 content: None,
1111 message: None,
1112 depth: None,
1113 reason: None,
1114 error: None,
1115 }
1116 }
1117
1118 #[test]
1121 fn hook_output_parse_continue() {
1122 let json = r#"{"action":"continue"}"#;
1123 let out: HookOutput = serde_json::from_str(json).unwrap();
1124 assert!(matches!(out.action, Some(JsonAction::Continue)));
1125 assert!(out.message.is_none());
1126 }
1127
1128 #[test]
1129 fn hook_output_parse_skip() {
1130 let json = r#"{"action":"skip","message":"blocked"}"#;
1131 let out: HookOutput = serde_json::from_str(json).unwrap();
1132 assert!(matches!(out.action, Some(JsonAction::Skip)));
1133 assert_eq!(out.message.as_deref(), Some("blocked"));
1134 }
1135
1136 #[test]
1137 fn hook_output_parse_error() {
1138 let json = r#"{"action":"error","message":"not allowed"}"#;
1139 let out: HookOutput = serde_json::from_str(json).unwrap();
1140 assert!(matches!(out.action, Some(JsonAction::Error)));
1141 assert_eq!(out.message.as_deref(), Some("not allowed"));
1142 }
1143
1144 #[test]
1145 fn hook_output_parse_camel_case() {
1146 let json = r#"{"action":"continue"}"#;
1147 let out: HookOutput = serde_json::from_str(json).unwrap();
1148 assert!(matches!(out.action, Some(JsonAction::Continue)));
1149
1150 let json = r#"{"action":"skip","message":"nope"}"#;
1151 let out: HookOutput = serde_json::from_str(json).unwrap();
1152 assert!(matches!(out.action, Some(JsonAction::Skip)));
1153 }
1154
1155 #[test]
1156 fn hook_output_parse_missing_message() {
1157 let json = r#"{"action":"error"}"#;
1159 let out: HookOutput = serde_json::from_str(json).unwrap();
1160 assert!(matches!(out.action, Some(JsonAction::Error)));
1161 assert!(out.message.is_none());
1162 }
1163
1164 #[test]
1165 fn hook_output_into_hook_action_continue() {
1166 let json = r#"{"action":"continue"}"#;
1167 let out: HookOutput = serde_json::from_str(json).unwrap();
1168 let result = out.into_hook_result();
1169 assert!(matches!(result.action, HookAction::Continue));
1170 }
1171
1172 #[test]
1173 fn hook_output_into_hook_action_skip() {
1174 let json = r#"{"action":"skip","message":"blocked"}"#;
1175 let out: HookOutput = serde_json::from_str(json).unwrap();
1176 let result = out.into_hook_result();
1177 assert!(matches!(result.action, HookAction::Skip));
1178 }
1179
1180 #[test]
1181 fn hook_output_into_hook_action_error_with_message() {
1182 let json = r#"{"action":"error","message":"not allowed"}"#;
1183 let out: HookOutput = serde_json::from_str(json).unwrap();
1184 let result = out.into_hook_result();
1185 assert!(matches!(result.action, HookAction::Error(ref msg) if msg == "not allowed"));
1186 }
1187
1188 #[test]
1189 fn hook_output_into_hook_action_error_without_message() {
1190 let json = r#"{"action":"error"}"#;
1191 let out: HookOutput = serde_json::from_str(json).unwrap();
1192 let result = out.into_hook_result();
1193 assert!(
1194 matches!(result.action, HookAction::Error(ref msg) if msg == "external hook blocked")
1195 );
1196 }
1197
1198 #[test]
1201 fn empty_runner_returns_continue() {
1202 let runner = ExternalHookRunner::discover(&[]);
1203 assert!(runner.is_empty());
1204 assert_eq!(runner.len(), 0);
1205 }
1206
1207 #[test]
1208 fn discover_skips_non_executable() {
1209 let tmp = tempfile::tempdir().unwrap();
1211 let non_exec = tmp.path().join("script.sh");
1212 std::fs::write(&non_exec, "echo hello").unwrap();
1213 #[cfg(unix)]
1215 {
1216 use std::os::unix::fs::PermissionsExt;
1217 let mut perms = std::fs::metadata(&non_exec).unwrap().permissions();
1218 perms.set_mode(0o644);
1219 std::fs::set_permissions(&non_exec, perms).unwrap();
1220 }
1221 let runner = ExternalHookRunner::discover(&[tmp.path().to_path_buf()]);
1222 assert!(runner.is_empty());
1223 }
1224
1225 #[test]
1226 #[cfg(unix)]
1227 fn discover_collects_executable() {
1228 use std::os::unix::fs::PermissionsExt;
1229 let tmp = tempfile::tempdir().unwrap();
1230 let exec = tmp.path().join("hook.sh");
1231 std::fs::write(&exec, "#!/bin/sh\necho '{\"action\":\"continue\"}'").unwrap();
1232 let mut perms = std::fs::metadata(&exec).unwrap().permissions();
1233 perms.set_mode(0o755);
1234 std::fs::set_permissions(&exec, perms).unwrap();
1235 let runner = ExternalHookRunner::discover(&[tmp.path().to_path_buf()]);
1236 assert_eq!(runner.len(), 1);
1237 }
1238
1239 #[test]
1240 fn is_executable_rejects_directory() {
1241 let tmp = tempfile::tempdir().unwrap();
1242 let dir = tmp.path().join("subdir");
1243 std::fs::create_dir(&dir).unwrap();
1244 assert!(!is_executable(&dir));
1246 }
1247
1248 #[test]
1249 fn is_executable_rejects_nonexistent() {
1250 assert!(!is_executable(std::path::Path::new(
1251 "/nonexistent/path/script"
1252 )));
1253 }
1254
1255 #[tokio::test]
1264 async fn dispatch_runs_executable_hook_and_returns_decision() {
1265 use crate::hooks::config::{HookCommand, HookCommandType, HookMatcher, HooksConfig};
1266 let cfg = HooksConfig {
1270 events: std::collections::HashMap::from([(
1271 "PreToolCall".to_string(),
1272 vec![HookMatcher {
1273 matcher: None,
1274 hooks: vec![HookCommand {
1275 r#type: HookCommandType::Command,
1276 command: Some("/bin/true".to_string()), timeout: 5,
1278 ..Default::default()
1279 }],
1280 }],
1281 )]),
1282 };
1283 let mock_skip = HookResult {
1284 action: HookAction::Skip,
1285 system_message: Some("blocked by test hook".to_string()),
1286 ..Default::default()
1287 };
1288 let runner = ExternalHookRunner::from_config(cfg).with_mock_results(vec![Some(mock_skip)]);
1289 let input = make_tool_input(
1290 HookEvent::PreToolCall,
1291 "Bash",
1292 serde_json::json!({"command": "ls"}),
1293 );
1294 let result = runner.dispatch(&input).await;
1295 assert!(
1296 matches!(result.action, HookAction::Skip),
1297 "expected Skip; got {:?}",
1298 result.action
1299 );
1300 }
1301
1302 #[tokio::test]
1303 async fn dispatch_returns_continue_when_no_hooks() {
1304 let runner = ExternalHookRunner::discover(&[]);
1305 let input = make_tool_input(
1306 HookEvent::PreToolCall,
1307 "Read",
1308 serde_json::json!({"path": "foo.txt"}),
1309 );
1310 let result = runner.dispatch(&input).await;
1311 assert!(matches!(result.action, HookAction::Continue));
1312 }
1313
1314 #[tokio::test]
1315 #[cfg(unix)]
1316 async fn dispatch_treats_timeout_as_continue() {
1317 use std::os::unix::fs::PermissionsExt;
1318 let tmp = tempfile::tempdir().unwrap();
1319 let hook_path = tmp.path().join("hang.sh");
1320
1321 let script = "#!/bin/sh\nsleep 30\n";
1323 std::fs::write(&hook_path, script).unwrap();
1324 let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
1325 perms.set_mode(0o755);
1326 std::fs::set_permissions(&hook_path, perms).unwrap();
1327
1328 let runner = ExternalHookRunner::discover(&[tmp.path().to_path_buf()]);
1329 assert_eq!(runner.len(), 1);
1330 let input = make_tool_input(
1331 HookEvent::PreToolCall,
1332 "Bash",
1333 serde_json::json!({"command": "ls"}),
1334 );
1335 let result = runner.dispatch(&input).await;
1336 assert!(matches!(result.action, HookAction::Continue));
1338 }
1339
1340 #[tokio::test]
1341 #[cfg(unix)]
1342 async fn dispatch_treats_bad_output_as_continue() {
1343 use std::os::unix::fs::PermissionsExt;
1344 let tmp = tempfile::tempdir().unwrap();
1345 let hook_path = tmp.path().join("bad.sh");
1346
1347 let script = "#!/bin/sh\necho 'not json'\n";
1349 std::fs::write(&hook_path, script).unwrap();
1350 let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
1351 perms.set_mode(0o755);
1352 std::fs::set_permissions(&hook_path, perms).unwrap();
1353
1354 let runner = ExternalHookRunner::discover(&[tmp.path().to_path_buf()]);
1355 assert_eq!(runner.len(), 1);
1356 let input = make_tool_input(
1357 HookEvent::PreToolCall,
1358 "Bash",
1359 serde_json::json!({"command": "ls"}),
1360 );
1361 let result = runner.dispatch(&input).await;
1362 assert!(matches!(result.action, HookAction::Continue));
1364 }
1365
1366 #[tokio::test]
1367 async fn dispatch_short_circuits_on_first_non_continue() {
1368 use crate::hooks::config::{HookCommand, HookCommandType, HookMatcher, HooksConfig};
1369 let cfg = HooksConfig {
1373 events: std::collections::HashMap::from([(
1374 "PreToolCall".to_string(),
1375 vec![HookMatcher {
1376 matcher: None,
1377 hooks: vec![
1378 HookCommand {
1379 r#type: HookCommandType::Command,
1380 command: Some("/bin/true".to_string()),
1381 timeout: 5,
1382 ..Default::default()
1383 },
1384 HookCommand {
1385 r#type: HookCommandType::Command,
1386 command: Some("/bin/true".to_string()),
1387 timeout: 5,
1388 ..Default::default()
1389 },
1390 ],
1391 }],
1392 )]),
1393 };
1394 let mock_skip = HookResult {
1395 action: HookAction::Skip,
1396 system_message: Some("first".to_string()),
1397 ..Default::default()
1398 };
1399 let runner =
1401 ExternalHookRunner::from_config(cfg).with_mock_results(vec![Some(mock_skip), None]);
1402 let input = make_tool_input(
1403 HookEvent::PreToolCall,
1404 "Write",
1405 serde_json::json!({"path": "test.txt"}),
1406 );
1407 let result = runner.dispatch(&input).await;
1408 assert!(matches!(result.action, HookAction::Skip));
1409 }
1410
1411 #[test]
1414 fn new_hook_events_serialize_camel_case() {
1415 let cases: &[(&str, HookEvent)] = &[
1416 ("postToolCallFailure", HookEvent::PostToolCallFailure),
1417 ("permissionDenied", HookEvent::PermissionDenied),
1418 ("sessionStart", HookEvent::SessionStart),
1419 ("sessionEnd", HookEvent::SessionEnd),
1420 ("userPromptSubmit", HookEvent::UserPromptSubmit),
1421 ("stop", HookEvent::Stop),
1422 ("subagentStart", HookEvent::SubagentStart),
1423 ("subagentStop", HookEvent::SubagentStop),
1424 ("notification", HookEvent::Notification),
1425 ("setup", HookEvent::Setup),
1426 ];
1427 for (expected, event) in cases {
1428 let json = serde_json::to_string(event).unwrap();
1429 assert_eq!(
1430 json,
1431 format!("\"{expected}\""),
1432 "wrong camelCase for {expected}"
1433 );
1434 }
1435 }
1436
1437 #[test]
1438 fn hook_input_optional_fields_absent_when_none() {
1439 let input = HookInput {
1440 event: HookEvent::UserPromptSubmit,
1441 tool_name: None,
1442 args: None,
1443 mode: "ask".to_string(),
1444 content: Some("hello".to_string()),
1445 message: None,
1446 depth: None,
1447 reason: None,
1448 error: None,
1449 };
1450 let json = serde_json::to_string(&input).unwrap();
1451 assert!(
1453 !json.contains("tool_name") && !json.contains("toolName"),
1454 "toolName should be absent"
1455 );
1456 assert!(!json.contains("\"args\""), "args should be absent");
1457 assert!(json.contains("\"hello\""), "content should be present");
1458 }
1459
1460 #[test]
1461 fn hook_input_tool_name_present_for_tool_events() {
1462 let input = make_tool_input(
1463 HookEvent::PreToolCall,
1464 "Bash",
1465 serde_json::json!({"command": "ls"}),
1466 );
1467 let json = serde_json::to_string(&input).unwrap();
1468 assert!(json.contains("Bash"));
1469 assert!(json.contains("command"));
1470 }
1471
1472 #[test]
1475 fn hook_output_parses_additional_context() {
1476 let json = r#"{"action":"continue","additionalContext":"extra info"}"#;
1477 let output: HookOutput = serde_json::from_str(json).unwrap();
1478 let result = output.into_hook_result();
1479 assert!(matches!(result.action, HookAction::Continue));
1480 assert_eq!(result.additional_context.as_deref(), Some("extra info"));
1481 }
1482
1483 #[test]
1484 fn hook_output_parses_updated_input() {
1485 let json = r#"{"action":"continue","updatedInput":{"command":"ls -la"}}"#;
1486 let output: HookOutput = serde_json::from_str(json).unwrap();
1487 let result = output.into_hook_result();
1488 assert_eq!(
1489 result.updated_input,
1490 Some(serde_json::json!({"command": "ls -la"}))
1491 );
1492 }
1493
1494 #[test]
1495 fn hook_output_parses_permission_decision_allow() {
1496 let json = r#"{"action":"continue","permissionDecision":"allow","permissionDecisionReason":"safe"}"#;
1497 let output: HookOutput = serde_json::from_str(json).unwrap();
1498 let result = output.into_hook_result();
1499 assert_eq!(result.permission_decision, Some(PermissionDecision::Allow));
1500 assert_eq!(result.permission_decision_reason.as_deref(), Some("safe"));
1501 }
1502
1503 #[test]
1504 fn hook_output_parses_permission_decision_deny() {
1505 let json = r#"{"action":"skip","permissionDecision":"deny"}"#;
1506 let output: HookOutput = serde_json::from_str(json).unwrap();
1507 let result = output.into_hook_result();
1508 assert_eq!(result.permission_decision, Some(PermissionDecision::Deny));
1509 assert!(matches!(result.action, HookAction::Skip));
1510 }
1511
1512 #[test]
1513 fn hook_output_parses_permission_decision_passthrough() {
1514 let json = r#"{"action":"continue","permissionDecision":"passthrough"}"#;
1515 let output: HookOutput = serde_json::from_str(json).unwrap();
1516 let result = output.into_hook_result();
1517 assert_eq!(
1518 result.permission_decision,
1519 Some(PermissionDecision::Passthrough)
1520 );
1521 }
1522
1523 #[test]
1524 fn hook_result_default_is_continue_with_no_extras() {
1525 let result = HookResult::default();
1526 assert!(matches!(result.action, HookAction::Continue));
1527 assert!(result.additional_context.is_none());
1528 assert!(result.updated_input.is_none());
1529 assert!(result.system_message.is_none());
1530 assert!(!result.suppress_output);
1531 assert!(result.permission_decision.is_none());
1532 assert!(result.permission_decision_reason.is_none());
1533 }
1534
1535 #[test]
1536 fn hook_output_system_message_included() {
1537 let json = r#"{"action":"continue","systemMessage":"Please review before proceeding"}"#;
1538 let output: HookOutput = serde_json::from_str(json).unwrap();
1539 let result = output.into_hook_result();
1540 assert_eq!(
1541 result.system_message.as_deref(),
1542 Some("Please review before proceeding")
1543 );
1544 }
1545
1546 #[test]
1547 fn hook_output_suppress_output_default_false() {
1548 let json = r#"{"action":"continue"}"#;
1549 let output: HookOutput = serde_json::from_str(json).unwrap();
1550 let result = output.into_hook_result();
1551 assert!(!result.suppress_output);
1552 }
1553
1554 #[test]
1555 fn hook_output_suppress_output_true() {
1556 let json = r#"{"action":"continue","suppressOutput":true}"#;
1557 let output: HookOutput = serde_json::from_str(json).unwrap();
1558 let result = output.into_hook_result();
1559 assert!(result.suppress_output);
1560 }
1561
1562 fn make_non_tool_input() -> HookInput {
1565 HookInput {
1566 event: HookEvent::UserPromptSubmit,
1567 tool_name: None,
1568 args: None,
1569 mode: "ask".to_string(),
1570 content: Some("hello".to_string()),
1571 message: None,
1572 depth: None,
1573 reason: None,
1574 error: None,
1575 }
1576 }
1577
1578 #[tokio::test]
1579 async fn http_hook_posts_json_input_and_parses_response() {
1580 let mut server = mockito::Server::new_async().await;
1581 let mock = server
1582 .mock("POST", "/hook")
1583 .with_status(200)
1584 .with_header("content-type", "application/json")
1585 .with_body(r#"{"action":"skip","message":"blocked by http hook"}"#)
1586 .create_async()
1587 .await;
1588
1589 let input = make_non_tool_input();
1590 let url = format!("{}/hook", server.url());
1591 let result = run_http_hook(&url, &input, 10, None, None, HookFailMode::Open).await;
1592 assert!(matches!(result.action, HookAction::Skip));
1593 mock.assert_async().await;
1594 }
1595
1596 #[tokio::test]
1597 async fn http_hook_parses_continue_response() {
1598 let mut server = mockito::Server::new_async().await;
1599 server
1600 .mock("POST", "/hook")
1601 .with_status(200)
1602 .with_header("content-type", "application/json")
1603 .with_body(r#"{"action":"continue"}"#)
1604 .create_async()
1605 .await;
1606
1607 let input = make_non_tool_input();
1608 let url = format!("{}/hook", server.url());
1609 let result = run_http_hook(&url, &input, 10, None, None, HookFailMode::Open).await;
1610 assert!(matches!(result.action, HookAction::Continue));
1611 }
1612
1613 #[tokio::test]
1614 async fn http_hook_connection_error_returns_continue() {
1615 let input = make_non_tool_input();
1617 let result = run_http_hook(
1618 "http://127.0.0.1:19999/hook",
1619 &input,
1620 2,
1621 None,
1622 None,
1623 HookFailMode::Open,
1624 )
1625 .await;
1626 assert!(matches!(result.action, HookAction::Continue));
1627 }
1628
1629 #[tokio::test]
1630 async fn http_hook_bad_json_response_returns_continue() {
1631 let mut server = mockito::Server::new_async().await;
1632 server
1633 .mock("POST", "/hook")
1634 .with_status(200)
1635 .with_body("not json at all")
1636 .create_async()
1637 .await;
1638
1639 let input = make_non_tool_input();
1640 let url = format!("{}/hook", server.url());
1641 let result = run_http_hook(&url, &input, 10, None, None, HookFailMode::Open).await;
1642 assert!(matches!(result.action, HookAction::Continue));
1643 }
1644
1645 #[test]
1646 fn env_var_interpolation_respects_allowlist() {
1647 std::env::set_var("MY_TOKEN", "secret123");
1648 let allowed = vec!["MY_TOKEN".to_string()];
1649 let result = interpolate_env_vars("Bearer $MY_TOKEN", Some(&allowed));
1650 assert_eq!(result, "Bearer secret123");
1651 }
1652
1653 #[test]
1654 fn env_var_interpolation_empty_for_non_allowed() {
1655 std::env::set_var("BLOCKED_VAR", "should-not-appear");
1656 let allowed = vec!["OTHER_VAR".to_string()];
1657 let result = interpolate_env_vars("Bearer $BLOCKED_VAR", Some(&allowed));
1658 assert_eq!(result, "Bearer ");
1659 }
1660
1661 #[test]
1662 fn env_var_interpolation_curly_brace_form() {
1663 std::env::set_var("API_KEY", "mykey");
1664 let allowed = vec!["API_KEY".to_string()];
1665 let result = interpolate_env_vars("key=${API_KEY}", Some(&allowed));
1666 assert_eq!(result, "key=mykey");
1667 }
1668
1669 #[test]
1670 fn env_var_interpolation_no_allowlist_replaces_all() {
1671 std::env::set_var("UNGUARDED", "value");
1672 let result = interpolate_env_vars("$UNGUARDED", None);
1673 assert_eq!(result, "value");
1674 }
1675
1676 #[tokio::test]
1679 async fn prompt_hook_replaces_arguments_placeholder() {
1680 use crate::llm::{Completion, MockProvider};
1681 let captured = Arc::new(std::sync::Mutex::new(String::new()));
1683 let c = captured.clone();
1684
1685 let provider = MockProvider::new(vec![Completion {
1688 content: r#"{"action":"skip","message":"prompt blocked"}"#.to_string(),
1689 ..Default::default()
1690 }]);
1691 drop(c); let input = make_non_tool_input();
1694 let result = run_prompt_hook(
1695 &provider,
1696 "Is this safe? $ARGUMENTS",
1697 &input,
1698 Duration::from_secs(10),
1699 HookFailMode::Open,
1700 )
1701 .await;
1702 assert!(matches!(result.action, HookAction::Skip));
1703 }
1704
1705 #[tokio::test]
1706 async fn prompt_hook_uses_llm_response_continue() {
1707 use crate::llm::{Completion, MockProvider};
1708 let provider = MockProvider::new(vec![Completion {
1709 content: r#"{"action":"continue"}"#.to_string(),
1710 ..Default::default()
1711 }]);
1712 let input = make_non_tool_input();
1713 let result = run_prompt_hook(
1714 &provider,
1715 "Check: $ARGUMENTS",
1716 &input,
1717 Duration::from_secs(10),
1718 HookFailMode::Open,
1719 )
1720 .await;
1721 assert!(matches!(result.action, HookAction::Continue));
1722 }
1723
1724 #[tokio::test]
1725 async fn prompt_hook_falls_back_on_non_json_response() {
1726 use crate::llm::{Completion, MockProvider};
1727 let provider = MockProvider::new(vec![Completion {
1728 content: "Sorry, I cannot evaluate this.".to_string(),
1729 ..Default::default()
1730 }]);
1731 let input = make_non_tool_input();
1732 let result = run_prompt_hook(
1733 &provider,
1734 "Is this safe? $ARGUMENTS",
1735 &input,
1736 Duration::from_secs(10),
1737 HookFailMode::Open,
1738 )
1739 .await;
1740 assert!(matches!(result.action, HookAction::Continue));
1742 }
1743
1744 #[tokio::test]
1745 async fn no_llm_prompt_hook_returns_continue() {
1746 use crate::hooks::config::HooksConfig;
1747 let json = r#"{"PreToolCall":[{"hooks":[{"type":"prompt","prompt":"Is this safe? $ARGUMENTS"}]}]}"#;
1748 let cfg: HooksConfig = serde_json::from_str(json).unwrap();
1749 let runner = ExternalHookRunner::from_config_with_llm(cfg, None);
1751 assert_eq!(runner.len(), 1); let input = make_tool_input(HookEvent::PreToolCall, "Bash", serde_json::json!({}));
1754 let result = runner.dispatch(&input).await;
1755 assert!(matches!(result.action, HookAction::Continue));
1757 }
1758
1759 #[test]
1762 fn from_config_creates_hooks_from_json() {
1763 use crate::hooks::config::HooksConfig;
1764 let json = r#"{"PreToolCall":[{"matcher":"Bash","hooks":[{"type":"command","command":"/usr/bin/true"}]}]}"#;
1765 let cfg: HooksConfig = serde_json::from_str(json).unwrap();
1766 let runner = ExternalHookRunner::from_config(cfg);
1767 assert_eq!(runner.len(), 1);
1768 }
1769
1770 #[test]
1771 fn from_config_http_type_is_registered() {
1772 use crate::hooks::config::HooksConfig;
1773 let json = r#"{"PreToolCall":[{"hooks":[{"type":"http","url":"https://example.com"}]}]}"#;
1775 let cfg: HooksConfig = serde_json::from_str(json).unwrap();
1776 let runner = ExternalHookRunner::from_config(cfg);
1777 assert_eq!(runner.len(), 1);
1778 }
1779
1780 #[test]
1781 fn from_config_prompt_without_prompt_field_is_skipped() {
1782 use crate::hooks::config::HooksConfig;
1783 let json = r#"{"PreToolCall":[{"hooks":[{"type":"prompt"}]}]}"#;
1785 let cfg: HooksConfig = serde_json::from_str(json).unwrap();
1786 let runner = ExternalHookRunner::from_config(cfg);
1787 assert_eq!(runner.len(), 0);
1788 }
1789
1790 #[test]
1791 fn from_config_http_without_url_is_skipped() {
1792 use crate::hooks::config::HooksConfig;
1793 let json = r#"{"PreToolCall":[{"hooks":[{"type":"http"}]}]}"#;
1795 let cfg: HooksConfig = serde_json::from_str(json).unwrap();
1796 let runner = ExternalHookRunner::from_config(cfg);
1797 assert_eq!(runner.len(), 0);
1798 }
1799
1800 #[test]
1801 fn from_config_empty_config_gives_empty_runner() {
1802 use crate::hooks::config::HooksConfig;
1803 let runner = ExternalHookRunner::from_config(HooksConfig::default());
1804 assert!(runner.is_empty());
1805 }
1806
1807 #[tokio::test]
1808 async fn from_config_respects_matcher_event_filter() {
1809 use crate::hooks::config::{HookCommand, HookCommandType, HookMatcher, HooksConfig};
1810 let cfg = HooksConfig {
1813 events: std::collections::HashMap::from([(
1814 "PostToolCall".to_string(),
1815 vec![HookMatcher {
1816 matcher: Some("Bash".to_string()),
1817 hooks: vec![HookCommand {
1818 r#type: HookCommandType::Command,
1819 command: Some("/bin/true".to_string()),
1820 timeout: 5,
1821 ..Default::default()
1822 }],
1823 }],
1824 )]),
1825 };
1826 let mock_skip = HookResult {
1827 action: HookAction::Skip,
1828 ..Default::default()
1829 };
1830 let runner = ExternalHookRunner::from_config(cfg).with_mock_results(vec![Some(mock_skip)]);
1831
1832 let pre_input = make_tool_input(HookEvent::PreToolCall, "Bash", serde_json::json!({}));
1834 let result = runner.dispatch(&pre_input).await;
1835 assert!(
1836 matches!(result.action, HookAction::Continue),
1837 "PreToolCall should not trigger PostToolCall hook"
1838 );
1839
1840 let post_input = make_tool_input(HookEvent::PostToolCall, "Bash", serde_json::json!({}));
1842 let result = runner.dispatch(&post_input).await;
1843 assert!(
1844 matches!(result.action, HookAction::Skip),
1845 "PostToolCall should trigger hook"
1846 );
1847 }
1848
1849 #[tokio::test]
1852 async fn once_hook_runs_only_first_time() {
1853 use crate::hooks::config::{HookCommand, HookCommandType, HookMatcher, HooksConfig};
1854 let cfg = HooksConfig {
1857 events: std::collections::HashMap::from([(
1858 "PreToolCall".to_string(),
1859 vec![HookMatcher {
1860 matcher: None,
1861 hooks: vec![HookCommand {
1862 r#type: HookCommandType::Command,
1863 command: Some("/bin/true".to_string()),
1864 once: true,
1865 timeout: 5,
1866 ..Default::default()
1867 }],
1868 }],
1869 )]),
1870 };
1871 let mock_skip = HookResult {
1872 action: HookAction::Skip,
1873 ..Default::default()
1874 };
1875 let runner = ExternalHookRunner::from_config(cfg).with_mock_results(vec![Some(mock_skip)]);
1876 let input = make_tool_input(HookEvent::PreToolCall, "Bash", serde_json::json!({}));
1877 let first = runner.dispatch(&input).await;
1878 let second = runner.dispatch(&input).await;
1879 assert!(
1880 matches!(first.action, HookAction::Skip),
1881 "first dispatch must run the hook → Skip"
1882 );
1883 assert!(
1884 matches!(second.action, HookAction::Continue),
1885 "second dispatch must skip the once-hook → Continue"
1886 );
1887 }
1888
1889 #[tokio::test]
1890 #[cfg(unix)]
1891 async fn async_hook_returns_continue_immediately() {
1892 use crate::hooks::config::{HookCommand, HookCommandType, HookMatcher, HooksConfig};
1893 let tmp = tempfile::tempdir().unwrap();
1894 let hook_path = tmp.path().join("slow_hook.sh");
1895 std::fs::write(
1898 &hook_path,
1899 "#!/bin/sh\nsleep 10\necho '{\"action\":\"skip\"}'\n",
1900 )
1901 .unwrap();
1902 {
1903 use std::os::unix::fs::PermissionsExt;
1904 let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
1905 perms.set_mode(0o755);
1906 std::fs::set_permissions(&hook_path, perms).unwrap();
1907 }
1908 let cfg = HooksConfig {
1909 events: std::collections::HashMap::from([(
1910 "PreToolCall".to_string(),
1911 vec![HookMatcher {
1912 matcher: None,
1913 hooks: vec![HookCommand {
1914 r#type: HookCommandType::Command,
1915 command: Some(hook_path.to_string_lossy().to_string()),
1916 r#async: true,
1917 timeout: 5,
1918 ..Default::default()
1919 }],
1920 }],
1921 )]),
1922 };
1923 let runner = ExternalHookRunner::from_config(cfg);
1924 let input = make_tool_input(HookEvent::PreToolCall, "Bash", serde_json::json!({}));
1925 let start = std::time::Instant::now();
1926 let result = runner.dispatch(&input).await;
1927 let elapsed = start.elapsed();
1928 assert!(
1930 elapsed.as_secs() < 1,
1931 "async hook blocked dispatch: {elapsed:?}"
1932 );
1933 assert!(matches!(result.action, HookAction::Continue));
1935 }
1936
1937 #[tokio::test]
1938 async fn async_rewake_exit2_triggers_cancel() {
1939 use crate::hooks::config::{HookCommand, HookCommandType, HookMatcher, HooksConfig};
1940 let token = CancellationToken::new();
1943 let child_token = token.clone();
1944 let cfg = HooksConfig {
1945 events: std::collections::HashMap::from([(
1946 "PreToolCall".to_string(),
1947 vec![HookMatcher {
1948 matcher: None,
1949 hooks: vec![HookCommand {
1950 r#type: HookCommandType::Command,
1951 command: Some("/bin/true".to_string()), async_rewake: true,
1953 timeout: 5,
1954 ..Default::default()
1955 }],
1956 }],
1957 )]),
1958 };
1959 let runner = ExternalHookRunner::from_config(cfg)
1960 .with_cancel_token(child_token)
1961 .with_mock_exit_codes(vec![Some(2)]);
1962 let input = make_tool_input(HookEvent::PreToolCall, "Bash", serde_json::json!({}));
1963 runner.dispatch(&input).await;
1964 let deadline = std::time::Instant::now() + Duration::from_secs(5);
1966 while !token.is_cancelled() {
1967 if std::time::Instant::now() >= deadline {
1968 panic!("timed out waiting for asyncRewake cancellation");
1969 }
1970 tokio::time::sleep(Duration::from_millis(10)).await;
1971 }
1972 assert!(
1973 token.is_cancelled(),
1974 "exit code 2 should have triggered cancellation"
1975 );
1976 }
1977
1978 #[tokio::test]
1979 #[cfg(unix)]
1980 async fn async_rewake_exit0_no_cancel() {
1981 use crate::hooks::config::{HookCommand, HookCommandType, HookMatcher, HooksConfig};
1982 let tmp = tempfile::tempdir().unwrap();
1983 let hook_path = tmp.path().join("exit0.sh");
1984 std::fs::write(&hook_path, "#!/bin/sh\nexit 0\n").unwrap();
1986 {
1987 use std::os::unix::fs::PermissionsExt;
1988 let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
1989 perms.set_mode(0o755);
1990 std::fs::set_permissions(&hook_path, perms).unwrap();
1991 }
1992 let token = CancellationToken::new();
1993 let child_token = token.clone();
1994 let cfg = HooksConfig {
1995 events: std::collections::HashMap::from([(
1996 "PreToolCall".to_string(),
1997 vec![HookMatcher {
1998 matcher: None,
1999 hooks: vec![HookCommand {
2000 r#type: HookCommandType::Command,
2001 command: Some(hook_path.to_string_lossy().to_string()),
2002 async_rewake: true,
2003 timeout: 5,
2004 ..Default::default()
2005 }],
2006 }],
2007 )]),
2008 };
2009 let runner = ExternalHookRunner::from_config(cfg).with_cancel_token(child_token);
2010 let input = make_tool_input(HookEvent::PreToolCall, "Bash", serde_json::json!({}));
2011 runner.dispatch(&input).await;
2012 tokio::time::sleep(Duration::from_millis(300)).await;
2013 assert!(
2014 !token.is_cancelled(),
2015 "exit code 0 should NOT trigger cancellation"
2016 );
2017 }
2018
2019 #[tokio::test]
2022 #[cfg(unix)]
2023 async fn open_hook_times_out_returns_continue() {
2024 use crate::hooks::config::{HookCommand, HookCommandType, HookMatcher, HooksConfig};
2025 let tmp = tempfile::tempdir().unwrap();
2026 let hook_path = tmp.path().join("open_hang.sh");
2027 let script = "#!/bin/sh\nsleep 30\n";
2028 std::fs::write(&hook_path, script).unwrap();
2029 {
2030 use std::os::unix::fs::PermissionsExt;
2031 let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
2032 perms.set_mode(0o755);
2033 std::fs::set_permissions(&hook_path, perms).unwrap();
2034 }
2035 let cfg = HooksConfig {
2036 events: std::collections::HashMap::from([(
2037 "PreToolCall".to_string(),
2038 vec![HookMatcher {
2039 matcher: None,
2040 hooks: vec![HookCommand {
2041 r#type: HookCommandType::Command,
2042 command: Some(hook_path.to_string_lossy().to_string()),
2043 timeout: 1,
2044 fail_mode: HookFailMode::Open,
2045 ..Default::default()
2046 }],
2047 }],
2048 )]),
2049 };
2050 let runner = ExternalHookRunner::from_config(cfg);
2051 let input = make_tool_input(
2052 HookEvent::PreToolCall,
2053 "Bash",
2054 serde_json::json!({"command": "ls"}),
2055 );
2056 let result = runner.dispatch(&input).await;
2057 assert!(
2059 matches!(result.action, HookAction::Continue),
2060 "Open hook timeout should return Continue, got {:?}",
2061 result.action
2062 );
2063 }
2064
2065 #[tokio::test]
2066 #[cfg(unix)]
2067 async fn closed_hook_times_out_returns_error() {
2068 use crate::hooks::config::{HookCommand, HookCommandType, HookMatcher, HooksConfig};
2069 let tmp = tempfile::tempdir().unwrap();
2070 let hook_path = tmp.path().join("closed_hang.sh");
2071 let script = "#!/bin/sh\nsleep 30\n";
2072 std::fs::write(&hook_path, script).unwrap();
2073 {
2074 use std::os::unix::fs::PermissionsExt;
2075 let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
2076 perms.set_mode(0o755);
2077 std::fs::set_permissions(&hook_path, perms).unwrap();
2078 }
2079 let cfg = HooksConfig {
2080 events: std::collections::HashMap::from([(
2081 "PreToolCall".to_string(),
2082 vec![HookMatcher {
2083 matcher: None,
2084 hooks: vec![HookCommand {
2085 r#type: HookCommandType::Command,
2086 command: Some(hook_path.to_string_lossy().to_string()),
2087 timeout: 1,
2088 fail_mode: HookFailMode::Closed,
2089 ..Default::default()
2090 }],
2091 }],
2092 )]),
2093 };
2094 let runner = ExternalHookRunner::from_config(cfg);
2095 let input = make_tool_input(
2096 HookEvent::PreToolCall,
2097 "Bash",
2098 serde_json::json!({"command": "ls"}),
2099 );
2100 let result = runner.dispatch(&input).await;
2101 assert!(
2103 matches!(result.action, HookAction::Error(_)),
2104 "Closed hook timeout should return Error, got {:?}",
2105 result.action
2106 );
2107 }
2108
2109 #[tokio::test]
2110 #[cfg(unix)]
2111 async fn closed_hook_exits_nonzero_returns_error() {
2112 use crate::hooks::config::{HookCommand, HookCommandType, HookMatcher, HooksConfig};
2113 let tmp = tempfile::tempdir().unwrap();
2114 let hook_path = tmp.path().join("closed_exit1.sh");
2115 let script = "#!/bin/sh\nexit 1\n";
2116 std::fs::write(&hook_path, script).unwrap();
2117 {
2118 use std::os::unix::fs::PermissionsExt;
2119 let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
2120 perms.set_mode(0o755);
2121 std::fs::set_permissions(&hook_path, perms).unwrap();
2122 }
2123 let cfg = HooksConfig {
2124 events: std::collections::HashMap::from([(
2125 "PreToolCall".to_string(),
2126 vec![HookMatcher {
2127 matcher: None,
2128 hooks: vec![HookCommand {
2129 r#type: HookCommandType::Command,
2130 command: Some(hook_path.to_string_lossy().to_string()),
2131 timeout: 5,
2132 fail_mode: HookFailMode::Closed,
2133 ..Default::default()
2134 }],
2135 }],
2136 )]),
2137 };
2138 let runner = ExternalHookRunner::from_config(cfg);
2139 let input = make_tool_input(
2140 HookEvent::PreToolCall,
2141 "Bash",
2142 serde_json::json!({"command": "rm -rf /"}),
2143 );
2144 let result = runner.dispatch(&input).await;
2145 assert!(
2147 matches!(result.action, HookAction::Error(_)),
2148 "Closed hook non-zero exit should return Error, got {:?}",
2149 result.action
2150 );
2151 }
2152
2153 #[tokio::test]
2154 #[cfg(unix)]
2155 async fn closed_hook_post_tool_call_timeout_returns_continue() {
2156 use crate::hooks::config::{HookCommand, HookCommandType, HookMatcher, HooksConfig};
2157 let tmp = tempfile::tempdir().unwrap();
2158 let hook_path = tmp.path().join("closed_post_hang.sh");
2159 let script = "#!/bin/sh\nsleep 30\n";
2160 std::fs::write(&hook_path, script).unwrap();
2161 {
2162 use std::os::unix::fs::PermissionsExt;
2163 let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
2164 perms.set_mode(0o755);
2165 std::fs::set_permissions(&hook_path, perms).unwrap();
2166 }
2167 let cfg = HooksConfig {
2168 events: std::collections::HashMap::from([(
2169 "PostToolCall".to_string(),
2170 vec![HookMatcher {
2171 matcher: None,
2172 hooks: vec![HookCommand {
2173 r#type: HookCommandType::Command,
2174 command: Some(hook_path.to_string_lossy().to_string()),
2175 timeout: 1,
2176 fail_mode: HookFailMode::Closed,
2177 ..Default::default()
2178 }],
2179 }],
2180 )]),
2181 };
2182 let runner = ExternalHookRunner::from_config(cfg);
2183 let input = make_tool_input(
2184 HookEvent::PostToolCall,
2185 "Bash",
2186 serde_json::json!({"command": "ls"}),
2187 );
2188 let result = runner.dispatch(&input).await;
2189 assert!(
2191 matches!(result.action, HookAction::Continue),
2192 "Closed PostToolCall timeout should return Continue, got {:?}",
2193 result.action
2194 );
2195 }
2196}