1use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::io::Read as _;
14use std::process::Command;
15use std::time::Duration;
16use wait_timeout::ChildExt;
17
18#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum HookEvent {
22 SessionStart,
24 SessionEnd,
26 PreToolUse,
28 PostToolUse,
30 PostToolUseFailure,
32 RunStart,
34 RunEnd,
36 PreCompact,
38 PostCompact,
40 UserPromptSubmit,
42 ConfigChange,
44}
45
46impl std::fmt::Display for HookEvent {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 match self {
49 Self::SessionStart => write!(f, "session_start"),
50 Self::SessionEnd => write!(f, "session_end"),
51 Self::PreToolUse => write!(f, "pre_tool_use"),
52 Self::PostToolUse => write!(f, "post_tool_use"),
53 Self::PostToolUseFailure => write!(f, "post_tool_use_failure"),
54 Self::RunStart => write!(f, "run_start"),
55 Self::RunEnd => write!(f, "run_end"),
56 Self::PreCompact => write!(f, "pre_compact"),
57 Self::PostCompact => write!(f, "post_compact"),
58 Self::UserPromptSubmit => write!(f, "user_prompt_submit"),
59 Self::ConfigChange => write!(f, "config_change"),
60 }
61 }
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct HookConfig {
67 pub event: HookEvent,
69 pub matcher: Option<String>,
72 pub command: String,
74 #[serde(default = "default_timeout")]
76 pub timeout_secs: u32,
77 #[serde(default)]
79 pub blocking: bool,
80}
81
82fn default_timeout() -> u32 {
83 10
84}
85
86#[derive(Debug, Clone, Default)]
88pub struct HookContext {
89 pub session_id: String,
91 pub tool_name: Option<String>,
93 pub tool_input: Option<serde_json::Value>,
95 pub workspace: String,
97}
98
99#[derive(Debug)]
101pub struct HookResult {
102 pub exit_code: i32,
104 pub stdout: String,
106 pub stderr: String,
108 pub timed_out: bool,
110}
111
112#[derive(Debug, thiserror::Error)]
114#[error("hook blocked operation: {message}")]
115pub struct HookDenied {
116 pub message: String,
118}
119
120#[derive(Debug, Clone)]
122pub struct HookRegistry {
123 hooks: Vec<HookConfig>,
124}
125
126impl HookRegistry {
127 pub fn new() -> Self {
129 Self { hooks: Vec::new() }
130 }
131
132 pub fn from_configs(configs: Vec<HookConfig>) -> Self {
134 Self { hooks: configs }
135 }
136
137 pub fn len(&self) -> usize {
139 self.hooks.len()
140 }
141
142 pub fn is_empty(&self) -> bool {
144 self.hooks.is_empty()
145 }
146
147 pub fn fire(&self, event: &HookEvent, ctx: &HookContext) -> Vec<HookResult> {
152 self.matching_hooks(event, ctx)
153 .into_iter()
154 .map(|hook| execute_hook(hook, ctx))
155 .collect()
156 }
157
158 pub fn check_blocking(&self, event: &HookEvent, ctx: &HookContext) -> Result<(), HookDenied> {
163 for hook in self.matching_hooks(event, ctx) {
164 if !hook.blocking {
165 continue;
166 }
167 let result = execute_hook(hook, ctx);
168 if result.exit_code != 0 {
169 let message = if result.timed_out {
170 format!(
171 "hook timed out after {}s: {}",
172 hook.timeout_secs, hook.command
173 )
174 } else if result.stderr.is_empty() {
175 format!(
176 "hook exited with code {}: {}",
177 result.exit_code, hook.command
178 )
179 } else {
180 result.stderr.trim().to_string()
181 };
182 return Err(HookDenied { message });
183 }
184 }
185 Ok(())
186 }
187
188 fn matching_hooks<'a>(&'a self, event: &HookEvent, ctx: &HookContext) -> Vec<&'a HookConfig> {
190 self.hooks
191 .iter()
192 .filter(|hook| {
193 if &hook.event != event {
194 return false;
195 }
196 if let Some(ref matcher) = hook.matcher {
198 if let Some(ref tool_name) = ctx.tool_name {
199 tool_name == matcher
200 } else {
201 false
203 }
204 } else {
205 true
206 }
207 })
208 .collect()
209 }
210}
211
212impl Default for HookRegistry {
213 fn default() -> Self {
214 Self::new()
215 }
216}
217
218fn expand_placeholders(command: &str, ctx: &HookContext) -> String {
220 let mut expanded = command.to_string();
221 expanded = expanded.replace("{session_id}", &ctx.session_id);
222 expanded = expanded.replace("{workspace}", &ctx.workspace);
223 if let Some(ref tool_name) = ctx.tool_name {
224 expanded = expanded.replace("{tool_name}", tool_name);
225 } else {
226 expanded = expanded.replace("{tool_name}", "");
227 }
228 if let Some(ref tool_input) = ctx.tool_input {
229 expanded = expanded.replace("{tool_input}", &tool_input.to_string());
230 } else {
231 expanded = expanded.replace("{tool_input}", "");
232 }
233 expanded
234}
235
236fn execute_hook(hook: &HookConfig, ctx: &HookContext) -> HookResult {
238 let command = expand_placeholders(&hook.command, ctx);
239 let timeout = Duration::from_secs(u64::from(hook.timeout_secs));
240
241 let mut env: HashMap<String, String> = std::env::vars().collect();
243 env.insert("ARCAN_SESSION_ID".to_string(), ctx.session_id.clone());
244 env.insert("ARCAN_WORKSPACE".to_string(), ctx.workspace.clone());
245 env.insert("ARCAN_HOOK_EVENT".to_string(), hook.event.to_string());
246 if let Some(ref tool_name) = ctx.tool_name {
247 env.insert("ARCAN_TOOL_NAME".to_string(), tool_name.clone());
248 }
249 if let Some(ref tool_input) = ctx.tool_input {
250 env.insert("ARCAN_TOOL_INPUT".to_string(), tool_input.to_string());
251 }
252
253 let child_result = Command::new("sh")
254 .arg("-c")
255 .arg(&command)
256 .envs(&env)
257 .stdout(std::process::Stdio::piped())
258 .stderr(std::process::Stdio::piped())
259 .spawn();
260
261 let mut child = match child_result {
262 Ok(c) => c,
263 Err(e) => {
264 return HookResult {
265 exit_code: -1,
266 stdout: String::new(),
267 stderr: format!("failed to spawn hook: {e}"),
268 timed_out: false,
269 };
270 }
271 };
272
273 match child.wait_timeout(timeout) {
275 Ok(Some(status)) => {
276 let mut stdout = String::new();
277 let mut stderr = String::new();
278 if let Some(ref mut out) = child.stdout {
279 let _ = out.read_to_string(&mut stdout);
280 }
281 if let Some(ref mut err) = child.stderr {
282 let _ = err.read_to_string(&mut stderr);
283 }
284 HookResult {
285 exit_code: status.code().unwrap_or(-1),
286 stdout,
287 stderr,
288 timed_out: false,
289 }
290 }
291 Ok(None) => {
292 let _ = child.kill();
294 let _ = child.wait();
295 let mut stdout = String::new();
296 let mut stderr = String::new();
297 if let Some(ref mut out) = child.stdout {
298 let _ = out.read_to_string(&mut stdout);
299 }
300 if let Some(ref mut err) = child.stderr {
301 let _ = err.read_to_string(&mut stderr);
302 }
303 HookResult {
304 exit_code: -1,
305 stdout,
306 stderr,
307 timed_out: true,
308 }
309 }
310 Err(e) => HookResult {
311 exit_code: -1,
312 stdout: String::new(),
313 stderr: format!("failed to wait on hook: {e}"),
314 timed_out: false,
315 },
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 fn make_ctx() -> HookContext {
324 HookContext {
325 session_id: "test-session-42".to_string(),
326 tool_name: Some("bash".to_string()),
327 tool_input: Some(serde_json::json!({"command": "ls"})),
328 workspace: "/tmp/test-workspace".to_string(),
329 }
330 }
331
332 #[test]
333 fn test_fire_matching_hooks() {
334 let registry = HookRegistry::from_configs(vec![
335 HookConfig {
336 event: HookEvent::SessionStart,
337 matcher: None,
338 command: "echo session_start".to_string(),
339 timeout_secs: 5,
340 blocking: false,
341 },
342 HookConfig {
343 event: HookEvent::RunEnd,
344 matcher: None,
345 command: "echo run_end".to_string(),
346 timeout_secs: 5,
347 blocking: false,
348 },
349 HookConfig {
350 event: HookEvent::SessionStart,
351 matcher: None,
352 command: "echo session_start_2".to_string(),
353 timeout_secs: 5,
354 blocking: false,
355 },
356 ]);
357
358 let ctx = make_ctx();
359
360 let results = registry.fire(&HookEvent::SessionStart, &ctx);
362 assert_eq!(results.len(), 2);
363 assert!(results[0].stdout.contains("session_start"));
364 assert!(results[1].stdout.contains("session_start_2"));
365
366 let results = registry.fire(&HookEvent::RunEnd, &ctx);
368 assert_eq!(results.len(), 1);
369 assert!(results[0].stdout.contains("run_end"));
370
371 let results = registry.fire(&HookEvent::PreToolUse, &ctx);
373 assert_eq!(results.len(), 0);
374 }
375
376 #[test]
377 fn test_matcher_filters() {
378 let registry = HookRegistry::from_configs(vec![
379 HookConfig {
380 event: HookEvent::PreToolUse,
381 matcher: Some("bash".to_string()),
382 command: "echo matched_bash".to_string(),
383 timeout_secs: 5,
384 blocking: false,
385 },
386 HookConfig {
387 event: HookEvent::PreToolUse,
388 matcher: Some("file_edit".to_string()),
389 command: "echo matched_file_edit".to_string(),
390 timeout_secs: 5,
391 blocking: false,
392 },
393 HookConfig {
394 event: HookEvent::PreToolUse,
395 matcher: None,
396 command: "echo matched_all".to_string(),
397 timeout_secs: 5,
398 blocking: false,
399 },
400 ]);
401
402 let ctx = HookContext {
404 tool_name: Some("bash".to_string()),
405 ..make_ctx()
406 };
407 let results = registry.fire(&HookEvent::PreToolUse, &ctx);
408 assert_eq!(results.len(), 2);
409 assert!(results[0].stdout.contains("matched_bash"));
410 assert!(results[1].stdout.contains("matched_all"));
411
412 let ctx = HookContext {
414 tool_name: Some("file_edit".to_string()),
415 ..make_ctx()
416 };
417 let results = registry.fire(&HookEvent::PreToolUse, &ctx);
418 assert_eq!(results.len(), 2);
419 assert!(results[0].stdout.contains("matched_file_edit"));
420 assert!(results[1].stdout.contains("matched_all"));
421
422 let ctx = HookContext {
424 tool_name: None,
425 ..make_ctx()
426 };
427 let results = registry.fire(&HookEvent::PreToolUse, &ctx);
428 assert_eq!(results.len(), 1);
429 assert!(results[0].stdout.contains("matched_all"));
430 }
431
432 #[test]
433 fn test_blocking_hook_denies() {
434 let registry = HookRegistry::from_configs(vec![
435 HookConfig {
436 event: HookEvent::PreToolUse,
437 matcher: None,
438 command: "echo 'allowed' && exit 0".to_string(),
439 timeout_secs: 5,
440 blocking: true,
441 },
442 HookConfig {
443 event: HookEvent::PreToolUse,
444 matcher: None,
445 command: "echo 'denied' >&2 && exit 1".to_string(),
446 timeout_secs: 5,
447 blocking: true,
448 },
449 ]);
450
451 let ctx = make_ctx();
452 let result = registry.check_blocking(&HookEvent::PreToolUse, &ctx);
453 assert!(result.is_err());
454 let err = result.unwrap_err();
455 assert!(
456 err.message.contains("denied"),
457 "expected 'denied' in error: {}",
458 err.message
459 );
460 }
461
462 #[test]
463 fn test_blocking_hook_allows() {
464 let registry = HookRegistry::from_configs(vec![HookConfig {
465 event: HookEvent::PreToolUse,
466 matcher: None,
467 command: "exit 0".to_string(),
468 timeout_secs: 5,
469 blocking: true,
470 }]);
471
472 let ctx = make_ctx();
473 let result = registry.check_blocking(&HookEvent::PreToolUse, &ctx);
474 assert!(result.is_ok());
475 }
476
477 #[test]
478 fn test_non_blocking_hooks_ignored_in_check() {
479 let registry = HookRegistry::from_configs(vec![HookConfig {
481 event: HookEvent::PreToolUse,
482 matcher: None,
483 command: "exit 1".to_string(),
484 timeout_secs: 5,
485 blocking: false, }]);
487
488 let ctx = make_ctx();
489 let result = registry.check_blocking(&HookEvent::PreToolUse, &ctx);
490 assert!(result.is_ok());
491 }
492
493 #[test]
494 fn test_timeout_handling() {
495 let registry = HookRegistry::from_configs(vec![HookConfig {
496 event: HookEvent::RunEnd,
497 matcher: None,
498 command: "sleep 30".to_string(),
499 timeout_secs: 1, blocking: false,
501 }]);
502
503 let ctx = make_ctx();
504 let results = registry.fire(&HookEvent::RunEnd, &ctx);
505 assert_eq!(results.len(), 1);
506 assert!(results[0].timed_out);
507 assert_eq!(results[0].exit_code, -1);
508 }
509
510 #[test]
511 fn test_placeholder_expansion() {
512 let registry = HookRegistry::from_configs(vec![HookConfig {
513 event: HookEvent::PreToolUse,
514 matcher: None,
515 command: "echo 'tool={tool_name} session={session_id} ws={workspace}'".to_string(),
516 timeout_secs: 5,
517 blocking: false,
518 }]);
519
520 let ctx = make_ctx();
521 let results = registry.fire(&HookEvent::PreToolUse, &ctx);
522 assert_eq!(results.len(), 1);
523 assert_eq!(results[0].exit_code, 0);
524 let stdout = &results[0].stdout;
525 assert!(
526 stdout.contains("tool=bash"),
527 "expected tool=bash in stdout: {stdout}"
528 );
529 assert!(
530 stdout.contains("session=test-session-42"),
531 "expected session=test-session-42 in stdout: {stdout}"
532 );
533 assert!(
534 stdout.contains("ws=/tmp/test-workspace"),
535 "expected ws=/tmp/test-workspace in stdout: {stdout}"
536 );
537 }
538
539 #[test]
540 fn test_environment_variables_set() {
541 let registry = HookRegistry::from_configs(vec![HookConfig {
542 event: HookEvent::PreToolUse,
543 matcher: None,
544 command:
545 "echo \"$ARCAN_SESSION_ID|$ARCAN_WORKSPACE|$ARCAN_TOOL_NAME|$ARCAN_HOOK_EVENT\""
546 .to_string(),
547 timeout_secs: 5,
548 blocking: false,
549 }]);
550
551 let ctx = make_ctx();
552 let results = registry.fire(&HookEvent::PreToolUse, &ctx);
553 assert_eq!(results.len(), 1);
554 let stdout = results[0].stdout.trim();
555 assert_eq!(
556 stdout,
557 "test-session-42|/tmp/test-workspace|bash|pre_tool_use"
558 );
559 }
560
561 #[test]
562 fn test_empty_registry() {
563 let registry = HookRegistry::new();
564 assert!(registry.is_empty());
565 assert_eq!(registry.len(), 0);
566
567 let ctx = make_ctx();
568 let results = registry.fire(&HookEvent::SessionStart, &ctx);
569 assert!(results.is_empty());
570
571 assert!(
573 registry
574 .check_blocking(&HookEvent::PreToolUse, &ctx)
575 .is_ok()
576 );
577 }
578
579 #[test]
580 fn test_hook_event_serde_roundtrip() {
581 let events = vec![
582 HookEvent::SessionStart,
583 HookEvent::SessionEnd,
584 HookEvent::PreToolUse,
585 HookEvent::PostToolUse,
586 HookEvent::PostToolUseFailure,
587 HookEvent::RunStart,
588 HookEvent::RunEnd,
589 HookEvent::PreCompact,
590 HookEvent::PostCompact,
591 HookEvent::UserPromptSubmit,
592 HookEvent::ConfigChange,
593 ];
594
595 for event in events {
596 let json = serde_json::to_string(&event).unwrap();
597 let deserialized: HookEvent = serde_json::from_str(&json).unwrap();
598 assert_eq!(event, deserialized);
599 }
600 }
601
602 #[test]
603 fn test_hook_config_serde_defaults() {
604 let json = r#"{"event":"run_end","command":"echo done"}"#;
605 let config: HookConfig = serde_json::from_str(json).unwrap();
606 assert_eq!(config.event, HookEvent::RunEnd);
607 assert_eq!(config.command, "echo done");
608 assert_eq!(config.timeout_secs, 10); assert!(!config.blocking); assert!(config.matcher.is_none());
611 }
612}