use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::Read as _;
use std::process::Command;
use std::time::Duration;
use wait_timeout::ChildExt;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HookEvent {
SessionStart,
SessionEnd,
PreToolUse,
PostToolUse,
PostToolUseFailure,
RunStart,
RunEnd,
PreCompact,
PostCompact,
UserPromptSubmit,
ConfigChange,
}
impl std::fmt::Display for HookEvent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::SessionStart => write!(f, "session_start"),
Self::SessionEnd => write!(f, "session_end"),
Self::PreToolUse => write!(f, "pre_tool_use"),
Self::PostToolUse => write!(f, "post_tool_use"),
Self::PostToolUseFailure => write!(f, "post_tool_use_failure"),
Self::RunStart => write!(f, "run_start"),
Self::RunEnd => write!(f, "run_end"),
Self::PreCompact => write!(f, "pre_compact"),
Self::PostCompact => write!(f, "post_compact"),
Self::UserPromptSubmit => write!(f, "user_prompt_submit"),
Self::ConfigChange => write!(f, "config_change"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookConfig {
pub event: HookEvent,
pub matcher: Option<String>,
pub command: String,
#[serde(default = "default_timeout")]
pub timeout_secs: u32,
#[serde(default)]
pub blocking: bool,
}
fn default_timeout() -> u32 {
10
}
#[derive(Debug, Clone, Default)]
pub struct HookContext {
pub session_id: String,
pub tool_name: Option<String>,
pub tool_input: Option<serde_json::Value>,
pub workspace: String,
}
#[derive(Debug)]
pub struct HookResult {
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
pub timed_out: bool,
}
#[derive(Debug, thiserror::Error)]
#[error("hook blocked operation: {message}")]
pub struct HookDenied {
pub message: String,
}
#[derive(Debug, Clone)]
pub struct HookRegistry {
hooks: Vec<HookConfig>,
}
impl HookRegistry {
pub fn new() -> Self {
Self { hooks: Vec::new() }
}
pub fn from_configs(configs: Vec<HookConfig>) -> Self {
Self { hooks: configs }
}
pub fn len(&self) -> usize {
self.hooks.len()
}
pub fn is_empty(&self) -> bool {
self.hooks.is_empty()
}
pub fn fire(&self, event: &HookEvent, ctx: &HookContext) -> Vec<HookResult> {
self.matching_hooks(event, ctx)
.into_iter()
.map(|hook| execute_hook(hook, ctx))
.collect()
}
pub fn check_blocking(&self, event: &HookEvent, ctx: &HookContext) -> Result<(), HookDenied> {
for hook in self.matching_hooks(event, ctx) {
if !hook.blocking {
continue;
}
let result = execute_hook(hook, ctx);
if result.exit_code != 0 {
let message = if result.timed_out {
format!(
"hook timed out after {}s: {}",
hook.timeout_secs, hook.command
)
} else if result.stderr.is_empty() {
format!(
"hook exited with code {}: {}",
result.exit_code, hook.command
)
} else {
result.stderr.trim().to_string()
};
return Err(HookDenied { message });
}
}
Ok(())
}
fn matching_hooks<'a>(&'a self, event: &HookEvent, ctx: &HookContext) -> Vec<&'a HookConfig> {
self.hooks
.iter()
.filter(|hook| {
if &hook.event != event {
return false;
}
if let Some(ref matcher) = hook.matcher {
if let Some(ref tool_name) = ctx.tool_name {
tool_name == matcher
} else {
false
}
} else {
true
}
})
.collect()
}
}
impl Default for HookRegistry {
fn default() -> Self {
Self::new()
}
}
fn expand_placeholders(command: &str, ctx: &HookContext) -> String {
let mut expanded = command.to_string();
expanded = expanded.replace("{session_id}", &ctx.session_id);
expanded = expanded.replace("{workspace}", &ctx.workspace);
if let Some(ref tool_name) = ctx.tool_name {
expanded = expanded.replace("{tool_name}", tool_name);
} else {
expanded = expanded.replace("{tool_name}", "");
}
if let Some(ref tool_input) = ctx.tool_input {
expanded = expanded.replace("{tool_input}", &tool_input.to_string());
} else {
expanded = expanded.replace("{tool_input}", "");
}
expanded
}
fn execute_hook(hook: &HookConfig, ctx: &HookContext) -> HookResult {
let command = expand_placeholders(&hook.command, ctx);
let timeout = Duration::from_secs(u64::from(hook.timeout_secs));
let mut env: HashMap<String, String> = std::env::vars().collect();
env.insert("ARCAN_SESSION_ID".to_string(), ctx.session_id.clone());
env.insert("ARCAN_WORKSPACE".to_string(), ctx.workspace.clone());
env.insert("ARCAN_HOOK_EVENT".to_string(), hook.event.to_string());
if let Some(ref tool_name) = ctx.tool_name {
env.insert("ARCAN_TOOL_NAME".to_string(), tool_name.clone());
}
if let Some(ref tool_input) = ctx.tool_input {
env.insert("ARCAN_TOOL_INPUT".to_string(), tool_input.to_string());
}
let child_result = Command::new("sh")
.arg("-c")
.arg(&command)
.envs(&env)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn();
let mut child = match child_result {
Ok(c) => c,
Err(e) => {
return HookResult {
exit_code: -1,
stdout: String::new(),
stderr: format!("failed to spawn hook: {e}"),
timed_out: false,
};
}
};
match child.wait_timeout(timeout) {
Ok(Some(status)) => {
let mut stdout = String::new();
let mut stderr = String::new();
if let Some(ref mut out) = child.stdout {
let _ = out.read_to_string(&mut stdout);
}
if let Some(ref mut err) = child.stderr {
let _ = err.read_to_string(&mut stderr);
}
HookResult {
exit_code: status.code().unwrap_or(-1),
stdout,
stderr,
timed_out: false,
}
}
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
let mut stdout = String::new();
let mut stderr = String::new();
if let Some(ref mut out) = child.stdout {
let _ = out.read_to_string(&mut stdout);
}
if let Some(ref mut err) = child.stderr {
let _ = err.read_to_string(&mut stderr);
}
HookResult {
exit_code: -1,
stdout,
stderr,
timed_out: true,
}
}
Err(e) => HookResult {
exit_code: -1,
stdout: String::new(),
stderr: format!("failed to wait on hook: {e}"),
timed_out: false,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_ctx() -> HookContext {
HookContext {
session_id: "test-session-42".to_string(),
tool_name: Some("bash".to_string()),
tool_input: Some(serde_json::json!({"command": "ls"})),
workspace: "/tmp/test-workspace".to_string(),
}
}
#[test]
fn test_fire_matching_hooks() {
let registry = HookRegistry::from_configs(vec![
HookConfig {
event: HookEvent::SessionStart,
matcher: None,
command: "echo session_start".to_string(),
timeout_secs: 5,
blocking: false,
},
HookConfig {
event: HookEvent::RunEnd,
matcher: None,
command: "echo run_end".to_string(),
timeout_secs: 5,
blocking: false,
},
HookConfig {
event: HookEvent::SessionStart,
matcher: None,
command: "echo session_start_2".to_string(),
timeout_secs: 5,
blocking: false,
},
]);
let ctx = make_ctx();
let results = registry.fire(&HookEvent::SessionStart, &ctx);
assert_eq!(results.len(), 2);
assert!(results[0].stdout.contains("session_start"));
assert!(results[1].stdout.contains("session_start_2"));
let results = registry.fire(&HookEvent::RunEnd, &ctx);
assert_eq!(results.len(), 1);
assert!(results[0].stdout.contains("run_end"));
let results = registry.fire(&HookEvent::PreToolUse, &ctx);
assert_eq!(results.len(), 0);
}
#[test]
fn test_matcher_filters() {
let registry = HookRegistry::from_configs(vec![
HookConfig {
event: HookEvent::PreToolUse,
matcher: Some("bash".to_string()),
command: "echo matched_bash".to_string(),
timeout_secs: 5,
blocking: false,
},
HookConfig {
event: HookEvent::PreToolUse,
matcher: Some("file_edit".to_string()),
command: "echo matched_file_edit".to_string(),
timeout_secs: 5,
blocking: false,
},
HookConfig {
event: HookEvent::PreToolUse,
matcher: None,
command: "echo matched_all".to_string(),
timeout_secs: 5,
blocking: false,
},
]);
let ctx = HookContext {
tool_name: Some("bash".to_string()),
..make_ctx()
};
let results = registry.fire(&HookEvent::PreToolUse, &ctx);
assert_eq!(results.len(), 2);
assert!(results[0].stdout.contains("matched_bash"));
assert!(results[1].stdout.contains("matched_all"));
let ctx = HookContext {
tool_name: Some("file_edit".to_string()),
..make_ctx()
};
let results = registry.fire(&HookEvent::PreToolUse, &ctx);
assert_eq!(results.len(), 2);
assert!(results[0].stdout.contains("matched_file_edit"));
assert!(results[1].stdout.contains("matched_all"));
let ctx = HookContext {
tool_name: None,
..make_ctx()
};
let results = registry.fire(&HookEvent::PreToolUse, &ctx);
assert_eq!(results.len(), 1);
assert!(results[0].stdout.contains("matched_all"));
}
#[test]
fn test_blocking_hook_denies() {
let registry = HookRegistry::from_configs(vec![
HookConfig {
event: HookEvent::PreToolUse,
matcher: None,
command: "echo 'allowed' && exit 0".to_string(),
timeout_secs: 5,
blocking: true,
},
HookConfig {
event: HookEvent::PreToolUse,
matcher: None,
command: "echo 'denied' >&2 && exit 1".to_string(),
timeout_secs: 5,
blocking: true,
},
]);
let ctx = make_ctx();
let result = registry.check_blocking(&HookEvent::PreToolUse, &ctx);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.message.contains("denied"),
"expected 'denied' in error: {}",
err.message
);
}
#[test]
fn test_blocking_hook_allows() {
let registry = HookRegistry::from_configs(vec![HookConfig {
event: HookEvent::PreToolUse,
matcher: None,
command: "exit 0".to_string(),
timeout_secs: 5,
blocking: true,
}]);
let ctx = make_ctx();
let result = registry.check_blocking(&HookEvent::PreToolUse, &ctx);
assert!(result.is_ok());
}
#[test]
fn test_non_blocking_hooks_ignored_in_check() {
let registry = HookRegistry::from_configs(vec![HookConfig {
event: HookEvent::PreToolUse,
matcher: None,
command: "exit 1".to_string(),
timeout_secs: 5,
blocking: false, }]);
let ctx = make_ctx();
let result = registry.check_blocking(&HookEvent::PreToolUse, &ctx);
assert!(result.is_ok());
}
#[test]
fn test_timeout_handling() {
let registry = HookRegistry::from_configs(vec![HookConfig {
event: HookEvent::RunEnd,
matcher: None,
command: "sleep 30".to_string(),
timeout_secs: 1, blocking: false,
}]);
let ctx = make_ctx();
let results = registry.fire(&HookEvent::RunEnd, &ctx);
assert_eq!(results.len(), 1);
assert!(results[0].timed_out);
assert_eq!(results[0].exit_code, -1);
}
#[test]
fn test_placeholder_expansion() {
let registry = HookRegistry::from_configs(vec![HookConfig {
event: HookEvent::PreToolUse,
matcher: None,
command: "echo 'tool={tool_name} session={session_id} ws={workspace}'".to_string(),
timeout_secs: 5,
blocking: false,
}]);
let ctx = make_ctx();
let results = registry.fire(&HookEvent::PreToolUse, &ctx);
assert_eq!(results.len(), 1);
assert_eq!(results[0].exit_code, 0);
let stdout = &results[0].stdout;
assert!(
stdout.contains("tool=bash"),
"expected tool=bash in stdout: {stdout}"
);
assert!(
stdout.contains("session=test-session-42"),
"expected session=test-session-42 in stdout: {stdout}"
);
assert!(
stdout.contains("ws=/tmp/test-workspace"),
"expected ws=/tmp/test-workspace in stdout: {stdout}"
);
}
#[test]
fn test_environment_variables_set() {
let registry = HookRegistry::from_configs(vec![HookConfig {
event: HookEvent::PreToolUse,
matcher: None,
command:
"echo \"$ARCAN_SESSION_ID|$ARCAN_WORKSPACE|$ARCAN_TOOL_NAME|$ARCAN_HOOK_EVENT\""
.to_string(),
timeout_secs: 5,
blocking: false,
}]);
let ctx = make_ctx();
let results = registry.fire(&HookEvent::PreToolUse, &ctx);
assert_eq!(results.len(), 1);
let stdout = results[0].stdout.trim();
assert_eq!(
stdout,
"test-session-42|/tmp/test-workspace|bash|pre_tool_use"
);
}
#[test]
fn test_empty_registry() {
let registry = HookRegistry::new();
assert!(registry.is_empty());
assert_eq!(registry.len(), 0);
let ctx = make_ctx();
let results = registry.fire(&HookEvent::SessionStart, &ctx);
assert!(results.is_empty());
assert!(
registry
.check_blocking(&HookEvent::PreToolUse, &ctx)
.is_ok()
);
}
#[test]
fn test_hook_event_serde_roundtrip() {
let events = vec![
HookEvent::SessionStart,
HookEvent::SessionEnd,
HookEvent::PreToolUse,
HookEvent::PostToolUse,
HookEvent::PostToolUseFailure,
HookEvent::RunStart,
HookEvent::RunEnd,
HookEvent::PreCompact,
HookEvent::PostCompact,
HookEvent::UserPromptSubmit,
HookEvent::ConfigChange,
];
for event in events {
let json = serde_json::to_string(&event).unwrap();
let deserialized: HookEvent = serde_json::from_str(&json).unwrap();
assert_eq!(event, deserialized);
}
}
#[test]
fn test_hook_config_serde_defaults() {
let json = r#"{"event":"run_end","command":"echo done"}"#;
let config: HookConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.event, HookEvent::RunEnd);
assert_eq!(config.command, "echo done");
assert_eq!(config.timeout_secs, 10); assert!(!config.blocking); assert!(config.matcher.is_none());
}
}