Skip to main content

ninmu_plugins/
hooks.rs

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            // Tolerate BrokenPipe: a hook script that runs to completion
341            // (or exits early without reading stdin) closes its stdin
342            // before the parent finishes writing the JSON payload, and
343            // the kernel raises EPIPE on the parent's write_all. That is
344            // not a hook failure — the child still exited cleanly and we
345            // still need to wait_with_output() to capture stdout/stderr
346            // and the real exit code. Other write errors (e.g. EIO,
347            // permission, OOM) still propagate.
348            //
349            // This was the root cause of the Linux CI flake on
350            // hooks::tests::collects_and_runs_hooks_from_enabled_plugins
351            // (ROADMAP #25, runs 24120271422 / 24120538408 / 24121392171
352            // / 24121776826): the test hook scripts run in microseconds
353            // and the parent's stdin write races against child exit.
354            // macOS pipes happen to buffer the small payload before the
355            // child exits; Linux pipes do not, so the race shows up
356            // deterministically on ubuntu runners.
357            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        // given
440        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        // when
468        let runner = HookRunner::from_registry(&registry).expect("plugin hooks should load");
469
470        // then
471        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        // given
501        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        // when
508        let result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
509
510        // then
511        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        // given
518        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        // when
528        let result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
529
530        // then
531        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        // given
548        let root = temp_dir("exec-guard");
549        write_hook_plugin(&root, "exec-check", "pre", "post", "fail");
550
551        // then
552        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}