| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501 |
- use std::ffi::OsStr;
- use std::path::Path;
- use std::process::Command;
- use serde_json::json;
- use crate::{PluginError, PluginHooks, PluginRegistry};
- #[derive(Debug, Clone, Copy, PartialEq, Eq)]
- pub enum HookEvent {
- PreToolUse,
- PostToolUse,
- PostToolUseFailure,
- }
- impl HookEvent {
- fn as_str(self) -> &'static str {
- match self {
- Self::PreToolUse => "PreToolUse",
- Self::PostToolUse => "PostToolUse",
- Self::PostToolUseFailure => "PostToolUseFailure",
- }
- }
- }
- #[derive(Debug, Clone, PartialEq, Eq)]
- pub struct HookRunResult {
- denied: bool,
- failed: bool,
- messages: Vec<String>,
- }
- impl HookRunResult {
- #[must_use]
- pub fn allow(messages: Vec<String>) -> Self {
- Self {
- denied: false,
- failed: false,
- messages,
- }
- }
- #[must_use]
- pub fn is_denied(&self) -> bool {
- self.denied
- }
- #[must_use]
- pub fn is_failed(&self) -> bool {
- self.failed
- }
- #[must_use]
- pub fn messages(&self) -> &[String] {
- &self.messages
- }
- }
- #[derive(Debug, Clone, PartialEq, Eq, Default)]
- pub struct HookRunner {
- hooks: PluginHooks,
- }
- impl HookRunner {
- #[must_use]
- pub fn new(hooks: PluginHooks) -> Self {
- Self { hooks }
- }
- pub fn from_registry(plugin_registry: &PluginRegistry) -> Result<Self, PluginError> {
- Ok(Self::new(plugin_registry.aggregated_hooks()?))
- }
- #[must_use]
- pub fn run_pre_tool_use(&self, tool_name: &str, tool_input: &str) -> HookRunResult {
- self.run_commands(
- HookEvent::PreToolUse,
- &self.hooks.pre_tool_use,
- tool_name,
- tool_input,
- None,
- false,
- )
- }
- #[must_use]
- pub fn run_post_tool_use(
- &self,
- tool_name: &str,
- tool_input: &str,
- tool_output: &str,
- is_error: bool,
- ) -> HookRunResult {
- self.run_commands(
- HookEvent::PostToolUse,
- &self.hooks.post_tool_use,
- tool_name,
- tool_input,
- Some(tool_output),
- is_error,
- )
- }
- #[must_use]
- pub fn run_post_tool_use_failure(
- &self,
- tool_name: &str,
- tool_input: &str,
- tool_error: &str,
- ) -> HookRunResult {
- self.run_commands(
- HookEvent::PostToolUseFailure,
- &self.hooks.post_tool_use_failure,
- tool_name,
- tool_input,
- Some(tool_error),
- true,
- )
- }
- fn run_commands(
- &self,
- event: HookEvent,
- commands: &[String],
- tool_name: &str,
- tool_input: &str,
- tool_output: Option<&str>,
- is_error: bool,
- ) -> HookRunResult {
- if commands.is_empty() {
- return HookRunResult::allow(Vec::new());
- }
- let payload = hook_payload(event, tool_name, tool_input, tool_output, is_error).to_string();
- let mut messages = Vec::new();
- for command in commands {
- match self.run_command(
- command,
- event,
- tool_name,
- tool_input,
- tool_output,
- is_error,
- &payload,
- ) {
- HookCommandOutcome::Allow { message } => {
- if let Some(message) = message {
- messages.push(message);
- }
- }
- HookCommandOutcome::Deny { message } => {
- messages.push(message.unwrap_or_else(|| {
- format!("{} hook denied tool `{tool_name}`", event.as_str())
- }));
- return HookRunResult {
- denied: true,
- failed: false,
- messages,
- };
- }
- HookCommandOutcome::Failed { message } => {
- messages.push(message);
- return HookRunResult {
- denied: false,
- failed: true,
- messages,
- };
- }
- }
- }
- HookRunResult::allow(messages)
- }
- #[allow(clippy::too_many_arguments, clippy::unused_self)]
- fn run_command(
- &self,
- command: &str,
- event: HookEvent,
- tool_name: &str,
- tool_input: &str,
- tool_output: Option<&str>,
- is_error: bool,
- payload: &str,
- ) -> HookCommandOutcome {
- let mut child = shell_command(command);
- child.stdin(std::process::Stdio::piped());
- child.stdout(std::process::Stdio::piped());
- child.stderr(std::process::Stdio::piped());
- child.env("HOOK_EVENT", event.as_str());
- child.env("HOOK_TOOL_NAME", tool_name);
- child.env("HOOK_TOOL_INPUT", tool_input);
- child.env("HOOK_TOOL_IS_ERROR", if is_error { "1" } else { "0" });
- if let Some(tool_output) = tool_output {
- child.env("HOOK_TOOL_OUTPUT", tool_output);
- }
- match child.output_with_stdin(payload.as_bytes()) {
- Ok(output) => {
- let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
- let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
- let message = (!stdout.is_empty()).then_some(stdout);
- match output.status.code() {
- Some(0) => HookCommandOutcome::Allow { message },
- Some(2) => HookCommandOutcome::Deny { message },
- Some(code) => HookCommandOutcome::Failed {
- message: format_hook_warning(
- command,
- code,
- message.as_deref(),
- stderr.as_str(),
- ),
- },
- None => HookCommandOutcome::Failed {
- message: format!(
- "{} hook `{command}` terminated by signal while handling `{tool_name}`",
- event.as_str()
- ),
- },
- }
- }
- Err(error) => HookCommandOutcome::Failed {
- message: format!(
- "{} hook `{command}` failed to start for `{tool_name}`: {error}",
- event.as_str()
- ),
- },
- }
- }
- }
- enum HookCommandOutcome {
- Allow { message: Option<String> },
- Deny { message: Option<String> },
- Failed { message: String },
- }
- fn hook_payload(
- event: HookEvent,
- tool_name: &str,
- tool_input: &str,
- tool_output: Option<&str>,
- is_error: bool,
- ) -> serde_json::Value {
- match event {
- HookEvent::PostToolUseFailure => json!({
- "hook_event_name": event.as_str(),
- "tool_name": tool_name,
- "tool_input": parse_tool_input(tool_input),
- "tool_input_json": tool_input,
- "tool_error": tool_output,
- "tool_result_is_error": true,
- }),
- _ => json!({
- "hook_event_name": event.as_str(),
- "tool_name": tool_name,
- "tool_input": parse_tool_input(tool_input),
- "tool_input_json": tool_input,
- "tool_output": tool_output,
- "tool_result_is_error": is_error,
- }),
- }
- }
- fn parse_tool_input(tool_input: &str) -> serde_json::Value {
- serde_json::from_str(tool_input).unwrap_or_else(|_| json!({ "raw": tool_input }))
- }
- fn format_hook_warning(command: &str, code: i32, stdout: Option<&str>, stderr: &str) -> String {
- let mut message = format!("Hook `{command}` exited with status {code}");
- if let Some(stdout) = stdout.filter(|stdout| !stdout.is_empty()) {
- message.push_str(": ");
- message.push_str(stdout);
- } else if !stderr.is_empty() {
- message.push_str(": ");
- message.push_str(stderr);
- }
- message
- }
- fn shell_command(command: &str) -> CommandWithStdin {
- #[cfg(windows)]
- let command_builder = {
- let mut command_builder = Command::new("cmd");
- command_builder.arg("/C").arg(command);
- CommandWithStdin::new(command_builder)
- };
- #[cfg(not(windows))]
- let command_builder = if Path::new(command).exists() {
- let mut command_builder = Command::new("sh");
- command_builder.arg(command);
- CommandWithStdin::new(command_builder)
- } else {
- let mut command_builder = Command::new("sh");
- command_builder.arg("-lc").arg(command);
- CommandWithStdin::new(command_builder)
- };
- command_builder
- }
- struct CommandWithStdin {
- command: Command,
- }
- impl CommandWithStdin {
- fn new(command: Command) -> Self {
- Self { command }
- }
- fn stdin(&mut self, cfg: std::process::Stdio) -> &mut Self {
- self.command.stdin(cfg);
- self
- }
- fn stdout(&mut self, cfg: std::process::Stdio) -> &mut Self {
- self.command.stdout(cfg);
- self
- }
- fn stderr(&mut self, cfg: std::process::Stdio) -> &mut Self {
- self.command.stderr(cfg);
- self
- }
- fn env<K, V>(&mut self, key: K, value: V) -> &mut Self
- where
- K: AsRef<OsStr>,
- V: AsRef<OsStr>,
- {
- self.command.env(key, value);
- self
- }
- fn output_with_stdin(&mut self, stdin: &[u8]) -> std::io::Result<std::process::Output> {
- let mut child = self.command.spawn()?;
- if let Some(mut child_stdin) = child.stdin.take() {
- use std::io::Write as _;
- child_stdin.write_all(stdin)?;
- }
- child.wait_with_output()
- }
- }
- #[cfg(test)]
- mod tests {
- use super::{HookRunResult, HookRunner};
- use crate::{PluginManager, PluginManagerConfig};
- use std::fs;
- use std::path::{Path, PathBuf};
- use std::time::{SystemTime, UNIX_EPOCH};
- fn temp_dir(label: &str) -> PathBuf {
- let nanos = SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .expect("time should be after epoch")
- .as_nanos();
- std::env::temp_dir().join(format!("plugins-hook-runner-{label}-{nanos}"))
- }
- fn write_hook_plugin(
- root: &Path,
- name: &str,
- pre_message: &str,
- post_message: &str,
- failure_message: &str,
- ) {
- fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
- fs::create_dir_all(root.join("hooks")).expect("hooks dir");
- fs::write(
- root.join("hooks").join("pre.sh"),
- format!("#!/bin/sh\nprintf '%s\\n' '{pre_message}'\n"),
- )
- .expect("write pre hook");
- fs::write(
- root.join("hooks").join("post.sh"),
- format!("#!/bin/sh\nprintf '%s\\n' '{post_message}'\n"),
- )
- .expect("write post hook");
- fs::write(
- root.join("hooks").join("failure.sh"),
- format!("#!/bin/sh\nprintf '%s\\n' '{failure_message}'\n"),
- )
- .expect("write failure hook");
- fs::write(
- root.join(".claude-plugin").join("plugin.json"),
- format!(
- "{{\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}}"
- ),
- )
- .expect("write plugin manifest");
- }
- #[test]
- fn collects_and_runs_hooks_from_enabled_plugins() {
- // given
- let config_home = temp_dir("config");
- let first_source_root = temp_dir("source-a");
- let second_source_root = temp_dir("source-b");
- write_hook_plugin(
- &first_source_root,
- "first",
- "plugin pre one",
- "plugin post one",
- "plugin failure one",
- );
- write_hook_plugin(
- &second_source_root,
- "second",
- "plugin pre two",
- "plugin post two",
- "plugin failure two",
- );
- let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
- manager
- .install(first_source_root.to_str().expect("utf8 path"))
- .expect("first plugin install should succeed");
- manager
- .install(second_source_root.to_str().expect("utf8 path"))
- .expect("second plugin install should succeed");
- let registry = manager.plugin_registry().expect("registry should build");
- // when
- let runner = HookRunner::from_registry(®istry).expect("plugin hooks should load");
- // then
- assert_eq!(
- runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#),
- HookRunResult::allow(vec![
- "plugin pre one".to_string(),
- "plugin pre two".to_string(),
- ])
- );
- assert_eq!(
- runner.run_post_tool_use("Read", r#"{"path":"README.md"}"#, "ok", false),
- HookRunResult::allow(vec![
- "plugin post one".to_string(),
- "plugin post two".to_string(),
- ])
- );
- assert_eq!(
- runner.run_post_tool_use_failure("Read", r#"{"path":"README.md"}"#, "tool failed",),
- HookRunResult::allow(vec![
- "plugin failure one".to_string(),
- "plugin failure two".to_string(),
- ])
- );
- let _ = fs::remove_dir_all(config_home);
- let _ = fs::remove_dir_all(first_source_root);
- let _ = fs::remove_dir_all(second_source_root);
- }
- #[test]
- fn pre_tool_use_denies_when_plugin_hook_exits_two() {
- // given
- let runner = HookRunner::new(crate::PluginHooks {
- pre_tool_use: vec!["printf 'blocked by plugin'; exit 2".to_string()],
- post_tool_use: Vec::new(),
- post_tool_use_failure: Vec::new(),
- });
- // when
- let result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
- // then
- assert!(result.is_denied());
- assert_eq!(result.messages(), &["blocked by plugin".to_string()]);
- }
- #[test]
- fn propagates_plugin_hook_failures() {
- // given
- let runner = HookRunner::new(crate::PluginHooks {
- pre_tool_use: vec![
- "printf 'broken plugin hook'; exit 1".to_string(),
- "printf 'later plugin hook'".to_string(),
- ],
- post_tool_use: Vec::new(),
- post_tool_use_failure: Vec::new(),
- });
- // when
- let result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
- // then
- assert!(result.is_failed());
- assert!(result
- .messages()
- .iter()
- .any(|message| message.contains("broken plugin hook")));
- assert!(!result
- .messages()
- .iter()
- .any(|message| message == "later plugin hook"));
- }
- }
|