Skip to main content

mecha_core/
hooks.rs

1//! Lifecycle hooks: user commands that attach to the loop without touching it.
2//!
3//! Three events. `pre_tool` runs before the approver and can deny a call;
4//! `post_tool` observes a completed call; `session_end` fires when a front-end
5//! closes a recorded session. Each hook is a shell command run as the user in
6//! the workspace, with the event payload as one JSON object on stdin.
7//!
8//! Two policy decisions worth stating out loud:
9//!
10//! - **`pre_tool` fails closed.** Exit 0 allows; exit 2 denies with the hook's
11//!   output as the reason; any other exit, a spawn failure, or a timeout also
12//!   **denies**. A policy hook that cannot run and silently allows is the
13//!   silently-degrading-sandbox mistake with a different spelling. Observers
14//!   (`post_tool`, `session_end`) are best-effort: their failures are logged
15//!   and swallowed, because they cannot be load-bearing.
16//! - **Hooks run before the human.** A `pre_tool` denial never reaches the
17//!   approver — mechanical policy is cheaper than an interruption, and a hook
18//!   cannot be talked into clicking yes. The trifecta interlock still sits in
19//!   front of everything; hooks do not replace it and cannot loosen it.
20//!
21//! The order in config is the order they run. For `pre_tool`, the first denial
22//! wins and later hooks do not fire.
23
24use 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/// What a `pre_tool` hook decided.
34#[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/// The validated hook set for one process. Empty is the common case and free.
62#[derive(Debug, Default)]
63pub struct HookSet {
64    hooks: Vec<Hook>,
65}
66
67impl HookSet {
68    /// Validate config into a runnable set. Unknown events and empty commands
69    /// are startup errors: a hook that can never fire is a typo, and finding
70    /// out at startup beats finding out during an incident.
71    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    /// True when any `pre_tool` or `post_tool` hook exists — lets the dispatch
100    /// path skip payload construction entirely in the common empty case.
101    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        // The timeout covers the stdin write as well as the wait. A hook that
124        // never reads stdin blocks the write once the payload outgrows the
125        // pipe buffer — a pre_tool hook fed a large fs_write input would hang
126        // the run forever with the timeout never starting. Dropping the timed
127        // future drops the child, and kill_on_drop reaps it.
128        let fut = async move {
129            if let Some(mut stdin) = child.stdin.take() {
130                // Best-effort: a hook that decides without reading is fine.
131                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    /// Run the matching `pre_tool` hooks in order. First denial wins.
145    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                // Fail closed: an exit code the contract does not define, a
170                // crash, or a timeout is not permission.
171                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    /// Notify `post_tool` observers. Best-effort by design.
194    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                // Bounded: a hook that wants the whole output can read the
212                // session file; stdin is for deciding, not archiving.
213                "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    /// Notify `session_end` observers. Best-effort by design.
222    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        // The bug this pins: the stdin write used to sit outside the timeout,
308        // so a hook that never reads blocked write_all forever once the
309        // payload outgrew the pipe buffer — the timeout never started.
310        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        // A tool outside the filter never fires the hook.
331        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        // A matching tool does, and the payload arrives on stdin.
338        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        // Nothing to assert beyond "does not panic or error": the call has no
356        // way to fail the caller.
357        set.post_tool("echo", &json!({}), false, "out", std::path::Path::new("."))
358            .await;
359    }
360}