1use std::ffi::OsStr;
2use std::path::Path;
3use std::process::Command;
4
5use serde_json::json;
6
7use crate::{PluginError, PluginHooks, PluginRegistry};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum HookEvent {
11 PreToolUse,
12 PostToolUse,
13 PostToolUseFailure,
14}
15
16impl HookEvent {
17 fn as_str(self) -> &'static str {
18 match self {
19 Self::PreToolUse => "PreToolUse",
20 Self::PostToolUse => "PostToolUse",
21 Self::PostToolUseFailure => "PostToolUseFailure",
22 }
23 }
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct HookRunResult {
28 denied: bool,
29 failed: bool,
30 messages: Vec<String>,
31}
32
33impl HookRunResult {
34 #[must_use]
35 pub fn allow(messages: Vec<String>) -> Self {
36 Self {
37 denied: false,
38 failed: false,
39 messages,
40 }
41 }
42
43 #[must_use]
44 pub fn is_denied(&self) -> bool {
45 self.denied
46 }
47
48 #[must_use]
49 pub fn is_failed(&self) -> bool {
50 self.failed
51 }
52
53 #[must_use]
54 pub fn messages(&self) -> &[String] {
55 &self.messages
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Default)]
60pub struct HookRunner {
61 hooks: PluginHooks,
62}
63
64impl HookRunner {
65 #[must_use]
66 pub fn new(hooks: PluginHooks) -> Self {
67 Self { hooks }
68 }
69
70 pub fn from_registry(plugin_registry: &PluginRegistry) -> Result<Self, PluginError> {
71 Ok(Self::new(plugin_registry.aggregated_hooks()?))
72 }
73
74 #[must_use]
75 pub fn run_pre_tool_use(&self, tool_name: &str, tool_input: &str) -> HookRunResult {
76 Self::run_commands(
77 HookEvent::PreToolUse,
78 &self.hooks.pre_tool_use,
79 tool_name,
80 tool_input,
81 None,
82 false,
83 )
84 }
85
86 #[must_use]
87 pub fn run_post_tool_use(
88 &self,
89 tool_name: &str,
90 tool_input: &str,
91 tool_output: &str,
92 is_error: bool,
93 ) -> HookRunResult {
94 Self::run_commands(
95 HookEvent::PostToolUse,
96 &self.hooks.post_tool_use,
97 tool_name,
98 tool_input,
99 Some(tool_output),
100 is_error,
101 )
102 }
103
104 #[must_use]
105 pub fn run_post_tool_use_failure(
106 &self,
107 tool_name: &str,
108 tool_input: &str,
109 tool_error: &str,
110 ) -> HookRunResult {
111 Self::run_commands(
112 HookEvent::PostToolUseFailure,
113 &self.hooks.post_tool_use_failure,
114 tool_name,
115 tool_input,
116 Some(tool_error),
117 true,
118 )
119 }
120
121 fn run_commands(
122 event: HookEvent,
123 commands: &[String],
124 tool_name: &str,
125 tool_input: &str,
126 tool_output: Option<&str>,
127 is_error: bool,
128 ) -> HookRunResult {
129 if commands.is_empty() {
130 return HookRunResult::allow(Vec::new());
131 }
132
133 let payload = hook_payload(event, tool_name, tool_input, tool_output, is_error).to_string();
134
135 let mut messages = Vec::new();
136
137 for command in commands {
138 match Self::run_command(
139 command,
140 event,
141 tool_name,
142 tool_input,
143 tool_output,
144 is_error,
145 &payload,
146 ) {
147 HookCommandOutcome::Allow { message } => {
148 if let Some(message) = message {
149 messages.push(message);
150 }
151 }
152 HookCommandOutcome::Deny { message } => {
153 messages.push(message.unwrap_or_else(|| {
154 format!("{} hook denied tool `{tool_name}`", event.as_str())
155 }));
156 return HookRunResult {
157 denied: true,
158 failed: false,
159 messages,
160 };
161 }
162 HookCommandOutcome::Failed { message } => {
163 messages.push(message);
164 return HookRunResult {
165 denied: false,
166 failed: true,
167 messages,
168 };
169 }
170 }
171 }
172
173 HookRunResult::allow(messages)
174 }
175
176 #[allow(clippy::too_many_arguments)]
177 fn run_command(
178 command: &str,
179 event: HookEvent,
180 tool_name: &str,
181 tool_input: &str,
182 tool_output: Option<&str>,
183 is_error: bool,
184 payload: &str,
185 ) -> HookCommandOutcome {
186 let mut child = shell_command(command);
187 child.stdin(std::process::Stdio::piped());
188 child.stdout(std::process::Stdio::piped());
189 child.stderr(std::process::Stdio::piped());
190 child.env("HOOK_EVENT", event.as_str());
191 child.env("HOOK_TOOL_NAME", tool_name);
192 child.env("HOOK_TOOL_INPUT", tool_input);
193 child.env("HOOK_TOOL_IS_ERROR", if is_error { "1" } else { "0" });
194 if let Some(tool_output) = tool_output {
195 child.env("HOOK_TOOL_OUTPUT", tool_output);
196 }
197
198 match child.output_with_stdin(payload.as_bytes()) {
199 Ok(output) => {
200 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
201 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
202 let message = (!stdout.is_empty()).then_some(stdout);
203 match output.status.code() {
204 Some(0) => HookCommandOutcome::Allow { message },
205 Some(2) => HookCommandOutcome::Deny { message },
206 Some(code) => HookCommandOutcome::Failed {
207 message: format_hook_warning(
208 command,
209 code,
210 message.as_deref(),
211 stderr.as_str(),
212 ),
213 },
214 None => HookCommandOutcome::Failed {
215 message: format!(
216 "{} hook `{command}` terminated by signal while handling `{tool_name}`",
217 event.as_str()
218 ),
219 },
220 }
221 }
222 Err(error) => HookCommandOutcome::Failed {
223 message: format!(
224 "{} hook `{command}` failed to start for `{tool_name}`: {error}",
225 event.as_str()
226 ),
227 },
228 }
229 }
230}
231
232enum HookCommandOutcome {
233 Allow { message: Option<String> },
234 Deny { message: Option<String> },
235 Failed { message: String },
236}
237
238fn hook_payload(
239 event: HookEvent,
240 tool_name: &str,
241 tool_input: &str,
242 tool_output: Option<&str>,
243 is_error: bool,
244) -> serde_json::Value {
245 match event {
246 HookEvent::PostToolUseFailure => json!({
247 "hook_event_name": event.as_str(),
248 "tool_name": tool_name,
249 "tool_input": parse_tool_input(tool_input),
250 "tool_input_json": tool_input,
251 "tool_error": tool_output,
252 "tool_result_is_error": true,
253 }),
254 _ => json!({
255 "hook_event_name": event.as_str(),
256 "tool_name": tool_name,
257 "tool_input": parse_tool_input(tool_input),
258 "tool_input_json": tool_input,
259 "tool_output": tool_output,
260 "tool_result_is_error": is_error,
261 }),
262 }
263}
264
265fn parse_tool_input(tool_input: &str) -> serde_json::Value {
266 serde_json::from_str(tool_input).unwrap_or_else(|_| json!({ "raw": tool_input }))
267}
268
269fn format_hook_warning(command: &str, code: i32, stdout: Option<&str>, stderr: &str) -> String {
270 let mut message = format!("Hook `{command}` exited with status {code}");
271 if let Some(stdout) = stdout.filter(|stdout| !stdout.is_empty()) {
272 message.push_str(": ");
273 message.push_str(stdout);
274 } else if !stderr.is_empty() {
275 message.push_str(": ");
276 message.push_str(stderr);
277 }
278 message
279}
280
281fn shell_command(command: &str) -> CommandWithStdin {
282 #[cfg(windows)]
283 let command_builder = {
284 let mut command_builder = Command::new("cmd");
285 command_builder.arg("/C").arg(command);
286 CommandWithStdin::new(command_builder)
287 };
288
289 #[cfg(not(windows))]
290 let command_builder = if Path::new(command).exists() {
291 let mut command_builder = Command::new("sh");
292 command_builder.arg(command);
293 CommandWithStdin::new(command_builder)
294 } else {
295 let mut command_builder = Command::new("sh");
296 command_builder.arg("-lc").arg(command);
297 CommandWithStdin::new(command_builder)
298 };
299
300 command_builder
301}
302
303struct CommandWithStdin {
304 command: Command,
305}
306
307impl CommandWithStdin {
308 fn new(command: Command) -> Self {
309 Self { command }
310 }
311
312 fn stdin(&mut self, cfg: std::process::Stdio) -> &mut Self {
313 self.command.stdin(cfg);
314 self
315 }
316
317 fn stdout(&mut self, cfg: std::process::Stdio) -> &mut Self {
318 self.command.stdout(cfg);
319 self
320 }
321
322 fn stderr(&mut self, cfg: std::process::Stdio) -> &mut Self {
323 self.command.stderr(cfg);
324 self
325 }
326
327 fn env<K, V>(&mut self, key: K, value: V) -> &mut Self
328 where
329 K: AsRef<OsStr>,
330 V: AsRef<OsStr>,
331 {
332 self.command.env(key, value);
333 self
334 }
335
336 fn output_with_stdin(&mut self, stdin: &[u8]) -> std::io::Result<std::process::Output> {
337 let mut child = self.command.spawn()?;
338 if let Some(mut child_stdin) = child.stdin.take() {
339 use std::io::Write as _;
340 match child_stdin.write_all(stdin) {
358 Ok(()) => {}
359 Err(error) if error.kind() == std::io::ErrorKind::BrokenPipe => {}
360 Err(error) => return Err(error),
361 }
362 }
363 child.wait_with_output()
364 }
365}
366
367#[cfg(test)]
368mod tests {
369 use super::{HookRunResult, HookRunner};
370 use crate::{PluginManager, PluginManagerConfig};
371 use std::fs;
372 use std::path::{Path, PathBuf};
373 use std::time::{SystemTime, UNIX_EPOCH};
374
375 fn temp_dir(label: &str) -> PathBuf {
376 let nanos = SystemTime::now()
377 .duration_since(UNIX_EPOCH)
378 .expect("time should be after epoch")
379 .as_nanos();
380 std::env::temp_dir().join(format!("plugins-hook-runner-{label}-{nanos}"))
381 }
382
383 fn make_executable(path: &Path) {
384 #[cfg(unix)]
385 {
386 use std::os::unix::fs::PermissionsExt;
387 let perms = fs::Permissions::from_mode(0o755);
388 fs::set_permissions(path, perms)
389 .unwrap_or_else(|e| panic!("chmod +x {}: {e}", path.display()));
390 }
391 #[cfg(not(unix))]
392 let _ = path;
393 }
394
395 fn write_hook_plugin(
396 root: &Path,
397 name: &str,
398 pre_message: &str,
399 post_message: &str,
400 failure_message: &str,
401 ) {
402 fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
403 fs::create_dir_all(root.join("hooks")).expect("hooks dir");
404
405 let pre_path = root.join("hooks").join("pre.sh");
406 fs::write(
407 &pre_path,
408 format!("#!/bin/sh\nprintf '%s\\n' '{pre_message}'\n"),
409 )
410 .expect("write pre hook");
411 make_executable(&pre_path);
412
413 let post_path = root.join("hooks").join("post.sh");
414 fs::write(
415 &post_path,
416 format!("#!/bin/sh\nprintf '%s\\n' '{post_message}'\n"),
417 )
418 .expect("write post hook");
419 make_executable(&post_path);
420
421 let failure_path = root.join("hooks").join("failure.sh");
422 fs::write(
423 &failure_path,
424 format!("#!/bin/sh\nprintf '%s\\n' '{failure_message}'\n"),
425 )
426 .expect("write failure hook");
427 make_executable(&failure_path);
428 fs::write(
429 root.join(".claude-plugin").join("plugin.json"),
430 format!(
431 "{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"hook plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre.sh\"],\n \"PostToolUse\": [\"./hooks/post.sh\"],\n \"PostToolUseFailure\": [\"./hooks/failure.sh\"]\n }}\n}}"
432 ),
433 )
434 .expect("write plugin manifest");
435 }
436
437 #[test]
438 fn collects_and_runs_hooks_from_enabled_plugins() {
439 let config_home = temp_dir("config");
441 let first_source_root = temp_dir("source-a");
442 let second_source_root = temp_dir("source-b");
443 write_hook_plugin(
444 &first_source_root,
445 "first",
446 "plugin pre one",
447 "plugin post one",
448 "plugin failure one",
449 );
450 write_hook_plugin(
451 &second_source_root,
452 "second",
453 "plugin pre two",
454 "plugin post two",
455 "plugin failure two",
456 );
457
458 let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
459 manager
460 .install(first_source_root.to_str().expect("utf8 path"))
461 .expect("first plugin install should succeed");
462 manager
463 .install(second_source_root.to_str().expect("utf8 path"))
464 .expect("second plugin install should succeed");
465 let registry = manager.plugin_registry().expect("registry should build");
466
467 let runner = HookRunner::from_registry(®istry).expect("plugin hooks should load");
469
470 assert_eq!(
472 runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#),
473 HookRunResult::allow(vec![
474 "plugin pre one".to_string(),
475 "plugin pre two".to_string(),
476 ])
477 );
478 assert_eq!(
479 runner.run_post_tool_use("Read", r#"{"path":"README.md"}"#, "ok", false),
480 HookRunResult::allow(vec![
481 "plugin post one".to_string(),
482 "plugin post two".to_string(),
483 ])
484 );
485 assert_eq!(
486 runner.run_post_tool_use_failure("Read", r#"{"path":"README.md"}"#, "tool failed",),
487 HookRunResult::allow(vec![
488 "plugin failure one".to_string(),
489 "plugin failure two".to_string(),
490 ])
491 );
492
493 let _ = fs::remove_dir_all(config_home);
494 let _ = fs::remove_dir_all(first_source_root);
495 let _ = fs::remove_dir_all(second_source_root);
496 }
497
498 #[test]
499 fn pre_tool_use_denies_when_plugin_hook_exits_two() {
500 let runner = HookRunner::new(crate::PluginHooks {
502 pre_tool_use: vec!["printf 'blocked by plugin'; exit 2".to_string()],
503 post_tool_use: Vec::new(),
504 post_tool_use_failure: Vec::new(),
505 });
506
507 let result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
509
510 assert!(result.is_denied());
512 assert_eq!(result.messages(), &["blocked by plugin".to_string()]);
513 }
514
515 #[test]
516 fn propagates_plugin_hook_failures() {
517 let runner = HookRunner::new(crate::PluginHooks {
519 pre_tool_use: vec![
520 "printf 'broken plugin hook'; exit 1".to_string(),
521 "printf 'later plugin hook'".to_string(),
522 ],
523 post_tool_use: Vec::new(),
524 post_tool_use_failure: Vec::new(),
525 });
526
527 let result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
529
530 assert!(result.is_failed());
532 assert!(result
533 .messages()
534 .iter()
535 .any(|message| message.contains("broken plugin hook")));
536 assert!(!result
537 .messages()
538 .iter()
539 .any(|message| message == "later plugin hook"));
540 }
541
542 #[test]
543 #[cfg(unix)]
544 fn generated_hook_scripts_are_executable() {
545 use std::os::unix::fs::PermissionsExt;
546
547 let root = temp_dir("exec-guard");
549 write_hook_plugin(&root, "exec-check", "pre", "post", "fail");
550
551 for script in ["pre.sh", "post.sh", "failure.sh"] {
553 let path = root.join("hooks").join(script);
554 let mode = fs::metadata(&path)
555 .unwrap_or_else(|e| panic!("{script} metadata: {e}"))
556 .permissions()
557 .mode();
558 assert!(
559 mode & 0o111 != 0,
560 "{script} must have at least one execute bit set, got mode {mode:#o}"
561 );
562 }
563 }
564}