1use crate::config::HookConfig;
25use anyhow::{bail, Result};
26use serde_json::Value;
27use std::process::Stdio;
28use std::time::Duration;
29use tokio::io::AsyncWriteExt;
30
31const DEFAULT_TIMEOUT_SECS: u64 = 10;
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum HookVerdict {
36 Allow,
37 Deny(String),
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41enum Event {
42 PreTool,
43 PostTool,
44 SessionEnd,
45}
46
47#[derive(Debug)]
48struct Hook {
49 event: Event,
50 command: String,
51 tools: Vec<String>,
52 timeout: Duration,
53}
54
55impl Hook {
56 fn matches_tool(&self, tool: &str) -> bool {
57 self.tools.is_empty() || self.tools.iter().any(|t| t == tool)
58 }
59}
60
61#[derive(Debug, Default)]
63pub struct HookSet {
64 hooks: Vec<Hook>,
65}
66
67impl HookSet {
68 pub fn from_config(configs: &[HookConfig]) -> Result<Self> {
72 let mut hooks = Vec::new();
73 for c in configs {
74 let event = match c.event.as_str() {
75 "pre_tool" => Event::PreTool,
76 "post_tool" => Event::PostTool,
77 "session_end" => Event::SessionEnd,
78 other => {
79 bail!("hook event {other:?} is not one of pre_tool, post_tool, session_end")
80 }
81 };
82 if c.command.trim().is_empty() {
83 bail!("a hook for {:?} has an empty command", c.event);
84 }
85 hooks.push(Hook {
86 event,
87 command: c.command.clone(),
88 tools: c.tools.clone(),
89 timeout: Duration::from_secs(c.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS)),
90 });
91 }
92 Ok(HookSet { hooks })
93 }
94
95 pub fn is_empty(&self) -> bool {
96 self.hooks.is_empty()
97 }
98
99 pub fn watches_tools(&self) -> bool {
102 self.hooks
103 .iter()
104 .any(|h| matches!(h.event, Event::PreTool | Event::PostTool))
105 }
106
107 async fn run_one(
108 hook: &Hook,
109 payload: &Value,
110 workdir: &std::path::Path,
111 ) -> Result<(i32, String)> {
112 let mut child = tokio::process::Command::new("sh")
113 .arg("-c")
114 .arg(&hook.command)
115 .current_dir(workdir)
116 .stdin(Stdio::piped())
117 .stdout(Stdio::piped())
118 .stderr(Stdio::piped())
119 .kill_on_drop(true)
120 .spawn()?;
121 let bytes = serde_json::to_vec(payload)?;
122
123 let fut = async move {
129 if let Some(mut stdin) = child.stdin.take() {
130 let _ = stdin.write_all(&bytes).await;
132 drop(stdin);
133 }
134 child.wait_with_output().await
135 };
136 let out = tokio::time::timeout(hook.timeout, fut).await??;
137 let mut text = String::from_utf8_lossy(&out.stdout).trim().to_string();
138 if text.is_empty() {
139 text = String::from_utf8_lossy(&out.stderr).trim().to_string();
140 }
141 Ok((out.status.code().unwrap_or(-1), text))
142 }
143
144 pub async fn pre_tool(
146 &self,
147 tool: &str,
148 input: &Value,
149 workdir: &std::path::Path,
150 ) -> HookVerdict {
151 for hook in self.hooks.iter().filter(|h| h.event == Event::PreTool) {
152 if !hook.matches_tool(tool) {
153 continue;
154 }
155 let payload = serde_json::json!({
156 "event": "pre_tool",
157 "tool": tool,
158 "input": input,
159 });
160 match Self::run_one(hook, &payload, workdir).await {
161 Ok((0, _)) => {}
162 Ok((2, reason)) => {
163 return HookVerdict::Deny(if reason.is_empty() {
164 format!("blocked by hook `{}`", hook.command)
165 } else {
166 reason
167 });
168 }
169 Ok((code, reason)) => {
172 return HookVerdict::Deny(format!(
173 "hook `{}` exited {code} (exit 0 allows, 2 denies){}",
174 hook.command,
175 if reason.is_empty() {
176 String::new()
177 } else {
178 format!(": {reason}")
179 }
180 ));
181 }
182 Err(e) => {
183 return HookVerdict::Deny(format!(
184 "hook `{}` failed to run: {e}",
185 hook.command
186 ));
187 }
188 }
189 }
190 HookVerdict::Allow
191 }
192
193 pub async fn post_tool(
195 &self,
196 tool: &str,
197 input: &Value,
198 is_error: bool,
199 content: &str,
200 workdir: &std::path::Path,
201 ) {
202 for hook in self.hooks.iter().filter(|h| h.event == Event::PostTool) {
203 if !hook.matches_tool(tool) {
204 continue;
205 }
206 let payload = serde_json::json!({
207 "event": "post_tool",
208 "tool": tool,
209 "input": input,
210 "is_error": is_error,
211 "content": content.chars().take(4000).collect::<String>(),
214 });
215 if let Err(e) = Self::run_one(hook, &payload, workdir).await {
216 tracing::warn!("post_tool hook `{}` failed: {e}", hook.command);
217 }
218 }
219 }
220
221 pub async fn session_end(
223 &self,
224 session_id: &str,
225 path: &std::path::Path,
226 workdir: &std::path::Path,
227 ) {
228 for hook in self.hooks.iter().filter(|h| h.event == Event::SessionEnd) {
229 let payload = serde_json::json!({
230 "event": "session_end",
231 "session_id": session_id,
232 "path": path,
233 });
234 if let Err(e) = Self::run_one(hook, &payload, workdir).await {
235 tracing::warn!("session_end hook `{}` failed: {e}", hook.command);
236 }
237 }
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244 use serde_json::json;
245
246 fn cfg(event: &str, command: &str) -> HookConfig {
247 HookConfig {
248 event: event.into(),
249 command: command.into(),
250 tools: Vec::new(),
251 timeout_secs: Some(5),
252 }
253 }
254
255 #[test]
256 fn an_unknown_event_is_a_startup_error() {
257 let err = HookSet::from_config(&[cfg("pre-tool", "true")])
258 .unwrap_err()
259 .to_string();
260 assert!(err.contains("pre-tool"), "{err}");
261 assert!(HookSet::from_config(&[cfg("pre_tool", " ")]).is_err());
262 }
263
264 #[tokio::test]
265 async fn exit_zero_allows_and_exit_two_denies_with_the_reason() {
266 let set = HookSet::from_config(&[cfg("pre_tool", "true")]).unwrap();
267 let v = set
268 .pre_tool("echo", &json!({}), std::path::Path::new("."))
269 .await;
270 assert_eq!(v, HookVerdict::Allow);
271
272 let set = HookSet::from_config(&[cfg("pre_tool", "echo not today; exit 2")]).unwrap();
273 let v = set
274 .pre_tool("echo", &json!({}), std::path::Path::new("."))
275 .await;
276 assert_eq!(v, HookVerdict::Deny("not today".into()));
277 }
278
279 #[tokio::test]
280 async fn an_undefined_exit_code_fails_closed() {
281 let set = HookSet::from_config(&[cfg("pre_tool", "exit 1")]).unwrap();
282 match set
283 .pre_tool("echo", &json!({}), std::path::Path::new("."))
284 .await
285 {
286 HookVerdict::Deny(reason) => assert!(reason.contains("exited 1"), "{reason}"),
287 HookVerdict::Allow => panic!("an undefined exit code must not be permission"),
288 }
289 }
290
291 #[tokio::test]
292 async fn a_hook_that_hangs_fails_closed_at_its_timeout() {
293 let mut c = cfg("pre_tool", "sleep 30");
294 c.timeout_secs = Some(1);
295 let set = HookSet::from_config(&[c]).unwrap();
296 match set
297 .pre_tool("echo", &json!({}), std::path::Path::new("."))
298 .await
299 {
300 HookVerdict::Deny(reason) => assert!(reason.contains("failed to run"), "{reason}"),
301 HookVerdict::Allow => panic!("a timeout must not be permission"),
302 }
303 }
304
305 #[tokio::test]
306 async fn a_hook_that_never_reads_a_large_payload_still_times_out() {
307 let mut c = cfg("pre_tool", "sleep 30");
311 c.timeout_secs = Some(1);
312 let set = HookSet::from_config(&[c]).unwrap();
313 let big = json!({"content": "x".repeat(256 * 1024)});
314 let verdict = tokio::time::timeout(
315 std::time::Duration::from_secs(5),
316 set.pre_tool("fs_write", &big, std::path::Path::new(".")),
317 )
318 .await
319 .expect("the hook's own timeout must fire; the write must not wedge it");
320 assert!(matches!(verdict, HookVerdict::Deny(_)));
321 }
322
323 #[tokio::test]
324 async fn the_tool_filter_scopes_a_hook_and_the_payload_reaches_stdin() {
325 let marker = std::env::temp_dir().join(format!("mecha-hook-{}", uuid::Uuid::new_v4()));
326 let mut c = cfg("pre_tool", &format!("cat > {}; exit 2", marker.display()));
327 c.tools = vec!["shell".into()];
328 let set = HookSet::from_config(&[c]).unwrap();
329
330 let v = set
332 .pre_tool("echo", &json!({}), std::path::Path::new("."))
333 .await;
334 assert_eq!(v, HookVerdict::Allow);
335 assert!(!marker.exists());
336
337 let v = set
339 .pre_tool(
340 "shell",
341 &json!({"command": "rm -rf /"}),
342 std::path::Path::new("."),
343 )
344 .await;
345 assert!(matches!(v, HookVerdict::Deny(_)));
346 let written = std::fs::read_to_string(&marker).unwrap();
347 assert!(written.contains("\"event\":\"pre_tool\""));
348 assert!(written.contains("rm -rf /"));
349 std::fs::remove_file(&marker).ok();
350 }
351
352 #[tokio::test]
353 async fn post_tool_failures_are_swallowed_because_observers_cannot_be_load_bearing() {
354 let set = HookSet::from_config(&[cfg("post_tool", "exit 7")]).unwrap();
355 set.post_tool("echo", &json!({}), false, "out", std::path::Path::new("."))
358 .await;
359 }
360}