Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Compile hook matchers during discovery (#49379)
## Why

Hook dispatch recompiles regex matchers for each input even though discovery already validates them.

## What changed

Store compiled `HookMatcher` values on configured handlers and reuse them during dispatch. Preserve match-all, exact-name, pipe-alternative, and regex matching semantics, along with the original pattern spelling for configuration equality. Invalid regexes continue to be rejected during discovery.

GitOrigin-RevId: 117a8a19c7bc0e55ccbedcd5940b806a17ebba10
  • Loading branch information
jif-oai authored and copyberry committed Sep 29, 2026
commit bd4204efc24a888c6671681f5cd41fbf9377c163
49 changes: 29 additions & 20 deletions codex-rs/hooks/src/engine/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ use super::HookListEntry;
use super::HookListEntryHandler;
use super::dispatcher::hook_event_name_label;
use crate::config_rules::hook_states_from_stack;
use crate::engine::HookMatcher;
use crate::events::common::matcher_pattern_for_event;
use crate::events::common::validate_matcher_pattern;
use crate::events::session_end::SESSION_END_DEFAULT_TIMEOUT_SEC;
use crate::events::session_end::SESSION_END_MAX_TIMEOUT_SEC;
use crate::output_spill::AdditionalContextLimit;
Expand Down Expand Up @@ -486,20 +486,22 @@ fn append_matcher_groups(
) {
for (group_index, group) in groups.into_iter().enumerate() {
let matcher = matcher_pattern_for_event(event_name, group.matcher.as_deref());
if let Some(matcher) = matcher
&& let Err(err) = validate_matcher_pattern(matcher)
{
let warning = format!(
"invalid matcher {matcher:?} in {}: {err}",
source.path.display()
);
if group.hooks.is_empty() {
warnings.push(warning);
} else {
source.record_load_failure(warning, warnings);
let compiled_matcher = match matcher.map(HookMatcher::new).transpose() {
Ok(matcher) => matcher,
Err(err) => {
let matcher = matcher.unwrap_or_default();
let warning = format!(
"invalid matcher {matcher:?} in {}: {err}",
source.path.display()
);
if group.hooks.is_empty() {
warnings.push(warning);
} else {
source.record_load_failure(warning, warnings);
}
continue;
}
continue;
}
};
for (handler_index, handler) in group.hooks.iter().cloned().enumerate() {
let normalized = match handler {
HookHandlerConfig::Command {
Expand Down Expand Up @@ -720,7 +722,7 @@ fn append_matcher_groups(
handlers.push(ConfiguredHandler {
builtin,
event_name,
matcher: matcher.map(ToOwned::to_owned),
matcher: compiled_matcher.clone(),
timeout_sec,
status_message,
additional_context_limit: AdditionalContextLimit::from_config(
Expand Down Expand Up @@ -862,6 +864,7 @@ fn hook_source_for_requirement_source(source: Option<&RequirementSource>) -> Hoo

#[cfg(test)]
mod tests {
use super::HookMatcher;
use codex_config::ConfigLayerEntry;
use codex_config::ConfigLayerSource;
use codex_config::HookEventsToml;
Expand Down Expand Up @@ -1296,7 +1299,7 @@ mod tests {
vec![ConfiguredHandler {
builtin: false,
event_name: HookEventName::PreToolUse,
matcher: Some("^Bash$".to_string()),
matcher: Some(HookMatcher::new("^Bash$").expect("valid matcher")),
timeout_sec: 600,
status_message: None,
additional_context_limit: Default::default(),
Expand Down Expand Up @@ -1366,7 +1369,7 @@ mod tests {
assert_eq!(
handlers
.iter()
.map(|handler| handler.matcher.as_deref())
.map(|handler| handler.matcher.as_ref().map(HookMatcher::as_str))
.collect::<Vec<_>>(),
vec![Some("other"), Some("other")]
);
Expand Down Expand Up @@ -1446,7 +1449,7 @@ mod tests {
.iter()
.map(|handler| (
handler.timeout_sec,
handler.matcher.as_deref(),
handler.matcher.as_ref().map(HookMatcher::as_str),
handler.execution_mode()
))
.collect::<Vec<_>>(),
Expand Down Expand Up @@ -1558,7 +1561,10 @@ mod tests {

assert_eq!(warnings, Vec::<String>::new());
assert_eq!(handlers.len(), 1);
assert_eq!(handlers[0].matcher.as_deref(), Some("*"));
assert_eq!(
handlers[0].matcher.as_ref().map(HookMatcher::as_str),
Some("*")
);
}

#[test]
Expand All @@ -1582,7 +1588,10 @@ mod tests {
assert_eq!(warnings, Vec::<String>::new());
assert_eq!(handlers.len(), 1);
assert_eq!(handlers[0].event_name, HookEventName::PostToolUse);
assert_eq!(handlers[0].matcher.as_deref(), Some("Edit|Write"));
assert_eq!(
handlers[0].matcher.as_ref().map(HookMatcher::as_str),
Some("Edit|Write")
);
}

#[test]
Expand Down
9 changes: 5 additions & 4 deletions codex-rs/hooks/src/engine/dispatcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,11 @@ pub(crate) fn select_handlers_for_matcher_inputs(
| HookEventName::PreCompact
| HookEventName::PostCompact => {
if matcher_inputs.is_empty() {
matches_matcher(handler.matcher.as_deref(), /*input*/ None)
matches_matcher(handler.matcher.as_ref(), /*input*/ None)
} else {
matcher_inputs
.iter()
.any(|input| matches_matcher(handler.matcher.as_deref(), Some(input)))
.any(|input| matches_matcher(handler.matcher.as_ref(), Some(input)))
}
}
HookEventName::UserPromptSubmit | HookEventName::Stop | HookEventName::Interrupt => {
Expand Down Expand Up @@ -352,7 +352,8 @@ mod tests {
ConfiguredHandler {
builtin: false,
event_name,
matcher: matcher.map(str::to_owned),
matcher: matcher
.map(|pattern| crate::engine::HookMatcher::new(pattern).expect("valid matcher")),
timeout_sec: 5,
status_message: None,
additional_context_limit: Default::default(),
Expand Down Expand Up @@ -609,7 +610,7 @@ mod tests {
),
make_handler(
HookEventName::UserPromptSubmit,
Some("["),
Some("^unmatched$"),
"echo second",
/*display_order*/ 1,
),
Expand Down
49 changes: 49 additions & 0 deletions codex-rs/hooks/src/engine/matcher.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
//! Compiled hook matchers retain their original spelling for configuration identity.
//! Regex parsing happens during discovery; dispatch only evaluates admitted matchers.

#[derive(Debug, Clone)]
pub(crate) enum HookMatcher {
All(String),
Exact(String),
Regex(regex::Regex),
}

impl HookMatcher {
pub(crate) fn new(pattern: &str) -> Result<Self, regex::Error> {
if pattern.is_empty() || pattern == "*" {
Ok(Self::All(pattern.to_string()))
} else if pattern
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '|')
{
Ok(Self::Exact(pattern.to_string()))
} else {
regex::Regex::new(pattern).map(Self::Regex)
}
}

pub(crate) fn as_str(&self) -> &str {
match self {
Self::All(pattern) | Self::Exact(pattern) => pattern,
Self::Regex(regex) => regex.as_str(),
}
}

pub(crate) fn matches(&self, input: Option<&str>) -> bool {
match self {
Self::All(_) => true,
Self::Exact(pattern) => {
input.is_some_and(|input| pattern.split('|').any(|candidate| candidate == input))
}
Self::Regex(regex) => input.is_some_and(|input| regex.is_match(input)),
}
}
}

impl PartialEq for HookMatcher {
fn eq(&self, other: &Self) -> bool {
self.as_str() == other.as_str()
}
}

impl Eq for HookMatcher {}
5 changes: 4 additions & 1 deletion codex-rs/hooks/src/engine/mod.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
pub(crate) mod command_runner;
pub(crate) mod discovery;
pub(crate) mod dispatcher;
mod matcher;
pub(crate) mod mcp_runner;
pub(crate) mod output_parser;
pub(crate) mod schema_loader;

pub(crate) use matcher::HookMatcher;

use crate::events::compact::PostCompactRequest;
use crate::events::compact::PreCompactOutcome;
use crate::events::compact::PreCompactRequest;
Expand Down Expand Up @@ -60,7 +63,7 @@ pub(crate) struct ConfiguredHandler {
/// Internally admitted cleanup hook, enabled independently of per-hook state.
pub builtin: bool,
pub event_name: codex_protocol::protocol::HookEventName,
pub matcher: Option<String>,
pub matcher: Option<HookMatcher>,
pub timeout_sec: u64,
pub status_message: Option<String>,
pub additional_context_limit: AdditionalContextLimit,
Expand Down
Loading
Loading