1use 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
23pub const DEFAULT_HOOK_TIMEOUT_SECS: u64 = 60;
25
26#[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>, timeout: Duration,
42 command: String,
43}
44
45pub 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 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 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 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 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 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
146async 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 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 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 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 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 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 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 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 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 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}