use std::ffi::OsStr; use std::process::Command; 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()) } #[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) } 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 = { 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}; #[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"))); } #[cfg(windows)] fn shell_snippet(script: &str) -> String { script.replace('\'', "\"") } #[cfg(not(windows))] fn shell_snippet(script: &str) -> String { script.to_string() } }