#![allow(dead_code)]
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum HookEvent {
SessionStart,
PreToolUse,
PostToolUse,
PreCompact,
Stop,
SessionEnd,
SubagentStart,
SubagentStop,
}
impl HookEvent {
#[allow(dead_code)]
pub fn all() -> &'static [HookEvent] {
&[
HookEvent::SessionStart,
HookEvent::PreToolUse,
HookEvent::PostToolUse,
HookEvent::PreCompact,
HookEvent::Stop,
HookEvent::SessionEnd,
HookEvent::SubagentStart,
HookEvent::SubagentStop,
]
}
pub fn from_str_name(s: &str) -> Option<Self> {
match s {
"SessionStart" => Some(HookEvent::SessionStart),
"PreToolUse" => Some(HookEvent::PreToolUse),
"PostToolUse" => Some(HookEvent::PostToolUse),
"PreCompact" => Some(HookEvent::PreCompact),
"Stop" => Some(HookEvent::Stop),
"SessionEnd" => Some(HookEvent::SessionEnd),
"SubagentStart" => Some(HookEvent::SubagentStart),
"SubagentStop" => Some(HookEvent::SubagentStop),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct HookConfig {
raw: serde_json::Value,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct RegisteredHook {
pub event: HookEvent,
pub matcher: String,
pub command: String,
pub description: Option<String>,
pub is_async: bool,
pub timeout_secs: Option<u64>,
pub plugin_root: PathBuf,
}
#[derive(Debug, Clone, Default)]
pub struct HookRuntime {
hooks: HashMap<HookEvent, Vec<RegisteredHook>>,
}
#[derive(Debug)]
pub enum HookAction {
Allow,
Block(String),
Error(String),
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct HookContext {
pub tool_name: Option<String>,
pub tool_args: Option<String>,
pub tool_result: Option<String>,
pub tool_success: Option<bool>,
pub working_dir: PathBuf,
}
impl HookContext {
pub fn pre_tool_use(tool_name: &str, args: &str, working_dir: &Path) -> Self {
Self {
tool_name: Some(tool_name.to_string()),
tool_args: Some(args.to_string()),
tool_result: None,
tool_success: None,
working_dir: working_dir.to_path_buf(),
}
}
pub fn post_tool_use(tool_name: &str, result: &str, success: bool, working_dir: &Path) -> Self {
Self {
tool_name: Some(tool_name.to_string()),
tool_args: None,
tool_result: Some(result.to_string()),
tool_success: Some(success),
working_dir: working_dir.to_path_buf(),
}
}
pub fn simple(working_dir: &Path) -> Self {
Self {
tool_name: None,
tool_args: None,
tool_result: None,
tool_success: None,
working_dir: working_dir.to_path_buf(),
}
}
pub fn stop(reason: &str, working_dir: &Path) -> Self {
Self {
tool_name: Some(reason.to_string()),
tool_args: None,
tool_result: None,
tool_success: None,
working_dir: working_dir.to_path_buf(),
}
}
pub fn subagent_start(agent_id: &str, agent_name: &str, working_dir: &Path) -> Self {
Self {
tool_name: Some(agent_name.to_string()),
tool_args: Some(agent_id.to_string()),
tool_result: None,
tool_success: None,
working_dir: working_dir.to_path_buf(),
}
}
pub fn subagent_stop(
agent_id: &str,
agent_name: &str,
success: bool,
working_dir: &Path,
) -> Self {
Self {
tool_name: Some(agent_name.to_string()),
tool_args: Some(agent_id.to_string()),
tool_result: None,
tool_success: Some(success),
working_dir: working_dir.to_path_buf(),
}
}
}
impl HookRuntime {
pub fn new() -> Self {
Self {
hooks: HashMap::new(),
}
}
pub fn merge(&mut self, config: &HookConfig, plugin_root: &Path) {
let hooks_obj = match config.raw.get("hooks") {
Some(serde_json::Value::Object(obj)) => obj,
_ => return,
};
for (event_name, registrations) in hooks_obj {
let event = match HookEvent::from_str_name(event_name) {
Some(e) => e,
None => {
tracing::debug!(event = %event_name, "Unknown hook event, skipping");
continue;
}
};
let reg_list = match registrations.as_array() {
Some(arr) => arr,
None => continue,
};
for reg in reg_list {
let matcher = reg
.get("matcher")
.and_then(|v| v.as_str())
.unwrap_or("*")
.to_string();
let description = reg
.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let inner_hooks = match reg.get("hooks").and_then(|v| v.as_array()) {
Some(arr) => arr,
None => continue,
};
for hook_def in inner_hooks {
let command = match hook_def.get("command").and_then(|v| v.as_str()) {
Some(cmd) => cmd.to_string(),
None => continue,
};
let is_async = hook_def
.get("async")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let timeout_secs = hook_def
.get("timeout")
.and_then(|v| v.as_u64())
.or(if is_async { Some(5) } else { None });
self.hooks.entry(event).or_default().push(RegisteredHook {
event,
matcher: matcher.clone(),
command,
description: description.clone(),
is_async,
timeout_secs,
plugin_root: plugin_root.to_path_buf(),
});
}
}
}
}
pub async fn fire(&self, event: HookEvent, ctx: &HookContext) -> Vec<HookAction> {
let hooks = match self.hooks.get(&event) {
Some(h) => h,
None => return Vec::new(),
};
let mut results = Vec::new();
for hook in hooks {
if !matcher_matches(&hook.matcher, ctx.tool_name.as_deref()) {
continue;
}
let action = self.run_hook(hook, ctx).await;
results.push(action);
}
results
}
async fn run_hook(&self, hook: &RegisteredHook, ctx: &HookContext) -> HookAction {
const MAX_COMMAND_LEN: usize = 4096;
let command = substitute_env(&hook.command, &hook.plugin_root, ctx);
if command.len() > MAX_COMMAND_LEN {
return HookAction::Error(format!(
"Hook command exceeds maximum length ({} > {MAX_COMMAND_LEN})",
command.len()
));
}
let desc = hook.description.as_deref().unwrap_or(&hook.command);
tracing::debug!(
event = ?hook.event,
matcher = %hook.matcher,
desc = %desc,
command = %command,
"Firing plugin hook"
);
let exec = async {
let argv = match split_command(&command) {
Some(v) => v,
None => {
return HookAction::Error(format!(
"Hook '{}' command contains shell metacharacters or is invalid — \
use a plain executable with arguments only",
desc
));
}
};
let mut cmd = tokio::process::Command::new(&argv[0]);
cmd.args(&argv[1..])
.current_dir(&ctx.working_dir)
.env("COLLET_PLUGIN_ROOT", &hook.plugin_root)
.env("CLAUDE_PLUGIN_ROOT", &hook.plugin_root)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
match cmd.status().await {
Ok(status) => {
if status.success() {
HookAction::Allow
} else if status.code() == Some(2) {
HookAction::Block(desc.to_string())
} else {
HookAction::Error(format!(
"Hook '{}' exited with code {}",
desc,
status.code().unwrap_or(-1)
))
}
}
Err(e) => HookAction::Error(format!("Hook '{}' failed to execute: {e}", desc)),
}
};
let secs = hook.timeout_secs.unwrap_or(30);
match tokio::time::timeout(Duration::from_secs(secs), exec).await {
Ok(action) => action,
Err(_) => HookAction::Error(format!("Hook '{}' timed out after {}s", desc, secs)),
}
}
pub fn has_hooks(&self, event: HookEvent) -> bool {
self.hooks.get(&event).is_some_and(|v| !v.is_empty())
}
#[allow(dead_code)]
pub fn total_count(&self) -> usize {
self.hooks.values().map(|v| v.len()).sum()
}
}
pub(crate) fn split_command(s: &str) -> Option<Vec<String>> {
let mut tokens: Vec<String> = Vec::new();
let mut current = String::new();
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\'' => {
loop {
match chars.next() {
Some('\'') => break,
Some(c) => current.push(c),
None => return None, }
}
}
'"' => {
loop {
match chars.next() {
Some('"') => break,
Some(c) => current.push(c),
None => return None, }
}
}
'$' => {
current.push('$');
if chars.peek() == Some(&'(') {
return None;
}
}
';' => return None,
'`' => return None,
'>' | '<' => return None,
'|' => return None,
'&' => {
return None;
}
c if c.is_whitespace() => {
if !current.is_empty() {
tokens.push(std::mem::take(&mut current));
}
}
c => current.push(c),
}
}
if !current.is_empty() {
tokens.push(current);
}
if tokens.is_empty() {
return None;
}
Some(tokens)
}
fn matcher_matches(matcher: &str, tool_name: Option<&str>) -> bool {
if matcher == "*" {
return true;
}
match tool_name {
Some(name) => matcher.split('|').any(|m| m == name),
None => false,
}
}
fn substitute_env(command: &str, plugin_root: &Path, _ctx: &HookContext) -> String {
let root_str = plugin_root.to_string_lossy();
command
.replace("${CLAUDE_PLUGIN_ROOT}", &root_str)
.replace("${COLLET_PLUGIN_ROOT}", &root_str)
}
pub fn parse_hooks_json(path: &Path) -> Result<HookConfig> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
let raw: serde_json::Value = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse {}", path.display()))?;
Ok(HookConfig { raw })
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn test_parse_hooks_json() {
let dir = tempfile::tempdir().unwrap();
let hooks_json = dir.path().join("hooks.json");
fs::write(
&hooks_json,
r#"{
"hooks": {
"SessionStart": [{
"matcher": "*",
"hooks": [{
"type": "command",
"command": "echo hello"
}]
}],
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/guard.sh"
}]
}],
"PostToolUse": [{
"matcher": "*",
"hooks": [{
"type": "command",
"command": "observe.sh",
"async": true,
"timeout": 5
}]
}]
}
}"#,
)
.unwrap();
let config = parse_hooks_json(&hooks_json).unwrap();
let mut runtime = HookRuntime::new();
runtime.merge(&config, Path::new("/tmp/test-plugin"));
assert!(runtime.has_hooks(HookEvent::SessionStart));
assert!(runtime.has_hooks(HookEvent::PreToolUse));
assert!(runtime.has_hooks(HookEvent::PostToolUse));
assert!(!runtime.has_hooks(HookEvent::PreCompact));
assert_eq!(runtime.total_count(), 3);
let pre_hooks = runtime.hooks.get(&HookEvent::PreToolUse).unwrap();
assert_eq!(pre_hooks[0].matcher, "Bash");
}
#[test]
fn test_substitute_env() {
let result = substitute_env(
"${CLAUDE_PLUGIN_ROOT}/hooks/bin/epic-harness",
Path::new("/home/user/.collet/plugins/epic"),
&HookContext::simple(Path::new(".")),
);
assert_eq!(
result,
"/home/user/.collet/plugins/epic/hooks/bin/epic-harness"
);
}
#[test]
fn test_split_command_rejects_metacharacters() {
assert!(split_command("echo hello").is_some());
assert!(split_command("echo hello; rm -rf /").is_none());
assert!(split_command("cmd && other").is_none());
assert!(split_command("${PLUGIN_ROOT}/script.py --flag value").is_some());
}
#[test]
fn test_hook_event_from_str() {
assert_eq!(
HookEvent::from_str_name("SessionStart"),
Some(HookEvent::SessionStart)
);
assert_eq!(
HookEvent::from_str_name("PreToolUse"),
Some(HookEvent::PreToolUse)
);
assert_eq!(
HookEvent::from_str_name("PostToolUse"),
Some(HookEvent::PostToolUse)
);
assert_eq!(
HookEvent::from_str_name("PreCompact"),
Some(HookEvent::PreCompact)
);
assert_eq!(
HookEvent::from_str_name("SessionEnd"),
Some(HookEvent::SessionEnd)
);
assert_eq!(HookEvent::from_str_name("Unknown"), None);
}
}