1use async_trait::async_trait;
15use serde::{Deserialize, Serialize};
16
17use crate::id::{SessionId, TurnId};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum HookEvent {
23 SessionStart,
25 SessionEnd,
27 UserPromptSubmit,
29 PreToolUse,
31 PostToolUse,
33 TurnEnd,
35}
36
37impl HookEvent {
38 pub fn as_str(self) -> &'static str {
40 match self {
41 Self::SessionStart => "session_start",
42 Self::SessionEnd => "session_end",
43 Self::UserPromptSubmit => "user_prompt_submit",
44 Self::PreToolUse => "pre_tool_use",
45 Self::PostToolUse => "post_tool_use",
46 Self::TurnEnd => "turn_end",
47 }
48 }
49
50 pub fn supports_matcher(self) -> bool {
52 matches!(self, Self::PreToolUse | Self::PostToolUse)
53 }
54
55 pub fn can_block(self) -> bool {
57 matches!(self, Self::UserPromptSubmit | Self::PreToolUse)
58 }
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63pub struct HookContext {
64 pub session_id: SessionId,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub turn_id: Option<TurnId>,
69}
70
71impl HookContext {
72 pub fn session(session_id: SessionId) -> Self {
74 Self {
75 session_id,
76 turn_id: None,
77 }
78 }
79
80 pub fn turn(session_id: SessionId, turn_id: TurnId) -> Self {
82 Self {
83 session_id,
84 turn_id: Some(turn_id),
85 }
86 }
87}
88
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
91pub struct HookPayload {
92 pub event: HookEvent,
94 pub hook_id: String,
96 #[serde(flatten)]
99 pub context: HookContext,
100 pub ts: chrono::DateTime<chrono::Utc>,
102 pub data: serde_json::Value,
105}
106
107impl HookPayload {
108 pub fn new(
110 event: HookEvent,
111 hook_id: impl Into<String>,
112 context: HookContext,
113 data: serde_json::Value,
114 ) -> Self {
115 Self {
116 event,
117 hook_id: hook_id.into(),
118 context,
119 ts: chrono::Utc::now(),
120 data,
121 }
122 }
123}
124
125#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132#[serde(tag = "decision", rename_all = "snake_case")]
133pub enum HookOutcome {
134 Allow,
136 Mutate {
138 patch: serde_json::Value,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
142 reason: Option<String>,
143 },
144 Block {
146 reason: String,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
150 user_message: Option<String>,
151 },
152 Error {
154 message: String,
156 },
157}
158
159#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(rename_all = "snake_case")]
162pub enum HookErrorPolicy {
163 Block,
165 Allow,
167 #[default]
169 Warn,
170}
171
172#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
178#[serde(default)]
179pub struct HookMatcher {
180 pub tool_name: Option<String>,
182 pub tool_name_glob: Option<String>,
184 pub args_jsonpath: Option<String>,
186 pub match_regex: Option<String>,
188 pub deny_regex: Option<String>,
191}
192
193impl HookMatcher {
194 pub fn matches(&self, tool_name: &str, arguments: &serde_json::Value) -> bool {
196 if let Some(exact) = &self.tool_name
197 && exact != tool_name
198 {
199 return false;
200 }
201 if let Some(pattern) = &self.tool_name_glob
202 && !pattern.split('|').any(|alternative| {
203 let alternative = alternative.trim();
204 alternative
205 .strip_suffix('*')
206 .map_or(alternative == tool_name, |prefix| {
207 tool_name.starts_with(prefix)
208 })
209 })
210 {
211 return false;
212 }
213
214 let extracted = self
215 .args_jsonpath
216 .as_deref()
217 .map(|path| extract_json_path(arguments, path));
218 match (
219 self.match_regex.as_deref(),
220 self.deny_regex.as_deref(),
221 extracted,
222 ) {
223 (None, None, None) => true,
224 (None, None, Some(value)) => !value.is_empty(),
225 (Some(pattern), None, Some(value)) | (None, Some(pattern), Some(value)) => {
226 regex::Regex::new(pattern).is_ok_and(|regex| regex.is_match(&value))
227 }
228 _ => false,
229 }
230 }
231
232 pub fn validate(&self) -> std::result::Result<(), String> {
234 if self.match_regex.is_some() && self.deny_regex.is_some() {
235 return Err("match_regex and deny_regex are mutually exclusive".into());
236 }
237 if (self.match_regex.is_some() || self.deny_regex.is_some()) && self.args_jsonpath.is_none()
238 {
239 return Err("a regex matcher requires args_jsonpath".into());
240 }
241 for pattern in [&self.match_regex, &self.deny_regex].into_iter().flatten() {
242 regex::Regex::new(pattern)
243 .map_err(|error| format!("invalid hook matcher regex: {error}"))?;
244 }
245 Ok(())
246 }
247}
248
249fn extract_json_path(value: &serde_json::Value, path: &str) -> String {
250 let mut current = value;
251 for segment in path.strip_prefix("$.").unwrap_or(path).split('.') {
252 if segment.is_empty() {
253 continue;
254 }
255 let Some(next) = current.get(segment) else {
256 return String::new();
257 };
258 current = next;
259 }
260 match current {
261 serde_json::Value::String(value) => value.clone(),
262 serde_json::Value::Null => String::new(),
263 value => value.to_string(),
264 }
265}
266
267#[async_trait]
269pub trait Hook: Send + Sync {
270 fn id(&self) -> &str;
272
273 fn event(&self) -> HookEvent;
275
276 fn matcher(&self) -> Option<&HookMatcher> {
278 None
279 }
280
281 fn on_error(&self) -> HookErrorPolicy {
283 HookErrorPolicy::Warn
284 }
285
286 async fn run(&self, payload: &HookPayload) -> HookOutcome;
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293 use serde_json::json;
294
295 #[test]
296 fn matcher_combines_name_path_and_regex() {
297 let matcher = HookMatcher {
298 tool_name_glob: Some("bash|edit_*".into()),
299 args_jsonpath: Some("$.command".into()),
300 match_regex: Some(r"^rm\s+-rf".into()),
301 ..HookMatcher::default()
302 };
303 assert!(matcher.matches("bash", &json!({"command": "rm -rf build"})));
304 assert!(!matcher.matches("read_file", &json!({"command": "rm -rf build"})));
305 assert!(!matcher.matches("bash", &json!({"command": "cargo test"})));
306 }
307
308 #[test]
309 fn matcher_validation_rejects_ambiguous_regexes() {
310 let matcher = HookMatcher {
311 args_jsonpath: Some("$.command".into()),
312 match_regex: Some("x".into()),
313 deny_regex: Some("y".into()),
314 ..HookMatcher::default()
315 };
316 assert!(matcher.validate().is_err());
317 }
318}