[hooks] add non-streaming (non-stdin style) shell-only PreToolUse support (#15211)

- add `PreToolUse` hook for bash-like tool execution only at first
- block shell execution before dispatch with deny-only hook behavior
- introduces common.rs matcher framework for matching when hooks are run

example run:

```
› run three parallel echo commands, and the second one should echo "[block-pre-tool-use]" as a test


• Running the three echo commands in parallel now and I’ll report the output directly.

• Running PreToolUse hook: name for demo pre tool use hook

• Running PreToolUse hook: name for demo pre tool use hook

• Running PreToolUse hook: name for demo pre tool use hook

PreToolUse hook (completed)
  warning: wizard-tower PreToolUse demo inspected Bash: echo "first parallel echo"
  
PreToolUse hook (blocked)
  warning: wizard-tower PreToolUse demo blocked a Bash command on purpose.
  feedback: PreToolUse demo blocked the command. Remove [block-pre-tool-use] to continue.

PreToolUse hook (completed)
  warning: wizard-tower PreToolUse demo inspected Bash: echo "third parallel echo"

• Ran echo "first parallel echo"
  └ first parallel echo

• Ran echo "third parallel echo"
  └ third parallel echo

• Three little waves went out in parallel.

  1. printed first parallel echo
  2. was blocked before execution because it contained the exact test string [block-pre-tool-use]
  3. printed third parallel echo

  There was also an unrelated macOS defaults warning around the successful commands, but the echoes
  themselves worked fine. If you want, I can rerun the second one with a slightly modified string so
  it passes cleanly.
```
This commit is contained in:
Andrei Eternal
2026-03-23 14:32:59 -07:00
committed by GitHub
parent 18f1a08bc9
commit 73bbb07ba8
38 changed files with 1877 additions and 55 deletions

View File

@@ -8,6 +8,8 @@ pub(crate) struct HooksFile {
#[derive(Debug, Default, Deserialize)]
pub(crate) struct HookEvents {
#[serde(rename = "PreToolUse", default)]
pub pre_tool_use: Vec<MatcherGroup>,
#[serde(rename = "SessionStart", default)]
pub session_start: Vec<MatcherGroup>,
#[serde(rename = "UserPromptSubmit", default)]

View File

@@ -3,11 +3,12 @@ use std::path::Path;
use codex_config::ConfigLayerStack;
use codex_config::ConfigLayerStackOrdering;
use regex::Regex;
use super::ConfiguredHandler;
use super::config::HookHandlerConfig;
use super::config::HooksFile;
use crate::events::common::matcher_pattern_for_event;
use crate::events::common::validate_matcher_pattern;
pub(crate) struct DiscoveryResult {
pub handlers: Vec<ConfiguredHandler>,
@@ -69,6 +70,21 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
}
};
for group in parsed.hooks.pre_tool_use {
append_group_handlers(
&mut handlers,
&mut warnings,
&mut display_order,
source_path.as_path(),
codex_protocol::protocol::HookEventName::PreToolUse,
matcher_pattern_for_event(
codex_protocol::protocol::HookEventName::PreToolUse,
group.matcher.as_deref(),
),
group.hooks,
);
}
for group in parsed.hooks.session_start {
append_group_handlers(
&mut handlers,
@@ -76,7 +92,7 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
&mut display_order,
source_path.as_path(),
codex_protocol::protocol::HookEventName::SessionStart,
effective_matcher(
matcher_pattern_for_event(
codex_protocol::protocol::HookEventName::SessionStart,
group.matcher.as_deref(),
),
@@ -91,7 +107,7 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
&mut display_order,
source_path.as_path(),
codex_protocol::protocol::HookEventName::UserPromptSubmit,
effective_matcher(
matcher_pattern_for_event(
codex_protocol::protocol::HookEventName::UserPromptSubmit,
group.matcher.as_deref(),
),
@@ -106,7 +122,7 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
&mut display_order,
source_path.as_path(),
codex_protocol::protocol::HookEventName::Stop,
effective_matcher(
matcher_pattern_for_event(
codex_protocol::protocol::HookEventName::Stop,
group.matcher.as_deref(),
),
@@ -118,17 +134,6 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) -
DiscoveryResult { handlers, warnings }
}
fn effective_matcher(
event_name: codex_protocol::protocol::HookEventName,
matcher: Option<&str>,
) -> Option<&str> {
match event_name {
codex_protocol::protocol::HookEventName::SessionStart => matcher,
codex_protocol::protocol::HookEventName::UserPromptSubmit
| codex_protocol::protocol::HookEventName::Stop => None,
}
}
fn append_group_handlers(
handlers: &mut Vec<ConfiguredHandler>,
warnings: &mut Vec<String>,
@@ -139,7 +144,7 @@ fn append_group_handlers(
group_handlers: Vec<HookHandlerConfig>,
) {
if let Some(matcher) = matcher
&& let Err(err) = Regex::new(matcher)
&& let Err(err) = validate_matcher_pattern(matcher)
{
warnings.push(format!(
"invalid matcher {matcher:?} in {}: {err}",
@@ -205,7 +210,7 @@ mod tests {
use super::ConfiguredHandler;
use super::HookHandlerConfig;
use super::append_group_handlers;
use super::effective_matcher;
use crate::events::common::matcher_pattern_for_event;
#[test]
fn user_prompt_submit_ignores_invalid_matcher_during_discovery() {
@@ -219,7 +224,7 @@ mod tests {
&mut display_order,
Path::new("/tmp/hooks.json"),
HookEventName::UserPromptSubmit,
effective_matcher(HookEventName::UserPromptSubmit, Some("[")),
matcher_pattern_for_event(HookEventName::UserPromptSubmit, Some("[")),
vec![HookHandlerConfig::Command {
command: "echo hello".to_string(),
timeout_sec: None,
@@ -242,4 +247,66 @@ mod tests {
}]
);
}
#[test]
fn pre_tool_use_keeps_valid_matcher_during_discovery() {
let mut handlers = Vec::new();
let mut warnings = Vec::new();
let mut display_order = 0;
append_group_handlers(
&mut handlers,
&mut warnings,
&mut display_order,
Path::new("/tmp/hooks.json"),
HookEventName::PreToolUse,
matcher_pattern_for_event(HookEventName::PreToolUse, Some("^Bash$")),
vec![HookHandlerConfig::Command {
command: "echo hello".to_string(),
timeout_sec: None,
r#async: false,
status_message: None,
}],
);
assert_eq!(warnings, Vec::<String>::new());
assert_eq!(
handlers,
vec![ConfiguredHandler {
event_name: HookEventName::PreToolUse,
matcher: Some("^Bash$".to_string()),
command: "echo hello".to_string(),
timeout_sec: 600,
status_message: None,
source_path: PathBuf::from("/tmp/hooks.json"),
display_order: 0,
}]
);
}
#[test]
fn pre_tool_use_treats_star_matcher_as_match_all() {
let mut handlers = Vec::new();
let mut warnings = Vec::new();
let mut display_order = 0;
append_group_handlers(
&mut handlers,
&mut warnings,
&mut display_order,
Path::new("/tmp/hooks.json"),
HookEventName::PreToolUse,
matcher_pattern_for_event(HookEventName::PreToolUse, Some("*")),
vec![HookHandlerConfig::Command {
command: "echo hello".to_string(),
timeout_sec: None,
r#async: false,
status_message: None,
}],
);
assert_eq!(warnings, Vec::<String>::new());
assert_eq!(handlers.len(), 1);
assert_eq!(handlers[0].matcher.as_deref(), Some("*"));
}
}

View File

@@ -14,6 +14,7 @@ use super::CommandShell;
use super::ConfiguredHandler;
use super::command_runner::CommandRunResult;
use super::command_runner::run_command;
use crate::events::common::matches_matcher;
#[derive(Debug)]
pub(crate) struct ParsedHandler<T> {
@@ -30,13 +31,9 @@ pub(crate) fn select_handlers(
.iter()
.filter(|handler| handler.event_name == event_name)
.filter(|handler| match event_name {
HookEventName::SessionStart => match (&handler.matcher, matcher_input) {
(Some(matcher), Some(input)) => regex::Regex::new(matcher)
.map(|regex| regex.is_match(input))
.unwrap_or(false),
(None, _) => true,
_ => false,
},
HookEventName::PreToolUse | HookEventName::SessionStart => {
matches_matcher(handler.matcher.as_deref(), matcher_input)
}
HookEventName::UserPromptSubmit | HookEventName::Stop => true,
})
.cloned()
@@ -109,7 +106,9 @@ pub(crate) fn completed_summary(
fn scope_for_event(event_name: HookEventName) -> HookScope {
match event_name {
HookEventName::SessionStart => HookScope::Thread,
HookEventName::UserPromptSubmit | HookEventName::Stop => HookScope::Turn,
HookEventName::PreToolUse | HookEventName::UserPromptSubmit | HookEventName::Stop => {
HookScope::Turn
}
}
}
@@ -172,6 +171,50 @@ mod tests {
assert_eq!(selected[1].display_order, 1);
}
#[test]
fn pre_tool_use_matches_tool_name() {
let handlers = vec![
make_handler(HookEventName::PreToolUse, Some("^Bash$"), "echo same", 0),
make_handler(HookEventName::PreToolUse, Some("^Edit$"), "echo same", 1),
];
let selected = select_handlers(&handlers, HookEventName::PreToolUse, Some("Bash"));
assert_eq!(selected.len(), 1);
assert_eq!(selected[0].display_order, 0);
}
#[test]
fn pre_tool_use_star_matcher_matches_all_tools() {
let handlers = vec![
make_handler(HookEventName::PreToolUse, Some("*"), "echo same", 0),
make_handler(HookEventName::PreToolUse, Some("^Edit$"), "echo same", 1),
];
let selected = select_handlers(&handlers, HookEventName::PreToolUse, Some("Bash"));
assert_eq!(selected.len(), 1);
assert_eq!(selected[0].display_order, 0);
}
#[test]
fn pre_tool_use_regex_alternation_matches_each_tool_name() {
let handlers = vec![make_handler(
HookEventName::PreToolUse,
Some("Edit|Write"),
"echo same",
0,
)];
let selected_edit = select_handlers(&handlers, HookEventName::PreToolUse, Some("Edit"));
let selected_write = select_handlers(&handlers, HookEventName::PreToolUse, Some("Write"));
let selected_bash = select_handlers(&handlers, HookEventName::PreToolUse, Some("Bash"));
assert_eq!(selected_edit.len(), 1);
assert_eq!(selected_write.len(), 1);
assert_eq!(selected_bash.len(), 0);
}
#[test]
fn user_prompt_submit_ignores_matcher() {
let handlers = vec![

View File

@@ -10,6 +10,8 @@ use std::path::PathBuf;
use codex_config::ConfigLayerStack;
use codex_protocol::protocol::HookRunSummary;
use crate::events::pre_tool_use::PreToolUseOutcome;
use crate::events::pre_tool_use::PreToolUseRequest;
use crate::events::session_start::SessionStartOutcome;
use crate::events::session_start::SessionStartRequest;
use crate::events::stop::StopOutcome;
@@ -46,6 +48,7 @@ impl ConfiguredHandler {
fn event_name_label(&self) -> &'static str {
match self.event_name {
codex_protocol::protocol::HookEventName::PreToolUse => "pre-tool-use",
codex_protocol::protocol::HookEventName::SessionStart => "session-start",
codex_protocol::protocol::HookEventName::UserPromptSubmit => "user-prompt-submit",
codex_protocol::protocol::HookEventName::Stop => "stop",
@@ -105,6 +108,10 @@ impl ClaudeHooksEngine {
crate::events::session_start::preview(&self.handlers, request)
}
pub(crate) fn preview_pre_tool_use(&self, request: &PreToolUseRequest) -> Vec<HookRunSummary> {
crate::events::pre_tool_use::preview(&self.handlers, request)
}
pub(crate) async fn run_session_start(
&self,
request: SessionStartRequest,
@@ -113,6 +120,10 @@ impl ClaudeHooksEngine {
crate::events::session_start::run(&self.handlers, &self.shell, request, turn_id).await
}
pub(crate) async fn run_pre_tool_use(&self, request: PreToolUseRequest) -> PreToolUseOutcome {
crate::events::pre_tool_use::run(&self.handlers, &self.shell, request).await
}
pub(crate) fn preview_user_prompt_submit(
&self,
request: &UserPromptSubmitRequest,

View File

@@ -12,6 +12,13 @@ pub(crate) struct SessionStartOutput {
pub additional_context: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct PreToolUseOutput {
pub universal: UniversalOutput,
pub block_reason: Option<String>,
pub invalid_reason: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct UserPromptSubmitOutput {
pub universal: UniversalOutput,
@@ -31,6 +38,9 @@ pub(crate) struct StopOutput {
use crate::schema::BlockDecisionWire;
use crate::schema::HookUniversalOutputWire;
use crate::schema::PreToolUseCommandOutputWire;
use crate::schema::PreToolUseDecisionWire;
use crate::schema::PreToolUsePermissionDecisionWire;
use crate::schema::SessionStartCommandOutputWire;
use crate::schema::StopCommandOutputWire;
use crate::schema::UserPromptSubmitCommandOutputWire;
@@ -46,6 +56,54 @@ pub(crate) fn parse_session_start(stdout: &str) -> Option<SessionStartOutput> {
})
}
pub(crate) fn parse_pre_tool_use(stdout: &str) -> Option<PreToolUseOutput> {
let PreToolUseCommandOutputWire {
universal: universal_wire,
decision,
reason,
hook_specific_output,
} = parse_json(stdout)?;
let universal = UniversalOutput::from(universal_wire);
let hook_specific_output = hook_specific_output.as_ref();
let use_hook_specific_decision = hook_specific_output.is_some_and(|output| {
output.permission_decision.is_some()
|| output.permission_decision_reason.is_some()
|| output.updated_input.is_some()
|| output.additional_context.is_some()
});
let invalid_reason = unsupported_pre_tool_use_universal(&universal).or_else(|| {
if use_hook_specific_decision {
hook_specific_output.and_then(unsupported_pre_tool_use_hook_specific_output)
} else {
unsupported_pre_tool_use_legacy_decision(decision.as_ref(), reason.as_deref())
}
});
let block_reason = if invalid_reason.is_none() {
if use_hook_specific_decision {
hook_specific_output.and_then(|output| match output.permission_decision {
Some(PreToolUsePermissionDecisionWire::Deny) => output
.permission_decision_reason
.as_deref()
.and_then(trimmed_reason),
_ => None,
})
} else {
match decision.as_ref() {
Some(PreToolUseDecisionWire::Block) => reason.as_deref().and_then(trimmed_reason),
Some(PreToolUseDecisionWire::Approve) | None => None,
}
}
} else {
None
};
Some(PreToolUseOutput {
universal,
block_reason,
invalid_reason,
})
}
pub(crate) fn parse_user_prompt_submit(stdout: &str) -> Option<UserPromptSubmitOutput> {
let wire: UserPromptSubmitCommandOutputWire = parse_json(stdout)?;
let should_block = matches!(wire.decision, Some(BlockDecisionWire::Block));
@@ -119,3 +177,97 @@ where
fn invalid_block_message(event_name: &str) -> String {
format!("{event_name} hook returned decision:block without a non-empty reason")
}
fn unsupported_pre_tool_use_universal(universal: &UniversalOutput) -> Option<String> {
if !universal.continue_processing {
Some("PreToolUse hook returned unsupported continue:false".to_string())
} else if universal.stop_reason.is_some() {
Some("PreToolUse hook returned unsupported stopReason".to_string())
} else if universal.suppress_output {
Some("PreToolUse hook returned unsupported suppressOutput".to_string())
} else {
None
}
}
fn unsupported_pre_tool_use_hook_specific_output(
output: &crate::schema::PreToolUseHookSpecificOutputWire,
) -> Option<String> {
if output.updated_input.is_some() {
Some("PreToolUse hook returned unsupported updatedInput".to_string())
} else if output
.additional_context
.as_deref()
.and_then(trimmed_reason)
.is_some()
{
Some("PreToolUse hook returned unsupported additionalContext".to_string())
} else {
match output.permission_decision {
Some(PreToolUsePermissionDecisionWire::Allow) => {
Some("PreToolUse hook returned unsupported permissionDecision:allow".to_string())
}
Some(PreToolUsePermissionDecisionWire::Ask) => {
Some("PreToolUse hook returned unsupported permissionDecision:ask".to_string())
}
Some(PreToolUsePermissionDecisionWire::Deny) => {
if output
.permission_decision_reason
.as_deref()
.and_then(trimmed_reason)
.is_none()
{
Some(invalid_pre_tool_use_reason_message())
} else {
None
}
}
None => {
if output.permission_decision_reason.is_some() {
Some("PreToolUse hook returned permissionDecisionReason without permissionDecision".to_string())
} else {
None
}
}
}
}
}
fn unsupported_pre_tool_use_legacy_decision(
decision: Option<&PreToolUseDecisionWire>,
reason: Option<&str>,
) -> Option<String> {
match decision {
Some(PreToolUseDecisionWire::Approve) => {
Some("PreToolUse hook returned unsupported decision:approve".to_string())
}
Some(PreToolUseDecisionWire::Block) => {
if reason.and_then(trimmed_reason).is_none() {
Some(invalid_block_message("PreToolUse"))
} else {
None
}
}
None => {
if reason.is_some() {
Some("PreToolUse hook returned reason without decision".to_string())
} else {
None
}
}
}
}
fn invalid_pre_tool_use_reason_message() -> String {
"PreToolUse hook returned permissionDecision:deny without a non-empty permissionDecisionReason"
.to_string()
}
fn trimmed_reason(reason: &str) -> Option<String> {
let trimmed = reason.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}

View File

@@ -4,6 +4,8 @@ use serde_json::Value;
#[allow(dead_code)]
pub(crate) struct GeneratedHookSchemas {
pub pre_tool_use_command_input: Value,
pub pre_tool_use_command_output: Value,
pub session_start_command_input: Value,
pub session_start_command_output: Value,
pub user_prompt_submit_command_input: Value,
@@ -15,6 +17,14 @@ pub(crate) struct GeneratedHookSchemas {
pub(crate) fn generated_hook_schemas() -> &'static GeneratedHookSchemas {
static SCHEMAS: OnceLock<GeneratedHookSchemas> = OnceLock::new();
SCHEMAS.get_or_init(|| GeneratedHookSchemas {
pre_tool_use_command_input: parse_json_schema(
"pre-tool-use.command.input",
include_str!("../../schema/generated/pre-tool-use.command.input.schema.json"),
),
pre_tool_use_command_output: parse_json_schema(
"pre-tool-use.command.output",
include_str!("../../schema/generated/pre-tool-use.command.output.schema.json"),
),
session_start_command_input: parse_json_schema(
"session-start.command.input",
include_str!("../../schema/generated/session-start.command.input.schema.json"),
@@ -56,6 +66,8 @@ mod tests {
fn loads_generated_hook_schemas() {
let schemas = generated_hook_schemas();
assert_eq!(schemas.pre_tool_use_command_input["type"], "object");
assert_eq!(schemas.pre_tool_use_command_output["type"], "object");
assert_eq!(schemas.session_start_command_input["type"], "object");
assert_eq!(schemas.session_start_command_output["type"], "object");
assert_eq!(schemas.user_prompt_submit_command_input["type"], "object");