1use std::collections::{BTreeMap, BTreeSet};
4use std::fmt;
5use std::path::PathBuf;
6use std::time::Duration;
7
8use anyhow::Context;
9use chrono::Utc;
10use halter_protocol::{HookHandlerType, HookRunStatus, HookRunSummary, PluginId};
11use serde_json::Value;
12
13use crate::config::{HookEventName, HookHandlerConfig as FileHookHandlerConfig, HooksFile};
14use crate::matcher::CompiledMatcher;
15use crate::merge::{HandlerPriority, HandlerPriorityGroup, HookMergedOutcome};
16use crate::sdk::{HookCallback, HookKind, RegisteredHook, RegisteredHookPriority};
17
18pub const HOOK_PROTOCOL_VERSION: u32 = 1;
20
21#[derive(Debug, Clone)]
22pub struct HookRegistrySource {
24 pub plugin_id: PluginId,
25 pub plugin_root: PathBuf,
26 pub source_path: PathBuf,
27 pub allowed_http_hosts: Vec<String>,
28 pub allowed_env_vars: Vec<String>,
29 pub file: HooksFile,
30}
31
32#[derive(Debug, Clone, Default)]
33pub struct Hooks {
35 handlers_by_event: BTreeMap<HookEventName, Vec<ConfiguredHandler>>,
36}
37
38impl Hooks {
39 #[must_use]
41 pub fn from_sources(sources: impl IntoIterator<Item = HookRegistrySource>) -> Self {
42 let mut handlers_by_event = BTreeMap::new();
43
44 for (plugin_index, source) in sources.into_iter().enumerate() {
45 for (event_index, (event_name, matcher_groups)) in source.file.hooks.iter().enumerate()
46 {
47 for (matcher_index, matcher_group) in matcher_groups.iter().enumerate() {
48 for (hook_index, hook) in matcher_group.hooks.iter().enumerate() {
49 let matcher = matcher_group.matcher.clone();
50 let handler_id = format!(
51 "{}:{}:{}:{}:{}",
52 source.plugin_id,
53 event_name.canonical_name(),
54 event_index,
55 matcher_index,
56 hook_index
57 );
58 handlers_by_event
59 .entry(*event_name)
60 .or_insert_with(Vec::new)
61 .push(ConfiguredHandler {
62 handler_id,
63 plugin_id: source.plugin_id.clone(),
64 plugin_root: source.plugin_root.clone(),
65 source_path: source.source_path.clone(),
66 allowed_http_hosts: source.allowed_http_hosts.clone(),
67 allowed_env_vars: source.allowed_env_vars.clone(),
68 event_name: *event_name,
69 handler_type: hook.handler_type,
70 timeout: hook.timeout,
71 status_message: hook.status_message.clone(),
72 if_condition: hook.if_condition.clone(),
73 once: hook.once,
74 matcher,
75 config: ConfiguredHandlerConfig::File(hook.config.clone()),
76 priority: HandlerPriority {
77 group: HandlerPriorityGroup::PluginFiles,
78 plugin_load_order: plugin_index,
79 event_declaration_index: event_index,
80 matcher_group_index: matcher_index,
81 hook_index_within_group: hook_index,
82 },
83 });
84 }
85 }
86 }
87 }
88
89 Self { handlers_by_event }
90 }
91
92 pub fn from_registered(
94 hooks: impl IntoIterator<Item = RegisteredHook>,
95 ) -> anyhow::Result<Self> {
96 let mut handlers_by_event = BTreeMap::new();
97
98 for (hook_index, registered) in hooks.into_iter().enumerate() {
99 let matcher = registered
100 .hook
101 .matcher
102 .as_deref()
103 .map(str::trim)
104 .filter(|value| !value.is_empty())
105 .map(|pattern| {
106 if registered.hook.event.matcher_field().is_none() {
107 anyhow::bail!(
108 "hook event '{}' does not support matcher",
109 registered.hook.event.canonical_name()
110 );
111 }
112 Ok(CompiledMatcher::compile_regex(pattern)?)
113 })
114 .transpose()
115 .with_context(|| {
116 format!(
117 "failed to compile sdk hook matcher for plugin '{}' event '{}'",
118 registered.plugin_id,
119 registered.hook.event.canonical_name()
120 )
121 })?;
122 let priority_group = match registered.priority {
123 RegisteredHookPriority::BeforePlugins => HandlerPriorityGroup::SdkBeforePlugins,
124 RegisteredHookPriority::AfterPlugins => HandlerPriorityGroup::SdkAfterPlugins,
125 };
126 let handler_type = registered.hook.kind.handler_type();
127 let config = match registered.hook.kind {
128 HookKind::Callback(callback) => ConfiguredHandlerConfig::Callback(callback),
129 HookKind::Function(factory) => ConfiguredHandlerConfig::Function(factory()),
130 };
131 handlers_by_event
132 .entry(registered.hook.event)
133 .or_insert_with(Vec::new)
134 .push(ConfiguredHandler {
135 handler_id: format!(
136 "{}:{}:sdk:{}",
137 registered.plugin_id,
138 registered.hook.event.canonical_name(),
139 hook_index
140 ),
141 plugin_id: registered.plugin_id.clone(),
142 plugin_root: registered.plugin_root.clone(),
143 source_path: PathBuf::from(format!(
144 "<sdk-hook:{}:{}>",
145 registered.plugin_id, hook_index
146 )),
147 allowed_http_hosts: Vec::new(),
148 allowed_env_vars: Vec::new(),
149 event_name: registered.hook.event,
150 handler_type,
151 timeout: registered.hook.timeout,
152 status_message: registered.hook.status_message.clone(),
153 if_condition: registered.hook.if_condition.clone(),
154 once: registered.hook.once,
155 matcher,
156 config,
157 priority: HandlerPriority {
158 group: priority_group,
159 plugin_load_order: hook_index,
160 event_declaration_index: 0,
161 matcher_group_index: 0,
162 hook_index_within_group: 0,
163 },
164 });
165 }
166
167 Ok(Self { handlers_by_event })
168 }
169
170 #[must_use]
172 pub fn prepare(&self, request: HookDispatchRequest) -> PreparedHookDispatch {
173 Self::prepare_many([self], request)
174 }
175
176 #[must_use]
178 pub fn prepare_many<'a>(
179 hook_sets: impl IntoIterator<Item = &'a Hooks>,
180 request: HookDispatchRequest,
181 ) -> PreparedHookDispatch {
182 let mut matched_handlers = Vec::new();
183
184 for hooks in hook_sets {
185 for handler in hooks
186 .handlers_by_event
187 .get(&request.event_name)
188 .into_iter()
189 .flatten()
190 {
191 if handler.once && request.fired_hook_ids.contains(&handler.handler_id) {
192 continue;
193 }
194 if !handler.matches(&request) {
195 continue;
196 }
197
198 matched_handlers.push(handler.clone());
199 }
200 }
201
202 matched_handlers.sort_by(|left, right| left.priority.cmp(&right.priority));
203 let previews = matched_handlers.iter().map(build_preview_run).collect();
204
205 PreparedHookDispatch {
206 request,
207 previews,
208 matched_handlers,
209 }
210 }
211}
212
213#[derive(Debug, Clone)]
214pub struct HookDispatchRequest {
216 pub event_name: HookEventName,
217 pub matcher_value: Option<String>,
218 pub payload: Value,
219 pub fired_hook_ids: BTreeSet<String>,
220}
221
222#[derive(Debug, Clone)]
223pub struct PreparedHookDispatch {
225 request: HookDispatchRequest,
226 previews: Vec<HookRunSummary>,
227 matched_handlers: Vec<ConfiguredHandler>,
228}
229
230impl PreparedHookDispatch {
231 #[must_use]
233 pub fn request(&self) -> &HookDispatchRequest {
234 &self.request
235 }
236
237 #[must_use]
239 pub fn preview_runs(&self) -> &[HookRunSummary] {
240 &self.previews
241 }
242
243 #[must_use]
245 pub fn matched_handlers(&self) -> &[ConfiguredHandler] {
246 &self.matched_handlers
247 }
248}
249
250#[derive(Debug, Clone)]
251pub struct HookDispatchOutcome {
253 pub merged: HookMergedOutcome,
254 pub runs: Vec<HookRunSummary>,
255 pub fired_hook_ids: Vec<String>,
256}
257
258#[derive(Debug, Clone)]
259pub struct ConfiguredHandler {
261 pub handler_id: String,
262 pub plugin_id: PluginId,
263 pub plugin_root: PathBuf,
264 pub source_path: PathBuf,
265 pub allowed_http_hosts: Vec<String>,
266 pub allowed_env_vars: Vec<String>,
267 pub event_name: HookEventName,
268 pub handler_type: HookHandlerType,
269 pub timeout: Duration,
270 pub status_message: Option<String>,
271 pub if_condition: Option<String>,
272 pub once: bool,
273 pub matcher: Option<CompiledMatcher>,
274 pub config: ConfiguredHandlerConfig,
275 pub priority: HandlerPriority,
276}
277
278impl ConfiguredHandler {
279 fn matches(&self, request: &HookDispatchRequest) -> bool {
283 let matcher_hit = match (&self.matcher, self.event_name.matcher_field()) {
284 (Some(matcher), Some(_)) => request
285 .matcher_value
286 .as_deref()
287 .is_some_and(|value| matcher.is_match(value)),
288 (Some(_), None) => false,
289 (None, _) => true,
290 };
291 matcher_hit
292 && self
293 .if_condition
294 .as_deref()
295 .is_none_or(|condition| matches_if_condition(condition, request))
296 }
297}
298
299#[derive(Clone)]
300pub enum ConfiguredHandlerConfig {
302 File(FileHookHandlerConfig),
304 Callback(HookCallback),
306 Function(HookCallback),
308}
309
310impl fmt::Debug for ConfiguredHandlerConfig {
311 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
312 match self {
313 Self::File(config) => f.debug_tuple("File").field(config).finish(),
314 Self::Callback(_) => f.write_str("Callback(..)"),
315 Self::Function(_) => f.write_str("Function(..)"),
316 }
317 }
318}
319
320fn build_preview_run(handler: &ConfiguredHandler) -> HookRunSummary {
321 let started_at = Utc::now();
322 HookRunSummary {
323 run_id: format!(
324 "{}:{}",
325 handler.handler_id,
326 started_at.timestamp_nanos_opt().unwrap_or_default()
327 ),
328 event_name: handler.event_name.canonical_name().to_owned(),
329 handler_type: handler.handler_type,
330 plugin_id: handler.plugin_id.clone(),
331 plugin_root: handler.plugin_root.clone(),
332 status: HookRunStatus::Running,
333 status_message: handler.status_message.clone(),
334 started_at,
335 completed_at: None,
336 duration_ms: None,
337 entries: Vec::new(),
338 }
339}
340
341fn matches_if_condition(condition: &str, request: &HookDispatchRequest) -> bool {
342 let trimmed = condition.trim();
343 if trimmed.is_empty() || trimmed == "*" {
344 return true;
345 }
346
347 let Some(tool_name) = request.payload.get("tool_name").and_then(Value::as_str) else {
348 return false;
349 };
350
351 if let Some((tool_pattern, input_pattern)) = parse_if_condition(trimmed) {
352 if !matches_text_pattern(tool_pattern, tool_name) {
353 return false;
354 }
355
356 let input_text = request
357 .payload
358 .get("tool_input")
359 .and_then(render_if_input_text)
360 .unwrap_or_default();
361 return matches_text_pattern(input_pattern, &input_text);
362 }
363
364 matches_text_pattern(trimmed, tool_name)
365}
366
367fn parse_if_condition(condition: &str) -> Option<(&str, &str)> {
368 let open = condition.find('(')?;
369 if !condition.ends_with(')') {
370 return None;
371 }
372 let close = condition.len().saturating_sub(1);
373 if close <= open {
374 return None;
375 }
376 Some((condition[..open].trim(), condition[open + 1..close].trim()))
377}
378
379fn render_if_input_text(value: &Value) -> Option<String> {
380 match value {
381 Value::Object(map) => map
382 .get("command")
383 .and_then(Value::as_str)
384 .map(ToOwned::to_owned)
385 .or_else(|| Some(Value::Object(map.clone()).to_string())),
386 Value::String(text) => Some(text.clone()),
387 Value::Null => None,
388 other => Some(other.to_string()),
389 }
390}
391
392fn matches_text_pattern(pattern: &str, candidate: &str) -> bool {
393 let pattern = pattern.trim();
394 if pattern.is_empty() || pattern == "*" {
395 return true;
396 }
397
398 match CompiledMatcher::compile(pattern) {
401 Ok(matcher) => matcher.is_match(candidate),
402 Err(_) => false,
403 }
404}
405
406#[cfg(test)]
407mod tests {
408 use serde_json::json;
409
410 use super::*;
411 use crate::config::{HookHandler, HookMatcherGroup, HooksFile, PromptHookConfig};
412
413 #[test]
414 fn wildcard_match_supports_globs() {
415 assert!(matches_text_pattern("git *", "git status"));
416 assert!(matches_text_pattern("shell", "Shell"));
417 assert!(!matches_text_pattern("git *", "cargo test"));
418 }
419
420 #[test]
424 fn review_hook_runtime_ac3_5_engine_never_sees_invalid_matcher() {
425 let error = HooksFile::from_json_bytes(
426 br#"{
427 "hooks": {
428 "PreToolUse": [
429 {
430 "matcher": "(",
431 "hooks": [
432 {
433 "type": "prompt",
434 "prompt": "never reached"
435 }
436 ]
437 }
438 ]
439 }
440 }"#,
441 )
442 .expect_err("invalid matcher must hard-fail at load");
443 let rendered = format!("{error:#}");
444 assert!(
445 rendered.contains("invalid matcher regex")
446 || rendered.contains("invalid regex pattern"),
447 "expected compile error, got: {rendered}",
448 );
449 }
450
451 #[test]
452 fn if_condition_matches_tool_name_and_command() {
453 let handler = ConfiguredHandler {
454 handler_id: "hook".to_owned(),
455 plugin_id: PluginId::from("plugin"),
456 plugin_root: PathBuf::from("/tmp/plugin"),
457 source_path: PathBuf::from("/tmp/plugin/hooks.json"),
458 allowed_http_hosts: Vec::new(),
459 allowed_env_vars: Vec::new(),
460 event_name: HookEventName::PreToolUse,
461 handler_type: HookHandlerType::Prompt,
462 timeout: Duration::from_secs(1),
463 status_message: None,
464 if_condition: Some("Shell(git *)".to_owned()),
465 once: false,
466 matcher: None,
467 config: ConfiguredHandlerConfig::File(FileHookHandlerConfig::Prompt(
468 PromptHookConfig {
469 prompt: "noop".to_owned(),
470 model: None,
471 },
472 )),
473 priority: HandlerPriority {
474 group: HandlerPriorityGroup::PluginFiles,
475 plugin_load_order: 0,
476 event_declaration_index: 0,
477 matcher_group_index: 0,
478 hook_index_within_group: 0,
479 },
480 };
481
482 let request = HookDispatchRequest {
483 event_name: HookEventName::PreToolUse,
484 matcher_value: Some("Shell".to_owned()),
485 payload: json!({
486 "tool_name": "Shell",
487 "tool_input": { "command": "git status" },
488 }),
489 fired_hook_ids: BTreeSet::new(),
490 };
491
492 assert!(handler.matches(&request));
493 }
494
495 #[test]
496 fn if_condition_matches_regex_patterns_and_string_inputs() {
497 let request = HookDispatchRequest {
498 event_name: HookEventName::PreToolUse,
499 matcher_value: Some("Read".to_owned()),
500 payload: json!({
501 "tool_name": "Read",
502 "tool_input": "src/lib.rs",
503 }),
504 fired_hook_ids: BTreeSet::new(),
505 };
506
507 assert!(matches_if_condition("^Read$", &request));
508 assert!(matches_if_condition("Read(^src/.*\\.rs$)", &request));
509 assert!(!matches_if_condition("Write(src/.*)", &request));
510 }
511
512 #[test]
513 fn if_condition_rejects_non_tool_payloads_and_unbalanced_groups() {
514 let request = HookDispatchRequest {
515 event_name: HookEventName::Notification,
516 matcher_value: None,
517 payload: json!({
518 "message": "hello"
519 }),
520 fired_hook_ids: BTreeSet::new(),
521 };
522
523 assert!(!matches_if_condition("Shell(git *)", &request));
524 assert!(!matches_if_condition("Shell(", &request));
525 }
526
527 #[test]
528 fn if_condition_rejects_trailing_text_after_group() {
529 let request = HookDispatchRequest {
530 event_name: HookEventName::PreToolUse,
531 matcher_value: Some("Shell".to_owned()),
532 payload: json!({
533 "tool_name": "Shell",
534 "tool_input": { "command": "git status" },
535 }),
536 fired_hook_ids: BTreeSet::new(),
537 };
538
539 assert!(!matches_if_condition("Shell(git *) trailing", &request));
540 }
541
542 #[test]
543 fn prepare_filters_once_handlers() {
544 let hooks = Hooks::from_sources([HookRegistrySource {
545 plugin_id: PluginId::from("plugin"),
546 plugin_root: PathBuf::from("/tmp/plugin"),
547 source_path: PathBuf::from("/tmp/plugin/hooks.json"),
548 allowed_http_hosts: Vec::new(),
549 allowed_env_vars: Vec::new(),
550 file: HooksFile {
551 hooks: [(
552 HookEventName::UserPromptSubmit,
553 vec![HookMatcherGroup {
554 matcher: None,
555 hooks: vec![HookHandler {
556 handler_type: HookHandlerType::Prompt,
557 timeout: Duration::from_secs(1),
558 status_message: None,
559 if_condition: None,
560 once: true,
561 config: FileHookHandlerConfig::Prompt(PromptHookConfig {
562 prompt: "noop".to_owned(),
563 model: None,
564 }),
565 }],
566 }],
567 )]
568 .into_iter()
569 .collect(),
570 },
571 }]);
572
573 let prepared = hooks.prepare(HookDispatchRequest {
574 event_name: HookEventName::UserPromptSubmit,
575 matcher_value: None,
576 payload: json!({}),
577 fired_hook_ids: ["plugin:UserPromptSubmit:0:0:0".to_owned()]
578 .into_iter()
579 .collect(),
580 });
581
582 assert!(prepared.matched_handlers().is_empty());
583 }
584}