Skip to main content

halter_hooks/
engine.rs

1// pattern: Functional Core
2
3use 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
18/// Current hook file protocol version.
19pub const HOOK_PROTOCOL_VERSION: u32 = 1;
20
21#[derive(Debug, Clone)]
22/// One plugin hook source used to build a registry.
23pub 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)]
33/// Prepared hook registry keyed by event.
34pub struct Hooks {
35    handlers_by_event: BTreeMap<HookEventName, Vec<ConfiguredHandler>>,
36}
37
38impl Hooks {
39    /// Build a hook registry from plugin hook files.
40    #[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    /// Build a hook registry from SDK-registered hooks.
93    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    /// Prepare matching handlers for one event dispatch.
171    #[must_use]
172    pub fn prepare(&self, request: HookDispatchRequest) -> PreparedHookDispatch {
173        Self::prepare_many([self], request)
174    }
175
176    /// Prepare matching handlers across multiple hook registries.
177    #[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)]
214/// Input used to select and run hooks for one event.
215pub 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)]
223/// Hook dispatch plan before handlers have executed.
224pub struct PreparedHookDispatch {
225    request: HookDispatchRequest,
226    previews: Vec<HookRunSummary>,
227    matched_handlers: Vec<ConfiguredHandler>,
228}
229
230impl PreparedHookDispatch {
231    /// Original dispatch request.
232    #[must_use]
233    pub fn request(&self) -> &HookDispatchRequest {
234        &self.request
235    }
236
237    /// Synthetic running summaries emitted before handlers complete.
238    #[must_use]
239    pub fn preview_runs(&self) -> &[HookRunSummary] {
240        &self.previews
241    }
242
243    /// Handlers that matched the request, ordered by priority.
244    #[must_use]
245    pub fn matched_handlers(&self) -> &[ConfiguredHandler] {
246        &self.matched_handlers
247    }
248}
249
250#[derive(Debug, Clone)]
251/// Final hook dispatch result after merging all handler outputs.
252pub struct HookDispatchOutcome {
253    pub merged: HookMergedOutcome,
254    pub runs: Vec<HookRunSummary>,
255    pub fired_hook_ids: Vec<String>,
256}
257
258#[derive(Debug, Clone)]
259/// Runtime-ready hook handler.
260pub 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    /// Single-pass match: the regex matcher must hit (or be absent) and the
280    /// `if` expression must evaluate true (or be absent). Collapsed from two
281    /// methods to one chained expression (finding L19).
282    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)]
300/// Executable handler configuration.
301pub enum ConfiguredHandlerConfig {
302    /// Handler loaded from a plugin hook file.
303    File(FileHookHandlerConfig),
304    /// SDK callback handler.
305    Callback(HookCallback),
306    /// SDK function handler after factory instantiation.
307    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    // Runtime match for `if_condition` patterns. These aren't validated at
399    // config-load time, so an invalid pattern fails closed (no match).
400    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    /// AC3.5: an invalid matcher cannot reach the engine. `HooksFile::from_raw`
421    /// rejects the config at load, so `Hooks::from_sources` never sees a raw
422    /// string matcher. Defense-in-depth via the type system (H22/H27).
423    #[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}