use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::id::{SessionId, TurnId};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HookEvent {
SessionStart,
SessionEnd,
UserPromptSubmit,
PreToolUse,
PostToolUse,
TurnEnd,
}
impl HookEvent {
pub fn as_str(self) -> &'static str {
match self {
Self::SessionStart => "session_start",
Self::SessionEnd => "session_end",
Self::UserPromptSubmit => "user_prompt_submit",
Self::PreToolUse => "pre_tool_use",
Self::PostToolUse => "post_tool_use",
Self::TurnEnd => "turn_end",
}
}
pub fn supports_matcher(self) -> bool {
matches!(self, Self::PreToolUse | Self::PostToolUse)
}
pub fn can_block(self) -> bool {
matches!(self, Self::UserPromptSubmit | Self::PreToolUse)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct HookContext {
pub session_id: SessionId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub turn_id: Option<TurnId>,
}
impl HookContext {
pub fn session(session_id: SessionId) -> Self {
Self {
session_id,
turn_id: None,
}
}
pub fn turn(session_id: SessionId, turn_id: TurnId) -> Self {
Self {
session_id,
turn_id: Some(turn_id),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HookPayload {
pub event: HookEvent,
pub hook_id: String,
#[serde(flatten)]
pub context: HookContext,
pub ts: chrono::DateTime<chrono::Utc>,
pub data: serde_json::Value,
}
impl HookPayload {
pub fn new(
event: HookEvent,
hook_id: impl Into<String>,
context: HookContext,
data: serde_json::Value,
) -> Self {
Self {
event,
hook_id: hook_id.into(),
context,
ts: chrono::Utc::now(),
data,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "decision", rename_all = "snake_case")]
pub enum HookOutcome {
Allow,
Mutate {
patch: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<String>,
},
Block {
reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
user_message: Option<String>,
},
Error {
message: String,
},
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HookErrorPolicy {
Block,
Allow,
#[default]
Warn,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct HookMatcher {
pub tool_name: Option<String>,
pub tool_name_glob: Option<String>,
pub args_jsonpath: Option<String>,
pub match_regex: Option<String>,
pub deny_regex: Option<String>,
}
impl HookMatcher {
pub fn matches(&self, tool_name: &str, arguments: &serde_json::Value) -> bool {
if let Some(exact) = &self.tool_name
&& exact != tool_name
{
return false;
}
if let Some(pattern) = &self.tool_name_glob
&& !pattern.split('|').any(|alternative| {
let alternative = alternative.trim();
alternative
.strip_suffix('*')
.map_or(alternative == tool_name, |prefix| {
tool_name.starts_with(prefix)
})
})
{
return false;
}
let extracted = self
.args_jsonpath
.as_deref()
.map(|path| extract_json_path(arguments, path));
match (
self.match_regex.as_deref(),
self.deny_regex.as_deref(),
extracted,
) {
(None, None, None) => true,
(None, None, Some(value)) => !value.is_empty(),
(Some(pattern), None, Some(value)) | (None, Some(pattern), Some(value)) => {
regex::Regex::new(pattern).is_ok_and(|regex| regex.is_match(&value))
}
_ => false,
}
}
pub fn validate(&self) -> std::result::Result<(), String> {
if self.match_regex.is_some() && self.deny_regex.is_some() {
return Err("match_regex and deny_regex are mutually exclusive".into());
}
if (self.match_regex.is_some() || self.deny_regex.is_some()) && self.args_jsonpath.is_none()
{
return Err("a regex matcher requires args_jsonpath".into());
}
for pattern in [&self.match_regex, &self.deny_regex].into_iter().flatten() {
regex::Regex::new(pattern)
.map_err(|error| format!("invalid hook matcher regex: {error}"))?;
}
Ok(())
}
}
fn extract_json_path(value: &serde_json::Value, path: &str) -> String {
let mut current = value;
for segment in path.strip_prefix("$.").unwrap_or(path).split('.') {
if segment.is_empty() {
continue;
}
let Some(next) = current.get(segment) else {
return String::new();
};
current = next;
}
match current {
serde_json::Value::String(value) => value.clone(),
serde_json::Value::Null => String::new(),
value => value.to_string(),
}
}
#[async_trait]
pub trait Hook: Send + Sync {
fn id(&self) -> &str;
fn event(&self) -> HookEvent;
fn matcher(&self) -> Option<&HookMatcher> {
None
}
fn on_error(&self) -> HookErrorPolicy {
HookErrorPolicy::Warn
}
async fn run(&self, payload: &HookPayload) -> HookOutcome;
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn matcher_combines_name_path_and_regex() {
let matcher = HookMatcher {
tool_name_glob: Some("bash|edit_*".into()),
args_jsonpath: Some("$.command".into()),
match_regex: Some(r"^rm\s+-rf".into()),
..HookMatcher::default()
};
assert!(matcher.matches("bash", &json!({"command": "rm -rf build"})));
assert!(!matcher.matches("read_file", &json!({"command": "rm -rf build"})));
assert!(!matcher.matches("bash", &json!({"command": "cargo test"})));
}
#[test]
fn matcher_validation_rejects_ambiguous_regexes() {
let matcher = HookMatcher {
args_jsonpath: Some("$.command".into()),
match_regex: Some("x".into()),
deny_regex: Some("y".into()),
..HookMatcher::default()
};
assert!(matcher.validate().is_err());
}
}