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, HooksConfig};
33use crate::llm::LlmProvider;
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);
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "camelCase")]
53pub enum HookEvent {
54 PreToolCall,
56 PostToolCall,
57 PermissionRequest,
58 PostToolCallFailure,
60 PermissionDenied,
61 SessionStart,
62 SessionEnd,
63 UserPromptSubmit,
64 Stop,
65 SubagentStart,
66 SubagentStop,
67 Notification,
68 Setup,
69}
70
71#[derive(Debug, Clone, Serialize)]
73#[serde(rename_all = "camelCase")]
74pub struct HookInput {
75 pub event: HookEvent,
76 #[serde(skip_serializing_if = "Option::is_none")]
78 pub tool_name: Option<String>,
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub args: Option<serde_json::Value>,
82 pub mode: String,
83 #[serde(skip_serializing_if = "Option::is_none")]
86 pub content: Option<String>,
87 #[serde(skip_serializing_if = "Option::is_none")]
89 pub message: Option<String>,
90 #[serde(skip_serializing_if = "Option::is_none")]
92 pub depth: Option<usize>,
93 #[serde(skip_serializing_if = "Option::is_none")]
95 pub reason: Option<String>,
96 #[serde(skip_serializing_if = "Option::is_none")]
98 pub error: Option<String>,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
103#[serde(rename_all = "camelCase")]
104enum JsonAction {
105 Continue,
106 Skip,
107 Error,
108}
109
110#[derive(Debug, Clone, Deserialize, Default)]
112#[serde(rename_all = "camelCase")]
113struct HookOutput {
114 #[serde(default)]
115 action: Option<JsonAction>,
116 #[serde(default)]
117 message: Option<String>,
118
119 #[serde(default)]
122 additional_context: Option<String>,
123 #[serde(default)]
125 updated_input: Option<serde_json::Value>,
126 #[serde(default)]
128 system_message: Option<String>,
129 #[serde(default)]
131 suppress_output: bool,
132 #[serde(default)]
134 permission_decision: Option<String>,
135 #[serde(default)]
137 permission_decision_reason: Option<String>,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
142pub enum PermissionDecision {
143 Allow,
144 Deny,
145 Passthrough,
146}
147
148impl PermissionDecision {
149 fn from_str(s: &str) -> Option<Self> {
150 match s {
151 "allow" => Some(Self::Allow),
152 "deny" => Some(Self::Deny),
153 "passthrough" => Some(Self::Passthrough),
154 _ => None,
155 }
156 }
157}
158
159#[derive(Debug, Clone)]
162pub struct HookResult {
163 pub action: HookAction,
165 pub additional_context: Option<String>,
167 pub updated_input: Option<serde_json::Value>,
169 pub system_message: Option<String>,
171 pub suppress_output: bool,
173 pub permission_decision: Option<PermissionDecision>,
175 pub permission_decision_reason: Option<String>,
177}
178
179impl Default for HookResult {
180 fn default() -> Self {
181 Self {
182 action: HookAction::Continue,
183 additional_context: None,
184 updated_input: None,
185 system_message: None,
186 suppress_output: false,
187 permission_decision: None,
188 permission_decision_reason: None,
189 }
190 }
191}
192
193impl HookResult {
194 pub fn continue_default() -> Self {
196 Self::default()
197 }
198}
199
200impl HookOutput {
201 fn into_hook_result(self) -> HookResult {
203 let action = match self.action.unwrap_or(JsonAction::Continue) {
204 JsonAction::Continue => HookAction::Continue,
205 JsonAction::Skip => HookAction::Skip,
206 JsonAction::Error => HookAction::Error(
207 self.message
208 .clone()
209 .unwrap_or_else(|| "external hook blocked".to_string()),
210 ),
211 };
212 let permission_decision = self
213 .permission_decision
214 .as_deref()
215 .and_then(PermissionDecision::from_str);
216 HookResult {
217 action,
218 additional_context: self.additional_context,
219 updated_input: self.updated_input,
220 system_message: self.system_message,
221 suppress_output: self.suppress_output,
222 permission_decision,
223 permission_decision_reason: self.permission_decision_reason,
224 }
225 }
226}
227
228#[derive(Clone, Debug)]
232enum ResolvedHookKind {
233 Command(PathBuf),
235 Http {
237 url: String,
238 headers: Option<std::collections::HashMap<String, String>>,
239 allowed_env_vars: Option<Vec<String>>,
240 },
241 Prompt {
243 prompt: String,
245 },
246}
247
248#[derive(Clone, Debug)]
250struct ResolvedHook {
251 kind: ResolvedHookKind,
253 timeout_secs: Option<u64>,
255 event_name: Option<String>,
257 matcher: Option<String>,
259 once: bool,
261 r#async: bool,
263 async_rewake: bool,
265}
266
267#[derive(Clone)]
279pub struct ExternalHookRunner {
280 hooks: Vec<ResolvedHook>,
281 llm: Option<Arc<dyn LlmProvider>>,
283 executed_once: Arc<Mutex<HashSet<usize>>>,
285 pub cancel_token: Option<CancellationToken>,
287 event_tx: Option<mpsc::UnboundedSender<AgentEvent>>,
289}
290
291impl ExternalHookRunner {
292 pub fn discover(dirs: &[PathBuf]) -> Self {
297 let mut hooks = Vec::new();
298 for dir in dirs {
299 if let Ok(entries) = std::fs::read_dir(dir) {
300 for entry in entries.flatten() {
301 let path = entry.path();
302 if is_executable(&path) {
303 hooks.push(ResolvedHook {
304 kind: ResolvedHookKind::Command(path),
305 timeout_secs: None,
306 event_name: None,
307 matcher: None,
308 once: false,
309 r#async: false,
310 async_rewake: false,
311 });
312 }
313 }
314 }
315 }
316 Self {
317 hooks,
318 llm: None,
319 executed_once: Arc::new(Mutex::new(HashSet::new())),
320 cancel_token: None,
321 event_tx: None,
322 }
323 }
324
325 pub fn from_config(config: HooksConfig) -> Self {
330 Self::from_config_with_llm(config, None)
331 }
332
333 pub fn from_config_with_llm(config: HooksConfig, llm: Option<Arc<dyn LlmProvider>>) -> Self {
336 let mut hooks = Vec::new();
337 for (event_name, matchers) in config.events {
338 for matcher_entry in matchers {
339 for cmd in &matcher_entry.hooks {
340 if let Some(resolved) = Self::resolve_command(
341 cmd,
342 Some(event_name.clone()),
343 matcher_entry.matcher.clone(),
344 ) {
345 hooks.push(resolved);
346 }
347 }
348 }
349 }
350 Self {
351 hooks,
352 llm,
353 executed_once: Arc::new(Mutex::new(HashSet::new())),
354 cancel_token: None,
355 event_tx: None,
356 }
357 }
358
359 pub fn with_cancel_token(mut self, token: CancellationToken) -> Self {
361 self.cancel_token = Some(token);
362 self
363 }
364
365 pub fn with_event_tx(mut self, tx: mpsc::UnboundedSender<AgentEvent>) -> Self {
367 self.event_tx = Some(tx);
368 self
369 }
370
371 fn emit(&self, event: AgentEvent) {
373 if let Some(tx) = &self.event_tx {
374 let _ = tx.send(event);
375 }
376 }
377
378 fn resolve_command(
382 cmd: &HookCommand,
383 event_name: Option<String>,
384 matcher: Option<String>,
385 ) -> Option<ResolvedHook> {
386 let base = |kind: ResolvedHookKind| ResolvedHook {
387 kind,
388 timeout_secs: Some(cmd.timeout),
389 event_name,
390 matcher,
391 once: cmd.once,
392 r#async: cmd.r#async,
393 async_rewake: cmd.async_rewake,
394 };
395
396 match cmd.r#type {
397 HookCommandType::Command => {
398 let command_str = cmd.command.as_deref()?;
399 let path = expand_tilde(command_str);
400 Some(base(ResolvedHookKind::Command(path)))
401 }
402 HookCommandType::Http => {
403 let url = cmd.url.clone()?;
404 Some(base(ResolvedHookKind::Http {
405 url,
406 headers: None,
407 allowed_env_vars: None,
408 }))
409 }
410 HookCommandType::Prompt | HookCommandType::Agent => {
411 let prompt = cmd.prompt.clone()?;
412 Some(base(ResolvedHookKind::Prompt { prompt }))
413 }
414 }
415 }
416
417 pub fn len(&self) -> usize {
419 self.hooks.len()
420 }
421
422 pub fn is_empty(&self) -> bool {
424 self.hooks.is_empty()
425 }
426
427 pub async fn dispatch(&self, input: &HookInput) -> HookResult {
432 let event_str = serde_json::to_string(&input.event)
433 .unwrap_or_default()
434 .trim_matches('"')
435 .to_string();
436 let tool_name = input.tool_name.as_deref().unwrap_or("");
437 let empty_args = serde_json::Value::Object(Default::default());
438 let args = input.args.as_ref().unwrap_or(&empty_args);
439
440 for (idx, hook) in self.hooks.iter().enumerate() {
441 if let Some(ref ev) = hook.event_name {
443 if !event_names_match(ev, &event_str) {
444 continue;
445 }
446 }
447 if !matches_hook(&hook.matcher, tool_name, args) {
449 continue;
450 }
451 if hook.once {
453 let already_ran = {
454 let guard = self.executed_once.lock().unwrap();
455 guard.contains(&idx)
456 };
457 if already_ran {
458 continue;
459 }
460 }
461
462 if hook.r#async && !hook.async_rewake {
464 let hook_clone = hook.clone();
465 let input_clone = input.clone();
466 let self_clone = self.clone();
467 tokio::spawn(async move {
468 if let Err(e) = self_clone.run_hook(&hook_clone, &input_clone).await {
469 tracing::warn!("async hook error: {e}");
470 }
471 });
472 if hook.once {
473 self.executed_once.lock().unwrap().insert(idx);
474 }
475 continue;
476 }
477
478 if hook.async_rewake {
480 let hook_clone = hook.clone();
481 let input_clone = input.clone();
482 let self_clone = self.clone();
483 let cancel = self.cancel_token.clone();
484 tokio::spawn(async move {
485 let exit_code = self_clone
486 .run_command_exit_code(&hook_clone, &input_clone)
487 .await
488 .unwrap_or(None);
489 if exit_code == Some(2) {
490 tracing::warn!("asyncRewake hook exited with code 2 — cancelling agent");
491 if let Some(token) = cancel {
492 token.cancel();
493 }
494 }
495 });
496 if hook.once {
497 self.executed_once.lock().unwrap().insert(idx);
498 }
499 continue;
500 }
501
502 let hook_display_name = match &hook.kind {
504 ResolvedHookKind::Command(path) => path
505 .file_name()
506 .map(|n| n.to_string_lossy().into_owned())
507 .unwrap_or_else(|| "hook".to_string()),
508 ResolvedHookKind::Http { url, .. } => url.clone(),
509 ResolvedHookKind::Prompt { .. } => "prompt-hook".to_string(),
510 };
511 self.emit(AgentEvent::HookStarted {
512 hook_event: event_str.clone(),
513 hook_name: hook_display_name.clone(),
514 status_message: None,
515 });
516 let start = std::time::Instant::now();
517 if let Ok(result) = self.run_hook(hook, input).await {
518 if hook.once {
519 self.executed_once.lock().unwrap().insert(idx);
520 }
521 let duration_ms = start.elapsed().as_millis() as u64;
522 let outcome = match &result.action {
523 HookAction::Continue => "continue".to_string(),
524 HookAction::Skip => "skip".to_string(),
525 HookAction::Error(reason) => format!("error: {reason}"),
526 };
527 if let Some(msg) = &result.system_message {
528 self.emit(AgentEvent::HookSystemMessage { text: msg.clone() });
529 }
530 self.emit(AgentEvent::HookFinished {
531 hook_event: event_str.clone(),
532 hook_name: hook_display_name.clone(),
533 outcome,
534 duration_ms,
535 });
536 if !matches!(result.action, HookAction::Continue) {
537 return result;
538 }
539 }
540 }
541 HookResult::continue_default()
542 }
543
544 async fn run_hook(&self, hook: &ResolvedHook, input: &HookInput) -> Result<HookResult> {
546 let hook_timeout = hook
547 .timeout_secs
548 .map(Duration::from_secs)
549 .unwrap_or(HOOK_TIMEOUT);
550
551 match &hook.kind {
552 ResolvedHookKind::Http {
553 url,
554 headers,
555 allowed_env_vars,
556 } => {
557 let result = run_http_hook(
558 url,
559 input,
560 hook_timeout.as_secs(),
561 headers.as_ref(),
562 allowed_env_vars.as_deref(),
563 )
564 .await;
565 Ok(result)
566 }
567 ResolvedHookKind::Prompt { prompt } => {
568 if let Some(llm) = &self.llm {
569 let result = run_prompt_hook(llm.as_ref(), prompt, input, hook_timeout).await;
570 Ok(result)
571 } else {
572 tracing::warn!(
574 "prompt hook configured but no LLM provider available; skipping"
575 );
576 Ok(HookResult::continue_default())
577 }
578 }
579 ResolvedHookKind::Command(path) => {
580 self.run_command_hook(path, input, hook_timeout).await
581 }
582 }
583 }
584
585 async fn run_command_exit_code(
587 &self,
588 hook: &ResolvedHook,
589 input: &HookInput,
590 ) -> Result<Option<i32>> {
591 let ResolvedHookKind::Command(path) = &hook.kind else {
592 return Ok(None);
593 };
594 let hook_timeout = hook
595 .timeout_secs
596 .map(Duration::from_secs)
597 .unwrap_or(HOOK_TIMEOUT);
598 let input_json = serde_json::to_string(input).map_err(|e| Error::Config {
599 message: format!("hook input serialize: {e}"),
600 })?;
601 let mut child = Command::new(path)
602 .stdin(std::process::Stdio::piped())
603 .stdout(std::process::Stdio::null())
604 .stderr(std::process::Stdio::null())
605 .spawn()
606 .map_err(|e| Error::Config {
607 message: format!("hook spawn {}: {e}", path.display()),
608 })?;
609 let status = timeout(hook_timeout, async {
610 use tokio::io::AsyncWriteExt;
611 if let Some(stdin) = child.stdin.as_mut() {
612 let _ = stdin.write_all(input_json.as_bytes()).await;
613 let _ = stdin.shutdown().await;
614 }
615 child.wait().await
616 })
617 .await;
618 match status {
619 Ok(Ok(s)) => Ok(s.code()),
620 _ => Ok(None),
621 }
622 }
623
624 async fn run_command_hook(
626 &self,
627 path: &PathBuf,
628 input: &HookInput,
629 hook_timeout: Duration,
630 ) -> Result<HookResult> {
631 let input_json = serde_json::to_string(input).map_err(|e| Error::Config {
632 message: format!("hook input serialize: {e}"),
633 })?;
634
635 let mut child = Command::new(path)
636 .stdin(std::process::Stdio::piped())
637 .stdout(std::process::Stdio::piped())
638 .stderr(std::process::Stdio::null())
639 .spawn()
640 .map_err(|e| Error::Config {
641 message: format!("hook spawn {}: {e}", path.display()),
642 })?;
643
644 let output = timeout(hook_timeout, async {
646 use tokio::io::AsyncWriteExt;
647 if let Some(stdin) = child.stdin.as_mut() {
648 let _ = stdin.write_all(input_json.as_bytes()).await;
649 let _ = stdin.shutdown().await;
651 }
652 child.wait_with_output().await
653 })
654 .await
655 .map_err(|_| Error::Config {
656 message: format!("hook timeout: {}", path.display()),
657 })?
658 .map_err(|e| Error::Config {
659 message: format!("hook wait {}: {e}", path.display()),
660 })?;
661
662 let stdout = String::from_utf8_lossy(&output.stdout);
663 let parsed: HookOutput =
664 serde_json::from_str(stdout.trim()).map_err(|e| Error::Config {
665 message: format!("hook output parse {}: {e}", path.display()),
666 })?;
667
668 Ok(parsed.into_hook_result())
669 }
670}
671
672fn expand_tilde(path: &str) -> PathBuf {
674 if let Some(rest) = path.strip_prefix("~/") {
675 if let Some(home) = std::env::var_os("HOME") {
676 return PathBuf::from(home).join(rest);
677 }
678 }
679 PathBuf::from(path)
680}
681
682fn event_names_match(config_name: &str, wire_name: &str) -> bool {
684 config_name.to_lowercase() == wire_name.to_lowercase()
685}
686
687async fn run_prompt_hook(
692 llm: &dyn LlmProvider,
693 prompt_template: &str,
694 input: &HookInput,
695 hook_timeout: Duration,
696) -> HookResult {
697 let args_json = serde_json::to_string(input).unwrap_or_else(|_| "{}".to_string());
698 let prompt = prompt_template.replace("$ARGUMENTS", &args_json);
699
700 let messages = [Message::user(prompt)];
701 let completion_future = llm.complete(&messages, &[]);
702 let completion = match timeout(hook_timeout, completion_future).await {
703 Ok(Ok(c)) => c,
704 Ok(Err(e)) => {
705 tracing::warn!("prompt hook LLM error: {e}");
706 return HookResult::continue_default();
707 }
708 Err(_) => {
709 tracing::warn!("prompt hook timeout");
710 return HookResult::continue_default();
711 }
712 };
713
714 match serde_json::from_str::<HookOutput>(completion.content.trim()) {
715 Ok(output) => output.into_hook_result(),
716 Err(_) => {
717 HookResult::continue_default()
719 }
720 }
721}
722
723pub(crate) async fn run_http_hook(
729 url: &str,
730 input: &HookInput,
731 timeout_secs: u64,
732 headers: Option<&std::collections::HashMap<String, String>>,
733 allowed_env_vars: Option<&[String]>,
734) -> HookResult {
735 let client_result = reqwest::Client::builder()
736 .timeout(Duration::from_secs(timeout_secs))
737 .connect_timeout(Duration::from_secs(timeout_secs))
738 .build();
739
740 let client = match client_result {
741 Ok(c) => c,
742 Err(_) => return HookResult::continue_default(),
743 };
744
745 let mut builder = client.post(url).json(input);
746
747 if let Some(headers_map) = headers {
748 for (k, v) in headers_map {
749 let interpolated = interpolate_env_vars(v, allowed_env_vars);
750 builder = builder.header(k.as_str(), interpolated);
751 }
752 }
753
754 let response = match builder.send().await {
755 Ok(r) => r,
756 Err(e) => {
757 tracing::warn!("http hook request failed: {e}");
758 return HookResult::continue_default();
759 }
760 };
761
762 let body = match response.text().await {
763 Ok(b) => b,
764 Err(e) => {
765 tracing::warn!("http hook response read failed: {e}");
766 return HookResult::continue_default();
767 }
768 };
769
770 match serde_json::from_str::<HookOutput>(body.trim()) {
771 Ok(output) => output.into_hook_result(),
772 Err(e) => {
773 tracing::warn!("http hook response parse failed: {e}");
774 HookResult::continue_default()
775 }
776 }
777}
778
779fn interpolate_env_vars(value: &str, allowed: Option<&[String]>) -> String {
783 let mut result = value.to_string();
784
785 for (pattern, var_name) in find_env_var_refs(&result) {
787 let replacement = if is_allowed(&var_name, allowed) {
788 std::env::var(&var_name).unwrap_or_default()
789 } else {
790 String::new()
791 };
792 result = result.replacen(&pattern, &replacement, 1);
793 }
794 result
795}
796
797fn find_env_var_refs(s: &str) -> Vec<(String, String)> {
799 let mut refs = Vec::new();
800 let bytes = s.as_bytes();
801 let mut i = 0;
802 while i < bytes.len() {
803 if bytes[i] == b'$' {
804 if i + 1 < bytes.len() && bytes[i + 1] == b'{' {
805 if let Some(end) = s[i + 2..].find('}') {
807 let var_name = &s[i + 2..i + 2 + end];
808 refs.push((format!("${{{var_name}}}"), var_name.to_string()));
809 i += 3 + end;
810 continue;
811 }
812 } else {
813 let start = i + 1;
815 let mut end = start;
816 while end < bytes.len()
817 && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_')
818 {
819 end += 1;
820 }
821 if end > start {
822 let var_name = &s[start..end];
823 refs.push((format!("${var_name}"), var_name.to_string()));
824 i = end;
825 continue;
826 }
827 }
828 }
829 i += 1;
830 }
831 refs
832}
833
834fn is_allowed(var: &str, allowed: Option<&[String]>) -> bool {
835 let Some(list) = allowed else { return true };
836 list.iter().any(|a| a == var)
837}
838
839fn is_executable(path: &std::path::Path) -> bool {
847 #[cfg(unix)]
848 {
849 use std::os::unix::fs::PermissionsExt;
850 path.is_file()
851 && std::fs::metadata(path)
852 .map(|m| m.permissions().mode() & 0o111 != 0)
853 .unwrap_or(false)
854 }
855 #[cfg(not(unix))]
856 {
857 let _ = path;
859 false
860 }
861}
862
863#[cfg(test)]
866mod tests {
867 use super::*;
868
869 fn make_tool_input(event: HookEvent, tool: &str, args: serde_json::Value) -> HookInput {
871 HookInput {
872 event,
873 tool_name: Some(tool.to_string()),
874 args: Some(args),
875 mode: "ask".to_string(),
876 content: None,
877 message: None,
878 depth: None,
879 reason: None,
880 error: None,
881 }
882 }
883
884 #[test]
887 fn hook_output_parse_continue() {
888 let json = r#"{"action":"continue"}"#;
889 let out: HookOutput = serde_json::from_str(json).unwrap();
890 assert!(matches!(out.action, Some(JsonAction::Continue)));
891 assert!(out.message.is_none());
892 }
893
894 #[test]
895 fn hook_output_parse_skip() {
896 let json = r#"{"action":"skip","message":"blocked"}"#;
897 let out: HookOutput = serde_json::from_str(json).unwrap();
898 assert!(matches!(out.action, Some(JsonAction::Skip)));
899 assert_eq!(out.message.as_deref(), Some("blocked"));
900 }
901
902 #[test]
903 fn hook_output_parse_error() {
904 let json = r#"{"action":"error","message":"not allowed"}"#;
905 let out: HookOutput = serde_json::from_str(json).unwrap();
906 assert!(matches!(out.action, Some(JsonAction::Error)));
907 assert_eq!(out.message.as_deref(), Some("not allowed"));
908 }
909
910 #[test]
911 fn hook_output_parse_camel_case() {
912 let json = r#"{"action":"continue"}"#;
913 let out: HookOutput = serde_json::from_str(json).unwrap();
914 assert!(matches!(out.action, Some(JsonAction::Continue)));
915
916 let json = r#"{"action":"skip","message":"nope"}"#;
917 let out: HookOutput = serde_json::from_str(json).unwrap();
918 assert!(matches!(out.action, Some(JsonAction::Skip)));
919 }
920
921 #[test]
922 fn hook_output_parse_missing_message() {
923 let json = r#"{"action":"error"}"#;
925 let out: HookOutput = serde_json::from_str(json).unwrap();
926 assert!(matches!(out.action, Some(JsonAction::Error)));
927 assert!(out.message.is_none());
928 }
929
930 #[test]
931 fn hook_output_into_hook_action_continue() {
932 let json = r#"{"action":"continue"}"#;
933 let out: HookOutput = serde_json::from_str(json).unwrap();
934 let result = out.into_hook_result();
935 assert!(matches!(result.action, HookAction::Continue));
936 }
937
938 #[test]
939 fn hook_output_into_hook_action_skip() {
940 let json = r#"{"action":"skip","message":"blocked"}"#;
941 let out: HookOutput = serde_json::from_str(json).unwrap();
942 let result = out.into_hook_result();
943 assert!(matches!(result.action, HookAction::Skip));
944 }
945
946 #[test]
947 fn hook_output_into_hook_action_error_with_message() {
948 let json = r#"{"action":"error","message":"not allowed"}"#;
949 let out: HookOutput = serde_json::from_str(json).unwrap();
950 let result = out.into_hook_result();
951 assert!(matches!(result.action, HookAction::Error(ref msg) if msg == "not allowed"));
952 }
953
954 #[test]
955 fn hook_output_into_hook_action_error_without_message() {
956 let json = r#"{"action":"error"}"#;
957 let out: HookOutput = serde_json::from_str(json).unwrap();
958 let result = out.into_hook_result();
959 assert!(
960 matches!(result.action, HookAction::Error(ref msg) if msg == "external hook blocked")
961 );
962 }
963
964 #[test]
967 fn empty_runner_returns_continue() {
968 let runner = ExternalHookRunner::discover(&[]);
969 assert!(runner.is_empty());
970 assert_eq!(runner.len(), 0);
971 }
972
973 #[test]
974 fn discover_skips_non_executable() {
975 let tmp = tempfile::tempdir().unwrap();
977 let non_exec = tmp.path().join("script.sh");
978 std::fs::write(&non_exec, "echo hello").unwrap();
979 #[cfg(unix)]
981 {
982 use std::os::unix::fs::PermissionsExt;
983 let mut perms = std::fs::metadata(&non_exec).unwrap().permissions();
984 perms.set_mode(0o644);
985 std::fs::set_permissions(&non_exec, perms).unwrap();
986 }
987 let runner = ExternalHookRunner::discover(&[tmp.path().to_path_buf()]);
988 assert!(runner.is_empty());
989 }
990
991 #[test]
992 #[cfg(unix)]
993 fn discover_collects_executable() {
994 use std::os::unix::fs::PermissionsExt;
995 let tmp = tempfile::tempdir().unwrap();
996 let exec = tmp.path().join("hook.sh");
997 std::fs::write(&exec, "#!/bin/sh\necho '{\"action\":\"continue\"}'").unwrap();
998 let mut perms = std::fs::metadata(&exec).unwrap().permissions();
999 perms.set_mode(0o755);
1000 std::fs::set_permissions(&exec, perms).unwrap();
1001 let runner = ExternalHookRunner::discover(&[tmp.path().to_path_buf()]);
1002 assert_eq!(runner.len(), 1);
1003 }
1004
1005 #[test]
1006 fn is_executable_rejects_directory() {
1007 let tmp = tempfile::tempdir().unwrap();
1008 let dir = tmp.path().join("subdir");
1009 std::fs::create_dir(&dir).unwrap();
1010 assert!(!is_executable(&dir));
1012 }
1013
1014 #[test]
1015 fn is_executable_rejects_nonexistent() {
1016 assert!(!is_executable(std::path::Path::new(
1017 "/nonexistent/path/script"
1018 )));
1019 }
1020
1021 #[tokio::test]
1024 #[cfg(unix)]
1025 async fn dispatch_runs_executable_hook_and_returns_decision() {
1026 use std::os::unix::fs::PermissionsExt;
1027 let tmp = tempfile::tempdir().unwrap();
1028 let hook_path = tmp.path().join("my-hook.sh");
1029
1030 let script = r#"#!/bin/sh
1032read -r line
1033echo '{"action":"skip","message":"blocked by test hook"}'
1034"#;
1035 std::fs::write(&hook_path, script).unwrap();
1036 let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
1037 perms.set_mode(0o755);
1038 std::fs::set_permissions(&hook_path, perms).unwrap();
1039
1040 let runner = ExternalHookRunner::discover(&[tmp.path().to_path_buf()]);
1041 assert_eq!(runner.len(), 1);
1042 let input = make_tool_input(
1043 HookEvent::PreToolCall,
1044 "run_shell",
1045 serde_json::json!({"command": "ls"}),
1046 );
1047 let result = runner.dispatch(&input).await;
1048 assert!(matches!(result.action, HookAction::Skip));
1049 }
1050
1051 #[tokio::test]
1052 async fn dispatch_returns_continue_when_no_hooks() {
1053 let runner = ExternalHookRunner::discover(&[]);
1054 let input = make_tool_input(
1055 HookEvent::PreToolCall,
1056 "read_file",
1057 serde_json::json!({"path": "foo.txt"}),
1058 );
1059 let result = runner.dispatch(&input).await;
1060 assert!(matches!(result.action, HookAction::Continue));
1061 }
1062
1063 #[tokio::test]
1064 #[cfg(unix)]
1065 async fn dispatch_treats_timeout_as_continue() {
1066 use std::os::unix::fs::PermissionsExt;
1067 let tmp = tempfile::tempdir().unwrap();
1068 let hook_path = tmp.path().join("hang.sh");
1069
1070 let script = "#!/bin/sh\nsleep 30\n";
1072 std::fs::write(&hook_path, script).unwrap();
1073 let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
1074 perms.set_mode(0o755);
1075 std::fs::set_permissions(&hook_path, perms).unwrap();
1076
1077 let runner = ExternalHookRunner::discover(&[tmp.path().to_path_buf()]);
1078 assert_eq!(runner.len(), 1);
1079 let input = make_tool_input(
1080 HookEvent::PreToolCall,
1081 "run_shell",
1082 serde_json::json!({"command": "ls"}),
1083 );
1084 let result = runner.dispatch(&input).await;
1085 assert!(matches!(result.action, HookAction::Continue));
1087 }
1088
1089 #[tokio::test]
1090 #[cfg(unix)]
1091 async fn dispatch_treats_bad_output_as_continue() {
1092 use std::os::unix::fs::PermissionsExt;
1093 let tmp = tempfile::tempdir().unwrap();
1094 let hook_path = tmp.path().join("bad.sh");
1095
1096 let script = "#!/bin/sh\necho 'not json'\n";
1098 std::fs::write(&hook_path, script).unwrap();
1099 let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
1100 perms.set_mode(0o755);
1101 std::fs::set_permissions(&hook_path, perms).unwrap();
1102
1103 let runner = ExternalHookRunner::discover(&[tmp.path().to_path_buf()]);
1104 assert_eq!(runner.len(), 1);
1105 let input = make_tool_input(
1106 HookEvent::PreToolCall,
1107 "run_shell",
1108 serde_json::json!({"command": "ls"}),
1109 );
1110 let result = runner.dispatch(&input).await;
1111 assert!(matches!(result.action, HookAction::Continue));
1113 }
1114
1115 #[tokio::test]
1116 #[cfg(unix)]
1117 async fn dispatch_short_circuits_on_first_non_continue() {
1118 use std::os::unix::fs::PermissionsExt;
1119 let tmp = tempfile::tempdir().unwrap();
1120
1121 let h1 = tmp.path().join("h1.sh");
1123 let s1 = "#!/bin/sh\nread -r line\necho '{\"action\":\"skip\",\"message\":\"first\"}'\n";
1124 std::fs::write(&h1, s1).unwrap();
1125
1126 let h2 = tmp.path().join("h2.sh");
1128 let s2 = "#!/bin/sh\nread -r line\necho '{\"action\":\"continue\"}'\n";
1129 std::fs::write(&h2, s2).unwrap();
1130
1131 for p in [&h1, &h2] {
1132 let mut perms = std::fs::metadata(p).unwrap().permissions();
1133 perms.set_mode(0o755);
1134 std::fs::set_permissions(p, perms).unwrap();
1135 }
1136
1137 let runner = ExternalHookRunner::discover(&[tmp.path().to_path_buf()]);
1138 assert_eq!(runner.len(), 2);
1139 let input = make_tool_input(
1140 HookEvent::PreToolCall,
1141 "write_file",
1142 serde_json::json!({"path": "test.txt"}),
1143 );
1144 let result = runner.dispatch(&input).await;
1145 assert!(matches!(result.action, HookAction::Skip));
1146 }
1147
1148 #[test]
1151 fn new_hook_events_serialize_camel_case() {
1152 let cases: &[(&str, HookEvent)] = &[
1153 ("postToolCallFailure", HookEvent::PostToolCallFailure),
1154 ("permissionDenied", HookEvent::PermissionDenied),
1155 ("sessionStart", HookEvent::SessionStart),
1156 ("sessionEnd", HookEvent::SessionEnd),
1157 ("userPromptSubmit", HookEvent::UserPromptSubmit),
1158 ("stop", HookEvent::Stop),
1159 ("subagentStart", HookEvent::SubagentStart),
1160 ("subagentStop", HookEvent::SubagentStop),
1161 ("notification", HookEvent::Notification),
1162 ("setup", HookEvent::Setup),
1163 ];
1164 for (expected, event) in cases {
1165 let json = serde_json::to_string(event).unwrap();
1166 assert_eq!(
1167 json,
1168 format!("\"{expected}\""),
1169 "wrong camelCase for {expected}"
1170 );
1171 }
1172 }
1173
1174 #[test]
1175 fn hook_input_optional_fields_absent_when_none() {
1176 let input = HookInput {
1177 event: HookEvent::UserPromptSubmit,
1178 tool_name: None,
1179 args: None,
1180 mode: "ask".to_string(),
1181 content: Some("hello".to_string()),
1182 message: None,
1183 depth: None,
1184 reason: None,
1185 error: None,
1186 };
1187 let json = serde_json::to_string(&input).unwrap();
1188 assert!(
1190 !json.contains("tool_name") && !json.contains("toolName"),
1191 "toolName should be absent"
1192 );
1193 assert!(!json.contains("\"args\""), "args should be absent");
1194 assert!(json.contains("\"hello\""), "content should be present");
1195 }
1196
1197 #[test]
1198 fn hook_input_tool_name_present_for_tool_events() {
1199 let input = make_tool_input(
1200 HookEvent::PreToolCall,
1201 "run_shell",
1202 serde_json::json!({"command": "ls"}),
1203 );
1204 let json = serde_json::to_string(&input).unwrap();
1205 assert!(json.contains("run_shell"));
1206 assert!(json.contains("command"));
1207 }
1208
1209 #[test]
1212 fn hook_output_parses_additional_context() {
1213 let json = r#"{"action":"continue","additionalContext":"extra info"}"#;
1214 let output: HookOutput = serde_json::from_str(json).unwrap();
1215 let result = output.into_hook_result();
1216 assert!(matches!(result.action, HookAction::Continue));
1217 assert_eq!(result.additional_context.as_deref(), Some("extra info"));
1218 }
1219
1220 #[test]
1221 fn hook_output_parses_updated_input() {
1222 let json = r#"{"action":"continue","updatedInput":{"command":"ls -la"}}"#;
1223 let output: HookOutput = serde_json::from_str(json).unwrap();
1224 let result = output.into_hook_result();
1225 assert_eq!(
1226 result.updated_input,
1227 Some(serde_json::json!({"command": "ls -la"}))
1228 );
1229 }
1230
1231 #[test]
1232 fn hook_output_parses_permission_decision_allow() {
1233 let json = r#"{"action":"continue","permissionDecision":"allow","permissionDecisionReason":"safe"}"#;
1234 let output: HookOutput = serde_json::from_str(json).unwrap();
1235 let result = output.into_hook_result();
1236 assert_eq!(result.permission_decision, Some(PermissionDecision::Allow));
1237 assert_eq!(result.permission_decision_reason.as_deref(), Some("safe"));
1238 }
1239
1240 #[test]
1241 fn hook_output_parses_permission_decision_deny() {
1242 let json = r#"{"action":"skip","permissionDecision":"deny"}"#;
1243 let output: HookOutput = serde_json::from_str(json).unwrap();
1244 let result = output.into_hook_result();
1245 assert_eq!(result.permission_decision, Some(PermissionDecision::Deny));
1246 assert!(matches!(result.action, HookAction::Skip));
1247 }
1248
1249 #[test]
1250 fn hook_output_parses_permission_decision_passthrough() {
1251 let json = r#"{"action":"continue","permissionDecision":"passthrough"}"#;
1252 let output: HookOutput = serde_json::from_str(json).unwrap();
1253 let result = output.into_hook_result();
1254 assert_eq!(
1255 result.permission_decision,
1256 Some(PermissionDecision::Passthrough)
1257 );
1258 }
1259
1260 #[test]
1261 fn hook_result_default_is_continue_with_no_extras() {
1262 let result = HookResult::default();
1263 assert!(matches!(result.action, HookAction::Continue));
1264 assert!(result.additional_context.is_none());
1265 assert!(result.updated_input.is_none());
1266 assert!(result.system_message.is_none());
1267 assert!(!result.suppress_output);
1268 assert!(result.permission_decision.is_none());
1269 assert!(result.permission_decision_reason.is_none());
1270 }
1271
1272 #[test]
1273 fn hook_output_system_message_included() {
1274 let json = r#"{"action":"continue","systemMessage":"Please review before proceeding"}"#;
1275 let output: HookOutput = serde_json::from_str(json).unwrap();
1276 let result = output.into_hook_result();
1277 assert_eq!(
1278 result.system_message.as_deref(),
1279 Some("Please review before proceeding")
1280 );
1281 }
1282
1283 #[test]
1284 fn hook_output_suppress_output_default_false() {
1285 let json = r#"{"action":"continue"}"#;
1286 let output: HookOutput = serde_json::from_str(json).unwrap();
1287 let result = output.into_hook_result();
1288 assert!(!result.suppress_output);
1289 }
1290
1291 #[test]
1292 fn hook_output_suppress_output_true() {
1293 let json = r#"{"action":"continue","suppressOutput":true}"#;
1294 let output: HookOutput = serde_json::from_str(json).unwrap();
1295 let result = output.into_hook_result();
1296 assert!(result.suppress_output);
1297 }
1298
1299 fn make_non_tool_input() -> HookInput {
1302 HookInput {
1303 event: HookEvent::UserPromptSubmit,
1304 tool_name: None,
1305 args: None,
1306 mode: "ask".to_string(),
1307 content: Some("hello".to_string()),
1308 message: None,
1309 depth: None,
1310 reason: None,
1311 error: None,
1312 }
1313 }
1314
1315 #[tokio::test]
1316 async fn http_hook_posts_json_input_and_parses_response() {
1317 let mut server = mockito::Server::new_async().await;
1318 let mock = server
1319 .mock("POST", "/hook")
1320 .with_status(200)
1321 .with_header("content-type", "application/json")
1322 .with_body(r#"{"action":"skip","message":"blocked by http hook"}"#)
1323 .create_async()
1324 .await;
1325
1326 let input = make_non_tool_input();
1327 let url = format!("{}/hook", server.url());
1328 let result = run_http_hook(&url, &input, 10, None, None).await;
1329 assert!(matches!(result.action, HookAction::Skip));
1330 mock.assert_async().await;
1331 }
1332
1333 #[tokio::test]
1334 async fn http_hook_parses_continue_response() {
1335 let mut server = mockito::Server::new_async().await;
1336 server
1337 .mock("POST", "/hook")
1338 .with_status(200)
1339 .with_header("content-type", "application/json")
1340 .with_body(r#"{"action":"continue"}"#)
1341 .create_async()
1342 .await;
1343
1344 let input = make_non_tool_input();
1345 let url = format!("{}/hook", server.url());
1346 let result = run_http_hook(&url, &input, 10, None, None).await;
1347 assert!(matches!(result.action, HookAction::Continue));
1348 }
1349
1350 #[tokio::test]
1351 async fn http_hook_connection_error_returns_continue() {
1352 let input = make_non_tool_input();
1354 let result = run_http_hook("http://127.0.0.1:19999/hook", &input, 2, None, None).await;
1355 assert!(matches!(result.action, HookAction::Continue));
1356 }
1357
1358 #[tokio::test]
1359 async fn http_hook_bad_json_response_returns_continue() {
1360 let mut server = mockito::Server::new_async().await;
1361 server
1362 .mock("POST", "/hook")
1363 .with_status(200)
1364 .with_body("not json at all")
1365 .create_async()
1366 .await;
1367
1368 let input = make_non_tool_input();
1369 let url = format!("{}/hook", server.url());
1370 let result = run_http_hook(&url, &input, 10, None, None).await;
1371 assert!(matches!(result.action, HookAction::Continue));
1372 }
1373
1374 #[test]
1375 fn env_var_interpolation_respects_allowlist() {
1376 std::env::set_var("MY_TOKEN", "secret123");
1377 let allowed = vec!["MY_TOKEN".to_string()];
1378 let result = interpolate_env_vars("Bearer $MY_TOKEN", Some(&allowed));
1379 assert_eq!(result, "Bearer secret123");
1380 }
1381
1382 #[test]
1383 fn env_var_interpolation_empty_for_non_allowed() {
1384 std::env::set_var("BLOCKED_VAR", "should-not-appear");
1385 let allowed = vec!["OTHER_VAR".to_string()];
1386 let result = interpolate_env_vars("Bearer $BLOCKED_VAR", Some(&allowed));
1387 assert_eq!(result, "Bearer ");
1388 }
1389
1390 #[test]
1391 fn env_var_interpolation_curly_brace_form() {
1392 std::env::set_var("API_KEY", "mykey");
1393 let allowed = vec!["API_KEY".to_string()];
1394 let result = interpolate_env_vars("key=${API_KEY}", Some(&allowed));
1395 assert_eq!(result, "key=mykey");
1396 }
1397
1398 #[test]
1399 fn env_var_interpolation_no_allowlist_replaces_all() {
1400 std::env::set_var("UNGUARDED", "value");
1401 let result = interpolate_env_vars("$UNGUARDED", None);
1402 assert_eq!(result, "value");
1403 }
1404
1405 #[tokio::test]
1408 async fn prompt_hook_replaces_arguments_placeholder() {
1409 use crate::llm::{Completion, MockProvider};
1410 let captured = Arc::new(std::sync::Mutex::new(String::new()));
1412 let c = captured.clone();
1413
1414 let provider = MockProvider::new(vec![Completion {
1417 content: r#"{"action":"skip","message":"prompt blocked"}"#.to_string(),
1418 ..Default::default()
1419 }]);
1420 drop(c); let input = make_non_tool_input();
1423 let result = run_prompt_hook(
1424 &provider,
1425 "Is this safe? $ARGUMENTS",
1426 &input,
1427 Duration::from_secs(10),
1428 )
1429 .await;
1430 assert!(matches!(result.action, HookAction::Skip));
1431 }
1432
1433 #[tokio::test]
1434 async fn prompt_hook_uses_llm_response_continue() {
1435 use crate::llm::{Completion, MockProvider};
1436 let provider = MockProvider::new(vec![Completion {
1437 content: r#"{"action":"continue"}"#.to_string(),
1438 ..Default::default()
1439 }]);
1440 let input = make_non_tool_input();
1441 let result = run_prompt_hook(
1442 &provider,
1443 "Check: $ARGUMENTS",
1444 &input,
1445 Duration::from_secs(10),
1446 )
1447 .await;
1448 assert!(matches!(result.action, HookAction::Continue));
1449 }
1450
1451 #[tokio::test]
1452 async fn prompt_hook_falls_back_on_non_json_response() {
1453 use crate::llm::{Completion, MockProvider};
1454 let provider = MockProvider::new(vec![Completion {
1455 content: "Sorry, I cannot evaluate this.".to_string(),
1456 ..Default::default()
1457 }]);
1458 let input = make_non_tool_input();
1459 let result = run_prompt_hook(
1460 &provider,
1461 "Is this safe? $ARGUMENTS",
1462 &input,
1463 Duration::from_secs(10),
1464 )
1465 .await;
1466 assert!(matches!(result.action, HookAction::Continue));
1468 }
1469
1470 #[tokio::test]
1471 async fn no_llm_prompt_hook_returns_continue() {
1472 use crate::hooks::config::HooksConfig;
1473 let json = r#"{"PreToolCall":[{"hooks":[{"type":"prompt","prompt":"Is this safe? $ARGUMENTS"}]}]}"#;
1474 let cfg: HooksConfig = serde_json::from_str(json).unwrap();
1475 let runner = ExternalHookRunner::from_config_with_llm(cfg, None);
1477 assert_eq!(runner.len(), 1); let input = make_tool_input(HookEvent::PreToolCall, "run_shell", serde_json::json!({}));
1480 let result = runner.dispatch(&input).await;
1481 assert!(matches!(result.action, HookAction::Continue));
1483 }
1484
1485 #[test]
1488 fn from_config_creates_hooks_from_json() {
1489 use crate::hooks::config::HooksConfig;
1490 let json = r#"{"PreToolCall":[{"matcher":"run_shell","hooks":[{"type":"command","command":"/usr/bin/true"}]}]}"#;
1491 let cfg: HooksConfig = serde_json::from_str(json).unwrap();
1492 let runner = ExternalHookRunner::from_config(cfg);
1493 assert_eq!(runner.len(), 1);
1494 }
1495
1496 #[test]
1497 fn from_config_http_type_is_registered() {
1498 use crate::hooks::config::HooksConfig;
1499 let json = r#"{"PreToolCall":[{"hooks":[{"type":"http","url":"https://example.com"}]}]}"#;
1501 let cfg: HooksConfig = serde_json::from_str(json).unwrap();
1502 let runner = ExternalHookRunner::from_config(cfg);
1503 assert_eq!(runner.len(), 1);
1504 }
1505
1506 #[test]
1507 fn from_config_prompt_without_prompt_field_is_skipped() {
1508 use crate::hooks::config::HooksConfig;
1509 let json = r#"{"PreToolCall":[{"hooks":[{"type":"prompt"}]}]}"#;
1511 let cfg: HooksConfig = serde_json::from_str(json).unwrap();
1512 let runner = ExternalHookRunner::from_config(cfg);
1513 assert_eq!(runner.len(), 0);
1514 }
1515
1516 #[test]
1517 fn from_config_http_without_url_is_skipped() {
1518 use crate::hooks::config::HooksConfig;
1519 let json = r#"{"PreToolCall":[{"hooks":[{"type":"http"}]}]}"#;
1521 let cfg: HooksConfig = serde_json::from_str(json).unwrap();
1522 let runner = ExternalHookRunner::from_config(cfg);
1523 assert_eq!(runner.len(), 0);
1524 }
1525
1526 #[test]
1527 fn from_config_empty_config_gives_empty_runner() {
1528 use crate::hooks::config::HooksConfig;
1529 let runner = ExternalHookRunner::from_config(HooksConfig::default());
1530 assert!(runner.is_empty());
1531 }
1532
1533 #[tokio::test]
1534 #[cfg(unix)]
1535 async fn from_config_respects_matcher_event_filter() {
1536 use crate::hooks::config::HooksConfig;
1537 let tmp = tempfile::tempdir().unwrap();
1538 let hook_path = tmp.path().join("hook.sh");
1539 std::fs::write(
1540 &hook_path,
1541 "#!/bin/sh\nread -r line\necho '{\"action\":\"skip\"}'\n",
1542 )
1543 .unwrap();
1544 {
1545 use std::os::unix::fs::PermissionsExt;
1546 let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
1547 perms.set_mode(0o755);
1548 std::fs::set_permissions(&hook_path, perms).unwrap();
1549 }
1550 let json = format!(
1552 r#"{{"PostToolCall":[{{"matcher":"run_shell","hooks":[{{"type":"command","command":"{}"}}]}}]}}"#,
1553 hook_path.display()
1554 );
1555 let cfg: HooksConfig = serde_json::from_str(&json).unwrap();
1556 let runner = ExternalHookRunner::from_config(cfg);
1557
1558 let pre_input = make_tool_input(HookEvent::PreToolCall, "run_shell", serde_json::json!({}));
1560 let result = runner.dispatch(&pre_input).await;
1561 assert!(
1562 matches!(result.action, HookAction::Continue),
1563 "PreToolCall should not trigger PostToolCall hook"
1564 );
1565
1566 let post_input =
1568 make_tool_input(HookEvent::PostToolCall, "run_shell", serde_json::json!({}));
1569 let result = runner.dispatch(&post_input).await;
1570 assert!(
1571 matches!(result.action, HookAction::Skip),
1572 "PostToolCall should trigger hook"
1573 );
1574 }
1575
1576 #[tokio::test]
1579 #[cfg(unix)]
1580 async fn once_hook_runs_only_first_time() {
1581 use crate::hooks::config::{HookCommand, HookCommandType, HookMatcher, HooksConfig};
1582 let tmp = tempfile::tempdir().unwrap();
1583 let counter_file = tmp.path().join("counter");
1584 let hook_path = tmp.path().join("once_hook.sh");
1585 std::fs::write(&counter_file, "0").unwrap();
1586 let script = format!(
1587 "#!/bin/sh\nread -r _\necho $(( $(cat {0}) + 1 )) > {0}\necho '{{\"action\":\"continue\"}}'\n",
1588 counter_file.display()
1589 );
1590 std::fs::write(&hook_path, &script).unwrap();
1591 {
1592 use std::os::unix::fs::PermissionsExt;
1593 let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
1594 perms.set_mode(0o755);
1595 std::fs::set_permissions(&hook_path, perms).unwrap();
1596 }
1597 let cfg = HooksConfig {
1598 events: std::collections::HashMap::from([(
1599 "PreToolCall".to_string(),
1600 vec![HookMatcher {
1601 matcher: None,
1602 hooks: vec![HookCommand {
1603 r#type: HookCommandType::Command,
1604 command: Some(hook_path.to_string_lossy().to_string()),
1605 once: true,
1606 timeout: 5,
1607 ..Default::default()
1608 }],
1609 }],
1610 )]),
1611 };
1612 let runner = ExternalHookRunner::from_config(cfg);
1613 let input = make_tool_input(HookEvent::PreToolCall, "run_shell", serde_json::json!({}));
1614 runner.dispatch(&input).await;
1615 runner.dispatch(&input).await;
1616 let count = std::fs::read_to_string(&counter_file)
1618 .unwrap()
1619 .trim()
1620 .to_string();
1621 assert_eq!(count, "1", "once hook should have run exactly once");
1622 }
1623
1624 #[tokio::test]
1625 #[cfg(unix)]
1626 async fn async_hook_returns_continue_immediately() {
1627 use crate::hooks::config::{HookCommand, HookCommandType, HookMatcher, HooksConfig};
1628 let tmp = tempfile::tempdir().unwrap();
1629 let hook_path = tmp.path().join("slow_hook.sh");
1630 std::fs::write(
1632 &hook_path,
1633 "#!/bin/sh\nread -r _\nsleep 10\necho '{\"action\":\"skip\"}'\n",
1634 )
1635 .unwrap();
1636 {
1637 use std::os::unix::fs::PermissionsExt;
1638 let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
1639 perms.set_mode(0o755);
1640 std::fs::set_permissions(&hook_path, perms).unwrap();
1641 }
1642 let cfg = HooksConfig {
1643 events: std::collections::HashMap::from([(
1644 "PreToolCall".to_string(),
1645 vec![HookMatcher {
1646 matcher: None,
1647 hooks: vec![HookCommand {
1648 r#type: HookCommandType::Command,
1649 command: Some(hook_path.to_string_lossy().to_string()),
1650 r#async: true,
1651 timeout: 5,
1652 ..Default::default()
1653 }],
1654 }],
1655 )]),
1656 };
1657 let runner = ExternalHookRunner::from_config(cfg);
1658 let input = make_tool_input(HookEvent::PreToolCall, "run_shell", serde_json::json!({}));
1659 let start = std::time::Instant::now();
1660 let result = runner.dispatch(&input).await;
1661 let elapsed = start.elapsed();
1662 assert!(
1664 elapsed.as_secs() < 1,
1665 "async hook blocked dispatch: {elapsed:?}"
1666 );
1667 assert!(matches!(result.action, HookAction::Continue));
1669 }
1670
1671 #[tokio::test]
1672 #[cfg(unix)]
1673 async fn async_rewake_exit2_triggers_cancel() {
1674 use crate::hooks::config::{HookCommand, HookCommandType, HookMatcher, HooksConfig};
1675 let tmp = tempfile::tempdir().unwrap();
1676 let hook_path = tmp.path().join("exit2.sh");
1677 std::fs::write(&hook_path, "#!/bin/sh\nread -r _\nexit 2\n").unwrap();
1679 {
1680 use std::os::unix::fs::PermissionsExt;
1681 let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
1682 perms.set_mode(0o755);
1683 std::fs::set_permissions(&hook_path, perms).unwrap();
1684 }
1685 let token = CancellationToken::new();
1686 let child_token = token.clone();
1687 let cfg = HooksConfig {
1688 events: std::collections::HashMap::from([(
1689 "PreToolCall".to_string(),
1690 vec![HookMatcher {
1691 matcher: None,
1692 hooks: vec![HookCommand {
1693 r#type: HookCommandType::Command,
1694 command: Some(hook_path.to_string_lossy().to_string()),
1695 async_rewake: true,
1696 timeout: 5,
1697 ..Default::default()
1698 }],
1699 }],
1700 )]),
1701 };
1702 let runner = ExternalHookRunner::from_config(cfg).with_cancel_token(child_token);
1703 let input = make_tool_input(HookEvent::PreToolCall, "run_shell", serde_json::json!({}));
1704 runner.dispatch(&input).await;
1705 let deadline = std::time::Instant::now() + Duration::from_secs(5);
1708 while !token.is_cancelled() {
1709 if std::time::Instant::now() >= deadline {
1710 panic!("timed out waiting for asyncRewake cancellation (exit 2 hook)");
1711 }
1712 tokio::time::sleep(Duration::from_millis(50)).await;
1713 }
1714 assert!(
1715 token.is_cancelled(),
1716 "exit code 2 should have triggered cancellation"
1717 );
1718 }
1719
1720 #[tokio::test]
1721 #[cfg(unix)]
1722 async fn async_rewake_exit0_no_cancel() {
1723 use crate::hooks::config::{HookCommand, HookCommandType, HookMatcher, HooksConfig};
1724 let tmp = tempfile::tempdir().unwrap();
1725 let hook_path = tmp.path().join("exit0.sh");
1726 std::fs::write(&hook_path, "#!/bin/sh\nread -r _\nexit 0\n").unwrap();
1728 {
1729 use std::os::unix::fs::PermissionsExt;
1730 let mut perms = std::fs::metadata(&hook_path).unwrap().permissions();
1731 perms.set_mode(0o755);
1732 std::fs::set_permissions(&hook_path, perms).unwrap();
1733 }
1734 let token = CancellationToken::new();
1735 let child_token = token.clone();
1736 let cfg = HooksConfig {
1737 events: std::collections::HashMap::from([(
1738 "PreToolCall".to_string(),
1739 vec![HookMatcher {
1740 matcher: None,
1741 hooks: vec![HookCommand {
1742 r#type: HookCommandType::Command,
1743 command: Some(hook_path.to_string_lossy().to_string()),
1744 async_rewake: true,
1745 timeout: 5,
1746 ..Default::default()
1747 }],
1748 }],
1749 )]),
1750 };
1751 let runner = ExternalHookRunner::from_config(cfg).with_cancel_token(child_token);
1752 let input = make_tool_input(HookEvent::PreToolCall, "run_shell", serde_json::json!({}));
1753 runner.dispatch(&input).await;
1754 tokio::time::sleep(Duration::from_millis(300)).await;
1755 assert!(
1756 !token.is_cancelled(),
1757 "exit code 0 should NOT trigger cancellation"
1758 );
1759 }
1760}