Skip to main content

oxicode_sdk/ports/fs/
hook_runner.rs

1//! Shell-command [`HookRunner`] — the reference implementation of port #16.
2//!
3//! See spec at `docs/superpowers/specs/2026-08-04-hooks-system-design.md`.
4//!
5//! Each `HookSpec` is compiled at construction time: the `matcher` is split
6//! on `|` into one `globset::Glob` per name, all of which are added to a
7//! `globset::GlobSet`. At run time we filter by `event` + `tool_name`
8//! and execute matching scripts sequentially through `sh -c`.
9
10use std::pin::Pin;
11use std::process::Stdio;
12use std::time::Duration;
13
14use globset::{Glob, GlobSet, GlobSetBuilder};
15use serde::{Deserialize, Serialize};
16use tokio::io::AsyncWriteExt;
17use tokio::process::Command;
18use tokio::time::timeout;
19use tracing::warn;
20
21use crate::ports::{HookContext, HookEvent, HookOutcome, HookRunner, HookSpec};
22
23/// Default per-invocation timeout when `HookSpec::timeout_secs` is `None`.
24pub const DEFAULT_HOOK_TIMEOUT_SECS: u64 = 60;
25
26/// Error constructing a [`CommandHookRunner`].
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct HookConfigError(pub String);
29
30impl std::fmt::Display for HookConfigError {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        f.write_str(&self.0)
33    }
34}
35
36impl std::error::Error for HookConfigError {}
37
38struct MatcherEntry {
39    event: HookEvent,
40    set: Option<GlobSet>, // None = match all
41    timeout: Duration,
42    command: String,
43}
44
45/// Reference [`HookRunner`] backed by a list of [`HookSpec`]s.
46pub struct CommandHookRunner {
47    specs: Vec<HookSpec>,
48    matchers: Vec<MatcherEntry>,
49}
50
51impl std::fmt::Debug for CommandHookRunner {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        f.debug_struct("CommandHookRunner")
54            .field("spec_count", &self.specs.len())
55            .finish()
56    }
57}
58
59impl CommandHookRunner {
60    /// Compile the given specs. Returns an error if any `matcher` is an
61    /// invalid glob.
62    pub fn new(specs: Vec<HookSpec>) -> Result<Self, HookConfigError> {
63        let mut matchers = Vec::with_capacity(specs.len());
64        for spec in &specs {
65            let set = match &spec.matcher {
66                None => None,
67                Some(pat) => {
68                    let mut builder = GlobSetBuilder::new();
69                    for piece in pat.split('|') {
70                        let piece = piece.trim();
71                        if piece.is_empty() {
72                            return Err(HookConfigError(format!(
73                                "empty matcher segment in `{}`",
74                                pat
75                            )));
76                        }
77                        let glob = Glob::new(piece).map_err(|e| {
78                            HookConfigError(format!("invalid glob `{}`: {}", piece, e))
79                        })?;
80                        builder.add(glob);
81                    }
82                    Some(
83                        builder
84                            .build()
85                            .map_err(|e| HookConfigError(format!("globset build failed: {}", e)))?,
86                    )
87                }
88            };
89            let timeout =
90                Duration::from_secs(spec.timeout_secs.unwrap_or(DEFAULT_HOOK_TIMEOUT_SECS));
91            matchers.push(MatcherEntry {
92                event: spec.event,
93                set,
94                timeout,
95                command: spec.command.clone(),
96            });
97        }
98        Ok(Self { specs, matchers })
99    }
100
101    /// Borrow the original specs (read-only).
102    pub fn specs(&self) -> &[HookSpec] {
103        &self.specs
104    }
105}
106
107impl HookRunner for CommandHookRunner {
108    fn run<'a>(
109        &'a self,
110        event: HookEvent,
111        ctx: &'a HookContext,
112    ) -> Pin<Box<dyn Future<Output = HookOutcome> + Send + 'a>> {
113        Box::pin(async move {
114            let mut outcome = HookOutcome::default();
115
116            for entry in &self.matchers {
117                if entry.event != event {
118                    continue;
119                }
120                // Matcher: None = all; Some(set) = tool_name must be is_match.
121                let tool_name = ctx.tool_name.as_deref().unwrap_or("");
122                if let Some(set) = &entry.set
123                    && !set.is_match(tool_name)
124                {
125                    continue;
126                }
127
128                // Run this script. Fail-open: any error → log + continue.
129                let script_outcome = run_one(&entry.command, entry.timeout, event, ctx).await;
130                if script_outcome.block {
131                    outcome.block = true;
132                    outcome.reason = script_outcome.reason.or(outcome.reason);
133                    // block short-circuits: stop processing further scripts.
134                    return outcome;
135                }
136                if script_outcome.override_content.is_some() {
137                    outcome.override_content = script_outcome.override_content;
138                }
139            }
140
141            outcome
142        })
143    }
144}
145
146/// Execute one hook script. Never panics; never returns `Err`. The result
147/// is translated to the script's effect on the agent loop (block / override).
148async fn run_one(
149    command: &str,
150    timeout_dur: Duration,
151    event: HookEvent,
152    ctx: &HookContext,
153) -> HookOutcome {
154    let session_cwd = ctx
155        .session_cwd
156        .as_ref()
157        .map(|p| p.to_string_lossy().into_owned())
158        .unwrap_or_default();
159
160    let mut child = match Command::new("sh")
161        .arg("-c")
162        .arg(command)
163        .env("OXICODE_HOOK_EVENT", event_to_str(event))
164        .env(
165            "OXICODE_HOOK_TOOL_NAME",
166            ctx.tool_name.as_deref().unwrap_or(""),
167        )
168        .env(
169            "OXICODE_HOOK_SESSION_ID",
170            ctx.session_id.as_deref().unwrap_or(""),
171        )
172        .env("OXICODE_HOOK_SESSION_CWD", &session_cwd)
173        .stdin(Stdio::piped())
174        .stdout(Stdio::piped())
175        .stderr(Stdio::piped())
176        .kill_on_drop(true)
177        .spawn()
178    {
179        Ok(c) => c,
180        Err(e) => {
181            warn!(command, error = %e, "hook script failed to spawn (fail-open)");
182            return HookOutcome::default();
183        }
184    };
185
186    // Write the JSON context to stdin.
187    let stdin_payload = match serde_json::to_string(ctx) {
188        Ok(s) => s,
189        Err(e) => {
190            warn!(error = %e, "failed to serialise hook context (fail-open)");
191            return HookOutcome::default();
192        }
193    };
194    if let Some(mut stdin) = child.stdin.take() {
195        if let Err(e) = stdin.write_all(stdin_payload.as_bytes()).await {
196            warn!(error = %e, "failed to write hook stdin (fail-open)");
197        }
198        drop(stdin);
199    }
200
201    // Wait with timeout. On timeout, kill the child (kill_on_drop handles
202    // the case where the future is dropped).
203    let result = timeout(timeout_dur, child.wait_with_output()).await;
204    let output = match result {
205        Ok(Ok(o)) => o,
206        Ok(Err(e)) => {
207            warn!(command, error = %e, "hook wait failed (fail-open)");
208            return HookOutcome::default();
209        }
210        Err(_) => {
211            warn!(command, ?timeout_dur, "hook timed out (fail-open)");
212            return HookOutcome::default();
213        }
214    };
215
216    // Exit code 2 → block. The optional JSON on stdout can override
217    if output.status.code() == Some(2) {
218        return HookOutcome {
219            block: true,
220            reason: extract_reason(&output.stderr),
221            override_content: None,
222        };
223    }
224
225    // Non-2, non-0 → log + pass.
226    if !output.status.success()
227        && let Some(code) = output.status.code()
228    {
229        warn!(
230            command,
231            code,
232            stderr = %String::from_utf8_lossy(&output.stderr),
233            "hook script exited non-zero (fail-open)"
234        );
235    }
236
237    // Best-effort: parse stdout JSON for override / reason. Unknown shape
238    // is ignored silently (Claude Code permits a wide variety).
239    if let Ok(parsed) = serde_json::from_slice::<serde_json::Value>(&output.stdout) {
240        let override_content = parsed
241            .get("override_content")
242            .or_else(|| parsed.get("continue"))
243            .and_then(|v| v.as_str())
244            .map(String::from);
245        let reason = parsed
246            .get("reason")
247            .or_else(|| parsed.get("message"))
248            .and_then(|v| v.as_str())
249            .map(String::from);
250        return HookOutcome {
251            block: false,
252            reason,
253            override_content,
254        };
255    }
256
257    HookOutcome::default()
258}
259
260fn event_to_str(e: HookEvent) -> &'static str {
261    match e {
262        HookEvent::PreToolUse => "PreToolUse",
263        HookEvent::PostToolUse => "PostToolUse",
264        HookEvent::Stop => "Stop",
265        HookEvent::SubagentStop => "SubagentStop",
266        HookEvent::SessionStart => "SessionStart",
267        HookEvent::SessionEnd => "SessionEnd",
268        HookEvent::Notification => "Notification",
269    }
270}
271
272fn extract_reason(stdout: &[u8]) -> Option<String> {
273    serde_json::from_slice::<serde_json::Value>(stdout)
274        .ok()
275        .and_then(|v| {
276            v.get("reason")
277                .or_else(|| v.get("message"))
278                .and_then(|r| r.as_str())
279                .map(String::from)
280        })
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    fn spec(event: HookEvent, matcher: Option<&str>, command: &str) -> HookSpec {
288        HookSpec {
289            event,
290            matcher: matcher.map(String::from),
291            command: command.into(),
292            timeout_secs: None,
293        }
294    }
295
296    #[tokio::test]
297    async fn no_match_runs_nothing() {
298        let runner =
299            CommandHookRunner::new(vec![spec(HookEvent::PreToolUse, Some("bash"), "false")])
300                .unwrap();
301        let ctx = HookContext {
302            event: HookEvent::PreToolUse,
303            tool_name: Some("read".into()),
304            ..Default::default()
305        };
306        let outcome = runner.run(HookEvent::PreToolUse, &ctx).await;
307        assert!(!outcome.block);
308    }
309
310    #[tokio::test]
311    async fn no_matcher_runs_for_any_tool() {
312        let runner =
313            CommandHookRunner::new(vec![spec(HookEvent::PreToolUse, None, "exit 0")]).unwrap();
314        let ctx = HookContext {
315            event: HookEvent::PreToolUse,
316            tool_name: Some("anything".into()),
317            ..Default::default()
318        };
319        let outcome = runner.run(HookEvent::PreToolUse, &ctx).await;
320        assert!(!outcome.block);
321    }
322
323    #[tokio::test]
324    async fn exit_2_blocks() {
325        let runner = CommandHookRunner::new(vec![spec(
326            HookEvent::PreToolUse,
327            Some("bash"),
328            "echo '{\"reason\":\"nope\"}' >&2; exit 2",
329        )])
330        .unwrap();
331        let ctx = HookContext {
332            event: HookEvent::PreToolUse,
333            tool_name: Some("bash".into()),
334            ..Default::default()
335        };
336        let outcome = runner.run(HookEvent::PreToolUse, &ctx).await;
337        assert!(outcome.block);
338        assert_eq!(outcome.reason.as_deref(), Some("nope"));
339    }
340
341    #[tokio::test]
342    async fn nonzero_nonzero_2_fails_open() {
343        let runner =
344            CommandHookRunner::new(vec![spec(HookEvent::PreToolUse, Some("bash"), "exit 1")])
345                .unwrap();
346        let ctx = HookContext {
347            event: HookEvent::PreToolUse,
348            tool_name: Some("bash".into()),
349            ..Default::default()
350        };
351        let outcome = runner.run(HookEvent::PreToolUse, &ctx).await;
352        // Exit 1 is NOT a block. Tool should proceed.
353        assert!(!outcome.block);
354    }
355
356    #[tokio::test]
357    async fn pipe_matcher_matches_either() {
358        let runner = CommandHookRunner::new(vec![spec(
359            HookEvent::PreToolUse,
360            Some("bash|write"),
361            "exit 2",
362        )])
363        .unwrap();
364        for tool in ["bash", "write"] {
365            let ctx = HookContext {
366                event: HookEvent::PreToolUse,
367                tool_name: Some(tool.into()),
368                ..Default::default()
369            };
370            let outcome = runner.run(HookEvent::PreToolUse, &ctx).await;
371            assert!(outcome.block, "expected block for tool={tool}");
372        }
373        // And a non-matching tool passes.
374        let ctx = HookContext {
375            event: HookEvent::PreToolUse,
376            tool_name: Some("read".into()),
377            ..Default::default()
378        };
379        let outcome = runner.run(HookEvent::PreToolUse, &ctx).await;
380        assert!(!outcome.block);
381    }
382
383    #[tokio::test]
384    async fn stdout_json_overrides_content() {
385        let runner = CommandHookRunner::new(vec![spec(
386            HookEvent::PostToolUse,
387            Some("read"),
388            r#"echo '{"override_content":"replaced"}'"#,
389        )])
390        .unwrap();
391        let ctx = HookContext {
392            event: HookEvent::PostToolUse,
393            tool_name: Some("read".into()),
394            tool_result: Some("original".into()),
395            ..Default::default()
396        };
397        let outcome = runner.run(HookEvent::PostToolUse, &ctx).await;
398        assert_eq!(outcome.override_content.as_deref(), Some("replaced"));
399    }
400
401    #[tokio::test]
402    async fn multiple_matching_scripts_run_sequentially() {
403        let runner = CommandHookRunner::new(vec![
404            spec(HookEvent::PreToolUse, Some("bash"), "exit 0"),
405            spec(HookEvent::PreToolUse, Some("bash"), "exit 2"),
406        ])
407        .unwrap();
408        let ctx = HookContext {
409            event: HookEvent::PreToolUse,
410            tool_name: Some("bash".into()),
411            ..Default::default()
412        };
413        let outcome = runner.run(HookEvent::PreToolUse, &ctx).await;
414        // Second script blocks; first passes. We should see block=true.
415        assert!(outcome.block);
416    }
417
418    #[tokio::test]
419    async fn empty_matcher_segment_errors_at_construction() {
420        let bad = vec![spec(HookEvent::PreToolUse, Some("bash||write"), "true")];
421        let err = CommandHookRunner::new(bad).unwrap_err();
422        assert!(err.0.contains("empty matcher"));
423    }
424
425    #[tokio::test]
426    async fn invalid_glob_errors_at_construction() {
427        let bad = vec![spec(HookEvent::PreToolUse, Some("["), "true")];
428        assert!(CommandHookRunner::new(bad).is_err());
429    }
430
431    #[tokio::test]
432    async fn event_must_match() {
433        // A spec for PreToolUse should NOT fire for Stop.
434        let runner =
435            CommandHookRunner::new(vec![spec(HookEvent::PreToolUse, None, "exit 2")]).unwrap();
436        let ctx = HookContext {
437            event: HookEvent::Stop,
438            ..Default::default()
439        };
440        let outcome = runner.run(HookEvent::Stop, &ctx).await;
441        assert!(!outcome.block);
442    }
443}