use std::ffi::OsStr; use std::path::Path; use std::process::Command; use plugins::{PluginError, PluginRegistry}; use serde_json::json; use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HookEvent { PreToolUse, PostToolUse, } impl HookEvent { fn as_str(self) -> &'static str { match self { Self::PreToolUse => "PreToolUse", Self::PostToolUse => "PostToolUse", } } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct HookRunResult { denied: bool, messages: Vec, } impl HookRunResult { #[must_use] pub fn allow(messages: Vec) -> Self { Self { denied: false, messages, } } #[must_use] pub fn is_denied(&self) -> bool { self.denied } #[must_use] pub fn messages(&self) -> &[String] { &self.messages } } #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct HookRunner { config: RuntimeHookConfig, } impl HookRunner { #[must_use] pub fn new(config: RuntimeHookConfig) -> Self { Self { config } } #[must_use] pub fn from_feature_config(feature_config: &RuntimeFeatureConfig) -> Self { Self::new(feature_config.hooks().clone()) } pub fn from_feature_config_and_plugins( feature_config: &RuntimeFeatureConfig, plugin_registry: &PluginRegistry, ) -> Result { let mut config = feature_config.hooks().clone(); let plugin_hooks = plugin_registry.aggregated_hooks()?; config.extend(&RuntimeHookConfig::new( plugin_hooks.pre_tool_use, plugin_hooks.post_tool_use, )); Ok(Self::new(config)) } #[must_use] pub fn run_pre_tool_use(&self, tool_name: &str, tool_input: &str) -> HookRunResult { self.run_commands( HookEvent::PreToolUse, self.config.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.config.post_tool_use(), tool_name, tool_input, Some(tool_output), is_error, ) } 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 = 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, }) .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 } => { let message = message.unwrap_or_else(|| { format!("{} hook denied tool `{tool_name}`", event.as_str()) }); messages.push(message); return HookRunResult { denied: true, messages, }; } HookCommandOutcome::Warn { message } => messages.push(message), } } 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::Warn { message: format_hook_warning( command, code, message.as_deref(), stderr.as_str(), ), }, None => HookCommandOutcome::Warn { message: format!( "{} hook `{command}` terminated by signal while handling `{tool_name}`", event.as_str() ), }, } } Err(error) => HookCommandOutcome::Warn { message: format!( "{} hook `{command}` failed to start for `{tool_name}`: {error}", event.as_str() ), }, } } } enum HookCommandOutcome { Allow { message: Option }, Deny { message: Option }, Warn { message: String }, } 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}; allowing tool execution to continue"); 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 mut 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(&mut self, key: K, value: V) -> &mut Self where K: AsRef, V: AsRef, { self.command.env(key, value); self } fn output_with_stdin(&mut self, stdin: &[u8]) -> std::io::Result { let mut child = self.command.spawn()?; if let Some(mut child_stdin) = child.stdin.take() { use std::io::Write; child_stdin.write_all(stdin)?; } child.wait_with_output() } } #[cfg(test)] mod tests { use super::{HookRunResult, HookRunner}; use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig}; use plugins::{PluginManager, PluginManagerConfig}; use std::fs; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; 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!("hook-runner-{label}-{nanos}")) } fn write_hook_plugin(root: &Path, name: &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"), "#!/bin/sh\nprintf 'plugin pre'\n", ) .expect("write pre hook"); fs::write( root.join("hooks").join("post.sh"), "#!/bin/sh\nprintf 'plugin post'\n", ) .expect("write post hook"); #[cfg(unix)] { let exec_mode = fs::Permissions::from_mode(0o755); fs::set_permissions(root.join("hooks").join("pre.sh"), exec_mode.clone()) .expect("chmod pre hook"); fs::set_permissions(root.join("hooks").join("post.sh"), exec_mode) .expect("chmod post 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 }}\n}}" ), ) .expect("write plugin manifest"); } #[test] fn allows_exit_code_zero_and_captures_stdout() { let runner = HookRunner::new(RuntimeHookConfig::new( vec![shell_snippet("printf 'pre ok'")], Vec::new(), )); let result = runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#); assert_eq!(result, HookRunResult::allow(vec!["pre ok".to_string()])); } #[test] fn denies_exit_code_two() { let runner = HookRunner::new(RuntimeHookConfig::new( vec![shell_snippet("printf 'blocked by hook'; exit 2")], Vec::new(), )); let result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#); assert!(result.is_denied()); assert_eq!(result.messages(), &["blocked by hook".to_string()]); } #[test] fn warns_for_other_non_zero_statuses() { let runner = HookRunner::from_feature_config(&RuntimeFeatureConfig::default().with_hooks( RuntimeHookConfig::new( vec![shell_snippet("printf 'warning hook'; exit 1")], Vec::new(), ), )); let result = runner.run_pre_tool_use("Edit", r#"{"file":"src/lib.rs"}"#); assert!(!result.is_denied()); assert!(result .messages() .iter() .any(|message| message.contains("allowing tool execution to continue"))); } #[test] fn collects_hooks_from_enabled_plugins() { let config_home = temp_dir("config"); let source_root = temp_dir("source"); write_hook_plugin(&source_root, "hooked"); let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home)); manager .install(source_root.to_str().expect("utf8 path")) .expect("install should succeed"); let registry = manager.plugin_registry().expect("registry should build"); let runner = HookRunner::from_feature_config_and_plugins( &RuntimeFeatureConfig::default(), ®istry, ) .expect("plugin hooks should load"); let pre_result = runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#); let post_result = runner.run_post_tool_use("Read", r#"{"path":"README.md"}"#, "ok", false); assert_eq!( pre_result, HookRunResult::allow(vec!["plugin pre".to_string()]) ); assert_eq!( post_result, HookRunResult::allow(vec!["plugin post".to_string()]) ); let _ = fs::remove_dir_all(config_home); let _ = fs::remove_dir_all(source_root); } #[cfg(windows)] fn shell_snippet(script: &str) -> String { script.replace('\'', "\"") } #[cfg(not(windows))] fn shell_snippet(script: &str) -> String { script.to_string() } }