1use std::time::Duration;
2
3use tokio::io::AsyncWriteExt;
4use tokio::process::Command;
5
6use super::config::matching_hooks;
7use super::{
8 HookConfig, HookContext, HookEvent, PreHookResult, UserPromptHookResult,
9 UserPromptSubmitOutput, UserPromptSubmitPayload,
10};
11
12fn push_context(acc: &mut String, extra: &str) {
16 if !acc.is_empty() {
17 acc.push_str("\n\n");
18 }
19 acc.push_str(extra);
20}
21
22pub struct HookExecutor {
24 hooks: Vec<HookConfig>,
25}
26
27impl HookExecutor {
28 pub fn new(hooks: Vec<HookConfig>) -> Self {
30 Self { hooks }
31 }
32
33 pub fn empty() -> Self {
35 Self { hooks: vec![] }
36 }
37
38 pub fn has_hooks(&self) -> bool {
40 !self.hooks.is_empty()
41 }
42
43 pub async fn run_pre_tool_use(
50 &self,
51 tool_name: &str,
52 ctx: &HookContext,
53 ) -> PreHookResult {
54 let matched = matching_hooks(&self.hooks, HookEvent::PreToolUse, Some(tool_name));
55 if matched.is_empty() {
56 return PreHookResult::Allow;
57 }
58
59 let mut result = PreHookResult::Allow;
60
61 for hook in matched {
62 match self.execute_hook(hook, ctx).await {
63 Ok(stdout) => {
64 match serde_json::from_str::<PreHookResult>(&stdout) {
65 Ok(parsed) => match &parsed {
66 PreHookResult::Block { .. } => return parsed,
67 PreHookResult::Modify { .. } => result = parsed,
68 PreHookResult::Allow => {}
69 },
70 Err(_) => {}
72 }
73 }
74 Err(_) => {}
76 }
77 }
78
79 result
80 }
81
82 pub async fn run_post_tool_use(&self, tool_name: &str, ctx: &HookContext) {
86 let matched = matching_hooks(&self.hooks, HookEvent::PostToolUse, Some(tool_name));
87 for hook in matched {
88 let _ = self.execute_hook(hook, ctx).await;
89 }
90 }
91
92 pub async fn run_user_prompt_submit(
107 &self,
108 prompt: &str,
109 session_id: &str,
110 cwd: &str,
111 ) -> UserPromptHookResult {
112 let matched = matching_hooks(&self.hooks, HookEvent::UserPromptSubmit, None);
113 if matched.is_empty() {
114 return UserPromptHookResult::Continue;
115 }
116
117 let payload = UserPromptSubmitPayload {
118 session_id: session_id.to_string(),
119 hook_event_name: "UserPromptSubmit".to_string(),
120 prompt: prompt.to_string(),
121 cwd: cwd.to_string(),
122 };
123 let payload_json = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".into());
124
125 let mut injected = String::new();
126 for hook in matched {
127 match self.execute_hook_with_stdin(hook, &payload_json).await {
128 Ok((exit_ok, stdout, stderr)) => {
129 if !exit_ok {
130 let reason = if !stderr.trim().is_empty() {
134 stderr.trim().to_string()
135 } else if !stdout.trim().is_empty() {
136 stdout.trim().to_string()
137 } else {
138 "user prompt blocked by hook".into()
139 };
140 return UserPromptHookResult::Block(reason);
141 }
142 let last_line = stdout.lines().rev().find(|l| !l.trim().is_empty());
154 let json_action = last_line.and_then(|l| {
155 serde_json::from_str::<UserPromptSubmitOutput>(l.trim()).ok()
156 });
157 if let Some(parsed) = json_action {
158 if matches!(parsed.decision.as_deref(), Some("block")) {
159 let reason = parsed
160 .reason
161 .unwrap_or_else(|| "user prompt blocked by hook".into());
162 return UserPromptHookResult::Block(reason);
163 }
164 if let Some(ctx) = parsed
165 .hook_specific_output
166 .and_then(|o| o.additional_context)
167 {
168 push_context(&mut injected, &ctx);
169 continue;
170 }
171 continue;
173 }
174 let trimmed = stdout.trim();
177 if !trimmed.is_empty() {
178 push_context(&mut injected, trimmed);
179 }
180 }
181 Err(_) => {
182 }
186 }
187 }
188
189 if injected.is_empty() {
190 UserPromptHookResult::Continue
191 } else {
192 UserPromptHookResult::Inject(injected)
193 }
194 }
195
196 async fn execute_hook_with_stdin(
200 &self,
201 hook: &HookConfig,
202 payload_json: &str,
203 ) -> anyhow::Result<(bool, String, String)> {
204 use std::process::Stdio;
205
206 let mut cmd = Command::new("sh");
207 cmd.arg("-c")
208 .arg(&hook.command)
209 .stdin(Stdio::piped())
210 .stdout(Stdio::piped())
211 .stderr(Stdio::piped());
212 if let Some(ref root) = hook.plugin_root {
213 let s = root.as_os_str();
214 cmd.env("CLAUDE_PLUGIN_ROOT", s);
215 cmd.env("ATOMCODE_PLUGIN_ROOT", s);
216 }
217 crate::process_utils::suppress_console_window(&mut cmd);
218
219 let timeout = Duration::from_millis(hook.timeout_ms);
220
221 let fut = async {
222 let mut child = cmd.spawn()?;
223 if let Some(mut stdin) = child.stdin.take() {
224 stdin.write_all(payload_json.as_bytes()).await?;
225 stdin.shutdown().await.ok();
229 drop(stdin);
230 }
231 let output = child.wait_with_output().await?;
232 anyhow::Ok((
233 output.status.success(),
234 String::from_utf8_lossy(&output.stdout).to_string(),
235 String::from_utf8_lossy(&output.stderr).to_string(),
236 ))
237 };
238
239 Ok(tokio::time::timeout(timeout, fut).await??)
240 }
241
242 pub async fn run_session_event(&self, event: HookEvent, ctx: &HookContext) {
244 let matched = matching_hooks(&self.hooks, event, None);
245 for hook in matched {
246 let _ = self.execute_hook(hook, ctx).await;
247 }
248 }
249
250 pub async fn execute_hook(
259 &self,
260 hook: &HookConfig,
261 ctx: &HookContext,
262 ) -> anyhow::Result<String> {
263 let ctx_json =
264 serde_json::to_string(ctx).unwrap_or_else(|_| "{}".to_string());
265
266 let mut cmd = Command::new("sh");
267 cmd.arg("-c")
268 .arg(&hook.command)
269 .env("ATOMCODE_HOOK_EVENT", &ctx.event)
270 .env("ATOMCODE_HOOK_CONTEXT", &ctx_json);
271
272 if let Some(ref name) = ctx.tool_name {
273 cmd.env("ATOMCODE_TOOL_NAME", name);
274 }
275 if let Some(ref root) = hook.plugin_root {
276 let s = root.as_os_str();
280 cmd.env("CLAUDE_PLUGIN_ROOT", s);
281 cmd.env("ATOMCODE_PLUGIN_ROOT", s);
282 }
283
284 crate::process_utils::suppress_console_window(&mut cmd);
285
286 let timeout = Duration::from_millis(hook.timeout_ms);
287
288 let output = tokio::time::timeout(timeout, cmd.output()).await??;
289
290 if !output.status.success() {
291 anyhow::bail!(
292 "hook command exited with status {}",
293 output.status.code().unwrap_or(-1)
294 );
295 }
296
297 Ok(String::from_utf8_lossy(&output.stdout).to_string())
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304 use serde_json::json;
305
306 fn test_ctx() -> HookContext {
307 HookContext {
308 event: "pre_tool_use".into(),
309 tool_name: Some("bash".into()),
310 tool_args: Some(json!({"command": "ls"})),
311 tool_result: None,
312 tool_success: None,
313 session_id: "test-session".into(),
314 working_dir: "/tmp".into(),
315 }
316 }
317
318 fn make_hook(event: HookEvent, matcher: Option<&str>, cmd: &str) -> HookConfig {
319 HookConfig {
320 event,
321 matcher: matcher.map(String::from),
322 command: cmd.to_string(),
323 timeout_ms: 10_000,
324 plugin_root: None,
325 }
326 }
327
328 #[tokio::test]
331 async fn empty_executor_allows() {
332 let exec = HookExecutor::empty();
333 assert!(!exec.has_hooks());
334 let result = exec.run_pre_tool_use("bash", &test_ctx()).await;
335 assert_eq!(result, PreHookResult::Allow);
336 }
337
338 #[tokio::test]
341 async fn hook_returning_allow_json() {
342 let hook = make_hook(
343 HookEvent::PreToolUse,
344 Some("bash"),
345 r#"echo '{"action":"allow"}'"#,
346 );
347 let exec = HookExecutor::new(vec![hook]);
348 let result = exec.run_pre_tool_use("bash", &test_ctx()).await;
349 assert_eq!(result, PreHookResult::Allow);
350 }
351
352 #[tokio::test]
353 async fn hook_returning_block_json() {
354 let hook = make_hook(
355 HookEvent::PreToolUse,
356 Some("bash"),
357 r#"echo '{"action":"block","reason":"dangerous"}'"#,
358 );
359 let exec = HookExecutor::new(vec![hook]);
360 let result = exec.run_pre_tool_use("bash", &test_ctx()).await;
361 assert_eq!(
362 result,
363 PreHookResult::Block {
364 reason: "dangerous".into()
365 }
366 );
367 }
368
369 #[tokio::test]
370 async fn hook_returning_non_json_allows() {
371 let hook = make_hook(
372 HookEvent::PreToolUse,
373 Some("bash"),
374 "echo 'not json at all'",
375 );
376 let exec = HookExecutor::new(vec![hook]);
377 let result = exec.run_pre_tool_use("bash", &test_ctx()).await;
378 assert_eq!(result, PreHookResult::Allow);
379 }
380
381 #[tokio::test]
384 async fn hook_timeout_degrades_to_allow() {
385 let mut hook = make_hook(
386 HookEvent::PreToolUse,
387 Some("bash"),
388 "sleep 10",
389 );
390 hook.timeout_ms = 100; let exec = HookExecutor::new(vec![hook]);
392 let result = exec.run_pre_tool_use("bash", &test_ctx()).await;
393 assert_eq!(result, PreHookResult::Allow);
394 }
395
396 #[tokio::test]
399 async fn user_prompt_no_hooks_returns_continue() {
400 let exec = HookExecutor::empty();
401 let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
402 assert_eq!(r, UserPromptHookResult::Continue);
403 }
404
405 #[tokio::test]
406 async fn user_prompt_plain_stdout_injects_context() {
407 let hook = make_hook(
408 HookEvent::UserPromptSubmit,
409 None,
410 "echo extra-info",
411 );
412 let exec = HookExecutor::new(vec![hook]);
413 let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
414 assert_eq!(r, UserPromptHookResult::Inject("extra-info".into()));
415 }
416
417 #[tokio::test]
418 async fn user_prompt_decision_block_blocks() {
419 let hook = make_hook(
420 HookEvent::UserPromptSubmit,
421 None,
422 r#"echo '{"decision":"block","reason":"nope"}'"#,
423 );
424 let exec = HookExecutor::new(vec![hook]);
425 let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
426 assert_eq!(r, UserPromptHookResult::Block("nope".into()));
427 }
428
429 #[tokio::test]
430 async fn user_prompt_hook_specific_output_injects() {
431 let hook = make_hook(
432 HookEvent::UserPromptSubmit,
433 None,
434 r#"echo '{"hookSpecificOutput":{"additionalContext":"ctx-bag"}}'"#,
435 );
436 let exec = HookExecutor::new(vec![hook]);
437 let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
438 assert_eq!(r, UserPromptHookResult::Inject("ctx-bag".into()));
439 }
440
441 #[tokio::test]
442 async fn user_prompt_nonzero_exit_blocks_with_stderr() {
443 let hook = make_hook(
444 HookEvent::UserPromptSubmit,
445 None,
446 "echo bad >&2; exit 1",
447 );
448 let exec = HookExecutor::new(vec![hook]);
449 let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
450 assert_eq!(r, UserPromptHookResult::Block("bad".into()));
451 }
452
453 #[tokio::test]
458 async fn user_prompt_block_after_debug_noise_still_blocks() {
459 let hook = make_hook(
460 HookEvent::UserPromptSubmit,
461 None,
462 r#"echo 'debug line 1'; echo 'debug line 2'; echo '{"decision":"block","reason":"final"}'"#,
463 );
464 let exec = HookExecutor::new(vec![hook]);
465 let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
466 assert_eq!(r, UserPromptHookResult::Block("final".into()));
467 }
468
469 #[tokio::test]
473 async fn user_prompt_plugin_root_with_spaces_via_env() {
474 let mut hook = make_hook(
478 HookEvent::UserPromptSubmit,
479 None,
480 r#"printf '%s' "$CLAUDE_PLUGIN_ROOT""#,
481 );
482 hook.plugin_root = Some(std::path::PathBuf::from("/opt/has space/x"));
483 let exec = HookExecutor::new(vec![hook]);
484 let r = exec.run_user_prompt_submit("hi", "s", "/tmp").await;
485 assert_eq!(r, UserPromptHookResult::Inject("/opt/has space/x".into()));
486 }
487
488 #[tokio::test]
489 async fn user_prompt_payload_reaches_stdin() {
490 let hook = make_hook(
493 HookEvent::UserPromptSubmit,
494 None,
495 r#"python3 -c 'import json,sys;d=json.load(sys.stdin);print(d["prompt"])'"#,
496 );
497 let exec = HookExecutor::new(vec![hook]);
498 let r = exec
499 .run_user_prompt_submit("ping-payload", "sess", "/tmp")
500 .await;
501 assert_eq!(r, UserPromptHookResult::Inject("ping-payload".into()));
502 }
503
504 #[tokio::test]
505 async fn hook_crash_degrades_to_allow() {
506 let hook = make_hook(
507 HookEvent::PreToolUse,
508 Some("bash"),
509 "exit 1",
510 );
511 let exec = HookExecutor::new(vec![hook]);
512 let result = exec.run_pre_tool_use("bash", &test_ctx()).await;
513 assert_eq!(result, PreHookResult::Allow);
514 }
515
516 #[tokio::test]
519 async fn post_tool_use_fire_and_forget() {
520 let hook = make_hook(
521 HookEvent::PostToolUse,
522 Some("bash"),
523 "echo done",
524 );
525 let exec = HookExecutor::new(vec![hook]);
526 exec.run_post_tool_use("bash", &test_ctx()).await;
528 }
529
530 #[tokio::test]
533 async fn matcher_filters_correctly() {
534 let hook = make_hook(
535 HookEvent::PreToolUse,
536 Some("bash"),
537 r#"echo '{"action":"block","reason":"bash only"}'"#,
538 );
539 let exec = HookExecutor::new(vec![hook]);
540
541 let result = exec.run_pre_tool_use("bash", &test_ctx()).await;
543 assert_eq!(
544 result,
545 PreHookResult::Block {
546 reason: "bash only".into()
547 }
548 );
549
550 let result = exec.run_pre_tool_use("grep", &test_ctx()).await;
552 assert_eq!(result, PreHookResult::Allow);
553 }
554
555 #[tokio::test]
558 async fn hook_receives_env_vars() {
559 let hook = make_hook(
561 HookEvent::PreToolUse,
562 Some("bash"),
563 r#"printf '{"event":"%s","tool":"%s","has_ctx":"%s"}' "$ATOMCODE_HOOK_EVENT" "$ATOMCODE_TOOL_NAME" "$(test -n "$ATOMCODE_HOOK_CONTEXT" && echo yes || echo no)""#,
564 );
565 let exec = HookExecutor::new(vec![hook]);
566 let ctx = test_ctx();
567
568 let stdout = exec.execute_hook(&exec.hooks[0], &ctx).await.unwrap();
571 let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
572
573 assert_eq!(parsed["event"], "pre_tool_use");
574 assert_eq!(parsed["tool"], "bash");
575 assert_eq!(parsed["has_ctx"], "yes");
576 }
577}