hooks.rs 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. use std::ffi::OsStr;
  2. use std::process::Command;
  3. use serde_json::json;
  4. use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
  5. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  6. pub enum HookEvent {
  7. PreToolUse,
  8. PostToolUse,
  9. }
  10. impl HookEvent {
  11. fn as_str(self) -> &'static str {
  12. match self {
  13. Self::PreToolUse => "PreToolUse",
  14. Self::PostToolUse => "PostToolUse",
  15. }
  16. }
  17. }
  18. #[derive(Debug, Clone, PartialEq, Eq)]
  19. pub struct HookRunResult {
  20. denied: bool,
  21. messages: Vec<String>,
  22. }
  23. impl HookRunResult {
  24. #[must_use]
  25. pub fn allow(messages: Vec<String>) -> Self {
  26. Self {
  27. denied: false,
  28. messages,
  29. }
  30. }
  31. #[must_use]
  32. pub fn is_denied(&self) -> bool {
  33. self.denied
  34. }
  35. #[must_use]
  36. pub fn messages(&self) -> &[String] {
  37. &self.messages
  38. }
  39. }
  40. #[derive(Debug, Clone, PartialEq, Eq, Default)]
  41. pub struct HookRunner {
  42. config: RuntimeHookConfig,
  43. }
  44. impl HookRunner {
  45. #[must_use]
  46. pub fn new(config: RuntimeHookConfig) -> Self {
  47. Self { config }
  48. }
  49. #[must_use]
  50. pub fn from_feature_config(feature_config: &RuntimeFeatureConfig) -> Self {
  51. Self::new(feature_config.hooks().clone())
  52. }
  53. #[must_use]
  54. pub fn run_pre_tool_use(&self, tool_name: &str, tool_input: &str) -> HookRunResult {
  55. self.run_commands(
  56. HookEvent::PreToolUse,
  57. self.config.pre_tool_use(),
  58. tool_name,
  59. tool_input,
  60. None,
  61. false,
  62. )
  63. }
  64. #[must_use]
  65. pub fn run_post_tool_use(
  66. &self,
  67. tool_name: &str,
  68. tool_input: &str,
  69. tool_output: &str,
  70. is_error: bool,
  71. ) -> HookRunResult {
  72. self.run_commands(
  73. HookEvent::PostToolUse,
  74. self.config.post_tool_use(),
  75. tool_name,
  76. tool_input,
  77. Some(tool_output),
  78. is_error,
  79. )
  80. }
  81. fn run_commands(
  82. &self,
  83. event: HookEvent,
  84. commands: &[String],
  85. tool_name: &str,
  86. tool_input: &str,
  87. tool_output: Option<&str>,
  88. is_error: bool,
  89. ) -> HookRunResult {
  90. if commands.is_empty() {
  91. return HookRunResult::allow(Vec::new());
  92. }
  93. let payload = json!({
  94. "hook_event_name": event.as_str(),
  95. "tool_name": tool_name,
  96. "tool_input": parse_tool_input(tool_input),
  97. "tool_input_json": tool_input,
  98. "tool_output": tool_output,
  99. "tool_result_is_error": is_error,
  100. })
  101. .to_string();
  102. let mut messages = Vec::new();
  103. for command in commands {
  104. match self.run_command(
  105. command,
  106. event,
  107. tool_name,
  108. tool_input,
  109. tool_output,
  110. is_error,
  111. &payload,
  112. ) {
  113. HookCommandOutcome::Allow { message } => {
  114. if let Some(message) = message {
  115. messages.push(message);
  116. }
  117. }
  118. HookCommandOutcome::Deny { message } => {
  119. let message = message.unwrap_or_else(|| {
  120. format!("{} hook denied tool `{tool_name}`", event.as_str())
  121. });
  122. messages.push(message);
  123. return HookRunResult {
  124. denied: true,
  125. messages,
  126. };
  127. }
  128. HookCommandOutcome::Warn { message } => messages.push(message),
  129. }
  130. }
  131. HookRunResult::allow(messages)
  132. }
  133. fn run_command(
  134. &self,
  135. command: &str,
  136. event: HookEvent,
  137. tool_name: &str,
  138. tool_input: &str,
  139. tool_output: Option<&str>,
  140. is_error: bool,
  141. payload: &str,
  142. ) -> HookCommandOutcome {
  143. let mut child = shell_command(command);
  144. child.stdin(std::process::Stdio::piped());
  145. child.stdout(std::process::Stdio::piped());
  146. child.stderr(std::process::Stdio::piped());
  147. child.env("HOOK_EVENT", event.as_str());
  148. child.env("HOOK_TOOL_NAME", tool_name);
  149. child.env("HOOK_TOOL_INPUT", tool_input);
  150. child.env("HOOK_TOOL_IS_ERROR", if is_error { "1" } else { "0" });
  151. if let Some(tool_output) = tool_output {
  152. child.env("HOOK_TOOL_OUTPUT", tool_output);
  153. }
  154. match child.output_with_stdin(payload.as_bytes()) {
  155. Ok(output) => {
  156. let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
  157. let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
  158. let message = (!stdout.is_empty()).then_some(stdout);
  159. match output.status.code() {
  160. Some(0) => HookCommandOutcome::Allow { message },
  161. Some(2) => HookCommandOutcome::Deny { message },
  162. Some(code) => HookCommandOutcome::Warn {
  163. message: format_hook_warning(
  164. command,
  165. code,
  166. message.as_deref(),
  167. stderr.as_str(),
  168. ),
  169. },
  170. None => HookCommandOutcome::Warn {
  171. message: format!(
  172. "{} hook `{command}` terminated by signal while handling `{tool_name}`",
  173. event.as_str()
  174. ),
  175. },
  176. }
  177. }
  178. Err(error) => HookCommandOutcome::Warn {
  179. message: format!(
  180. "{} hook `{command}` failed to start for `{tool_name}`: {error}",
  181. event.as_str()
  182. ),
  183. },
  184. }
  185. }
  186. }
  187. enum HookCommandOutcome {
  188. Allow { message: Option<String> },
  189. Deny { message: Option<String> },
  190. Warn { message: String },
  191. }
  192. fn parse_tool_input(tool_input: &str) -> serde_json::Value {
  193. serde_json::from_str(tool_input).unwrap_or_else(|_| json!({ "raw": tool_input }))
  194. }
  195. fn format_hook_warning(command: &str, code: i32, stdout: Option<&str>, stderr: &str) -> String {
  196. let mut message =
  197. format!("Hook `{command}` exited with status {code}; allowing tool execution to continue");
  198. if let Some(stdout) = stdout.filter(|stdout| !stdout.is_empty()) {
  199. message.push_str(": ");
  200. message.push_str(stdout);
  201. } else if !stderr.is_empty() {
  202. message.push_str(": ");
  203. message.push_str(stderr);
  204. }
  205. message
  206. }
  207. fn shell_command(command: &str) -> CommandWithStdin {
  208. #[cfg(windows)]
  209. let mut command_builder = {
  210. let mut command_builder = Command::new("cmd");
  211. command_builder.arg("/C").arg(command);
  212. CommandWithStdin::new(command_builder)
  213. };
  214. #[cfg(not(windows))]
  215. let command_builder = {
  216. let mut command_builder = Command::new("sh");
  217. command_builder.arg("-lc").arg(command);
  218. CommandWithStdin::new(command_builder)
  219. };
  220. command_builder
  221. }
  222. struct CommandWithStdin {
  223. command: Command,
  224. }
  225. impl CommandWithStdin {
  226. fn new(command: Command) -> Self {
  227. Self { command }
  228. }
  229. fn stdin(&mut self, cfg: std::process::Stdio) -> &mut Self {
  230. self.command.stdin(cfg);
  231. self
  232. }
  233. fn stdout(&mut self, cfg: std::process::Stdio) -> &mut Self {
  234. self.command.stdout(cfg);
  235. self
  236. }
  237. fn stderr(&mut self, cfg: std::process::Stdio) -> &mut Self {
  238. self.command.stderr(cfg);
  239. self
  240. }
  241. fn env<K, V>(&mut self, key: K, value: V) -> &mut Self
  242. where
  243. K: AsRef<OsStr>,
  244. V: AsRef<OsStr>,
  245. {
  246. self.command.env(key, value);
  247. self
  248. }
  249. fn output_with_stdin(&mut self, stdin: &[u8]) -> std::io::Result<std::process::Output> {
  250. let mut child = self.command.spawn()?;
  251. if let Some(mut child_stdin) = child.stdin.take() {
  252. use std::io::Write;
  253. child_stdin.write_all(stdin)?;
  254. }
  255. child.wait_with_output()
  256. }
  257. }
  258. #[cfg(test)]
  259. mod tests {
  260. use super::{HookRunResult, HookRunner};
  261. use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
  262. #[test]
  263. fn allows_exit_code_zero_and_captures_stdout() {
  264. let runner = HookRunner::new(RuntimeHookConfig::new(
  265. vec![shell_snippet("printf 'pre ok'")],
  266. Vec::new(),
  267. ));
  268. let result = runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#);
  269. assert_eq!(result, HookRunResult::allow(vec!["pre ok".to_string()]));
  270. }
  271. #[test]
  272. fn denies_exit_code_two() {
  273. let runner = HookRunner::new(RuntimeHookConfig::new(
  274. vec![shell_snippet("printf 'blocked by hook'; exit 2")],
  275. Vec::new(),
  276. ));
  277. let result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
  278. assert!(result.is_denied());
  279. assert_eq!(result.messages(), &["blocked by hook".to_string()]);
  280. }
  281. #[test]
  282. fn warns_for_other_non_zero_statuses() {
  283. let runner = HookRunner::from_feature_config(&RuntimeFeatureConfig::default().with_hooks(
  284. RuntimeHookConfig::new(
  285. vec![shell_snippet("printf 'warning hook'; exit 1")],
  286. Vec::new(),
  287. ),
  288. ));
  289. let result = runner.run_pre_tool_use("Edit", r#"{"file":"src/lib.rs"}"#);
  290. assert!(!result.is_denied());
  291. assert!(result
  292. .messages()
  293. .iter()
  294. .any(|message| message.contains("allowing tool execution to continue")));
  295. }
  296. #[cfg(windows)]
  297. fn shell_snippet(script: &str) -> String {
  298. script.replace('\'', "\"")
  299. }
  300. #[cfg(not(windows))]
  301. fn shell_snippet(script: &str) -> String {
  302. script.to_string()
  303. }
  304. }
备用站点 当前处于降级运行的备用站点,仅供应急访问,数据和功能可能不是最新。