mirror of
https://github.com/openai/codex.git
synced 2026-05-05 13:51:29 +03:00
[hooks] add non-streaming (non-stdin style) shell-only PostToolUse support (#15531)
CHAINED PR - note that base is eternal/hooks-pretooluse-bash, not main -- so the following PR should be first Matching post-tool hook to the pre-tool functionality here: https://github.com/openai/codex/pull/15211 So, PreToolUse calls for plain shell calls, allows blocking. This PostToolUse call runs after the command executed example run: ``` › as a test, run in parallel the following commands: - echo 'one' - echo '[block-pre-tool-use]' - echo '[block-post-tool-use]' ⚠ MCP startup incomplete (failed: notion, linear) • Cruising through those three commands in parallel now, and I’ll share the exact outputs right after they land. • Running PreToolUse hook: checking the observatory runes • Running PreToolUse hook: checking the observatory runes • Running PreToolUse hook: checking the observatory runes 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 'one' PreToolUse hook (completed) warning: wizard-tower PreToolUse demo inspected Bash: echo '[block-post-tool-use]' • Ran echo '[block-post-tool-use]' └ [block-post-tool-use] • Ran echo 'one' └ one • Running PostToolUse hook: reading the observatory aftermath • Running PostToolUse hook: reading the observatory aftermath PostToolUse hook (blocked) warning: wizard-tower PostToolUse demo blocked the Bash follow-through on purpose. feedback: PostToolUse demo blocked the result after execution. Remove [block-post-tool-use] to continue. PostToolUse hook (completed) warning: wizard-tower PostToolUse demo inspected Bash: echo 'one' hook context: PostToolUse demo saw the Bash result. Response preview: one ────────────────────────────────────────────────────────────────────────────────────────────────────── • Ran them in parallel. Little wave report: - echo 'one' completed and returned one. - echo '[block-pre-tool-use]' was blocked before execution by the PreToolUse hook. - echo '[block-post-tool-use]' ran, but its result was blocked afterward by the PostToolUse hook. The hook messages were: - PreToolUse demo blocked the command. Remove [block-pre-tool-use] to continue. - PostToolUse demo blocked the result after execution. Remove [block-post-tool-use] to continue. › for the blocked messages (correctly blocked in this test) did the tool output land back into context? • Yeah, from this run: - PreToolUse block: the command itself did not execute, so no command output landed in context. Only the block message did. - PostToolUse block: the command did execute, but the actual stdout did not come back into context. Only the post-tool block message did. So the blocked notifications landed in context, but the blocked command payloads themselves did not. Nice clean guardrail, aloha. ```
This commit is contained in:
@@ -10,6 +10,8 @@ pub(crate) struct HooksFile {
|
||||
pub(crate) struct HookEvents {
|
||||
#[serde(rename = "PreToolUse", default)]
|
||||
pub pre_tool_use: Vec<MatcherGroup>,
|
||||
#[serde(rename = "PostToolUse", default)]
|
||||
pub post_tool_use: Vec<MatcherGroup>,
|
||||
#[serde(rename = "SessionStart", default)]
|
||||
pub session_start: Vec<MatcherGroup>,
|
||||
#[serde(rename = "UserPromptSubmit", default)]
|
||||
|
||||
@@ -7,6 +7,7 @@ use codex_config::ConfigLayerStackOrdering;
|
||||
use super::ConfiguredHandler;
|
||||
use super::config::HookHandlerConfig;
|
||||
use super::config::HooksFile;
|
||||
use super::config::MatcherGroup;
|
||||
use crate::events::common::matcher_pattern_for_event;
|
||||
use crate::events::common::validate_matcher_pattern;
|
||||
|
||||
@@ -70,63 +71,40 @@ 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(),
|
||||
let super::config::HookEvents {
|
||||
pre_tool_use,
|
||||
post_tool_use,
|
||||
session_start,
|
||||
user_prompt_submit,
|
||||
stop,
|
||||
} = parsed.hooks;
|
||||
|
||||
for (event_name, groups) in [
|
||||
(
|
||||
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,
|
||||
&mut warnings,
|
||||
&mut display_order,
|
||||
source_path.as_path(),
|
||||
pre_tool_use,
|
||||
),
|
||||
(
|
||||
codex_protocol::protocol::HookEventName::PostToolUse,
|
||||
post_tool_use,
|
||||
),
|
||||
(
|
||||
codex_protocol::protocol::HookEventName::SessionStart,
|
||||
matcher_pattern_for_event(
|
||||
codex_protocol::protocol::HookEventName::SessionStart,
|
||||
group.matcher.as_deref(),
|
||||
),
|
||||
group.hooks,
|
||||
);
|
||||
}
|
||||
|
||||
for group in parsed.hooks.user_prompt_submit {
|
||||
append_group_handlers(
|
||||
&mut handlers,
|
||||
&mut warnings,
|
||||
&mut display_order,
|
||||
source_path.as_path(),
|
||||
session_start,
|
||||
),
|
||||
(
|
||||
codex_protocol::protocol::HookEventName::UserPromptSubmit,
|
||||
matcher_pattern_for_event(
|
||||
codex_protocol::protocol::HookEventName::UserPromptSubmit,
|
||||
group.matcher.as_deref(),
|
||||
),
|
||||
group.hooks,
|
||||
);
|
||||
}
|
||||
|
||||
for group in parsed.hooks.stop {
|
||||
append_group_handlers(
|
||||
user_prompt_submit,
|
||||
),
|
||||
(codex_protocol::protocol::HookEventName::Stop, stop),
|
||||
] {
|
||||
append_matcher_groups(
|
||||
&mut handlers,
|
||||
&mut warnings,
|
||||
&mut display_order,
|
||||
source_path.as_path(),
|
||||
codex_protocol::protocol::HookEventName::Stop,
|
||||
matcher_pattern_for_event(
|
||||
codex_protocol::protocol::HookEventName::Stop,
|
||||
group.matcher.as_deref(),
|
||||
),
|
||||
group.hooks,
|
||||
event_name,
|
||||
groups,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -199,6 +177,27 @@ fn append_group_handlers(
|
||||
}
|
||||
}
|
||||
|
||||
fn append_matcher_groups(
|
||||
handlers: &mut Vec<ConfiguredHandler>,
|
||||
warnings: &mut Vec<String>,
|
||||
display_order: &mut i64,
|
||||
source_path: &Path,
|
||||
event_name: codex_protocol::protocol::HookEventName,
|
||||
groups: Vec<MatcherGroup>,
|
||||
) {
|
||||
for group in groups {
|
||||
append_group_handlers(
|
||||
handlers,
|
||||
warnings,
|
||||
display_order,
|
||||
source_path,
|
||||
event_name,
|
||||
matcher_pattern_for_event(event_name, group.matcher.as_deref()),
|
||||
group.hooks,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
@@ -309,4 +308,31 @@ mod tests {
|
||||
assert_eq!(handlers.len(), 1);
|
||||
assert_eq!(handlers[0].matcher.as_deref(), Some("*"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_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::PostToolUse,
|
||||
matcher_pattern_for_event(HookEventName::PostToolUse, Some("Edit|Write")),
|
||||
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].event_name, HookEventName::PostToolUse);
|
||||
assert_eq!(handlers[0].matcher.as_deref(), Some("Edit|Write"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,9 @@ pub(crate) fn select_handlers(
|
||||
.iter()
|
||||
.filter(|handler| handler.event_name == event_name)
|
||||
.filter(|handler| match event_name {
|
||||
HookEventName::PreToolUse | HookEventName::SessionStart => {
|
||||
HookEventName::PreToolUse
|
||||
| HookEventName::PostToolUse
|
||||
| HookEventName::SessionStart => {
|
||||
matches_matcher(handler.matcher.as_deref(), matcher_input)
|
||||
}
|
||||
HookEventName::UserPromptSubmit | HookEventName::Stop => true,
|
||||
@@ -106,9 +108,10 @@ pub(crate) fn completed_summary(
|
||||
fn scope_for_event(event_name: HookEventName) -> HookScope {
|
||||
match event_name {
|
||||
HookEventName::SessionStart => HookScope::Thread,
|
||||
HookEventName::PreToolUse | HookEventName::UserPromptSubmit | HookEventName::Stop => {
|
||||
HookScope::Turn
|
||||
}
|
||||
HookEventName::PreToolUse
|
||||
| HookEventName::PostToolUse
|
||||
| HookEventName::UserPromptSubmit
|
||||
| HookEventName::Stop => HookScope::Turn,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,6 +187,19 @@ mod tests {
|
||||
assert_eq!(selected[0].display_order, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn post_tool_use_matches_tool_name() {
|
||||
let handlers = vec![
|
||||
make_handler(HookEventName::PostToolUse, Some("^Bash$"), "echo same", 0),
|
||||
make_handler(HookEventName::PostToolUse, Some("^Edit$"), "echo same", 1),
|
||||
];
|
||||
|
||||
let selected = select_handlers(&handlers, HookEventName::PostToolUse, 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![
|
||||
|
||||
@@ -10,6 +10,8 @@ use std::path::PathBuf;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_protocol::protocol::HookRunSummary;
|
||||
|
||||
use crate::events::post_tool_use::PostToolUseOutcome;
|
||||
use crate::events::post_tool_use::PostToolUseRequest;
|
||||
use crate::events::pre_tool_use::PreToolUseOutcome;
|
||||
use crate::events::pre_tool_use::PreToolUseRequest;
|
||||
use crate::events::session_start::SessionStartOutcome;
|
||||
@@ -49,6 +51,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::PostToolUse => "post-tool-use",
|
||||
codex_protocol::protocol::HookEventName::SessionStart => "session-start",
|
||||
codex_protocol::protocol::HookEventName::UserPromptSubmit => "user-prompt-submit",
|
||||
codex_protocol::protocol::HookEventName::Stop => "stop",
|
||||
@@ -112,6 +115,13 @@ impl ClaudeHooksEngine {
|
||||
crate::events::pre_tool_use::preview(&self.handlers, request)
|
||||
}
|
||||
|
||||
pub(crate) fn preview_post_tool_use(
|
||||
&self,
|
||||
request: &PostToolUseRequest,
|
||||
) -> Vec<HookRunSummary> {
|
||||
crate::events::post_tool_use::preview(&self.handlers, request)
|
||||
}
|
||||
|
||||
pub(crate) async fn run_session_start(
|
||||
&self,
|
||||
request: SessionStartRequest,
|
||||
@@ -124,6 +134,13 @@ impl ClaudeHooksEngine {
|
||||
crate::events::pre_tool_use::run(&self.handlers, &self.shell, request).await
|
||||
}
|
||||
|
||||
pub(crate) async fn run_post_tool_use(
|
||||
&self,
|
||||
request: PostToolUseRequest,
|
||||
) -> PostToolUseOutcome {
|
||||
crate::events::post_tool_use::run(&self.handlers, &self.shell, request).await
|
||||
}
|
||||
|
||||
pub(crate) fn preview_user_prompt_submit(
|
||||
&self,
|
||||
request: &UserPromptSubmitRequest,
|
||||
|
||||
@@ -19,6 +19,16 @@ pub(crate) struct PreToolUseOutput {
|
||||
pub invalid_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PostToolUseOutput {
|
||||
pub universal: UniversalOutput,
|
||||
pub should_block: bool,
|
||||
pub reason: Option<String>,
|
||||
pub invalid_block_reason: Option<String>,
|
||||
pub additional_context: Option<String>,
|
||||
pub invalid_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct UserPromptSubmitOutput {
|
||||
pub universal: UniversalOutput,
|
||||
@@ -38,6 +48,7 @@ pub(crate) struct StopOutput {
|
||||
|
||||
use crate::schema::BlockDecisionWire;
|
||||
use crate::schema::HookUniversalOutputWire;
|
||||
use crate::schema::PostToolUseCommandOutputWire;
|
||||
use crate::schema::PreToolUseCommandOutputWire;
|
||||
use crate::schema::PreToolUseDecisionWire;
|
||||
use crate::schema::PreToolUsePermissionDecisionWire;
|
||||
@@ -104,6 +115,40 @@ pub(crate) fn parse_pre_tool_use(stdout: &str) -> Option<PreToolUseOutput> {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn parse_post_tool_use(stdout: &str) -> Option<PostToolUseOutput> {
|
||||
let wire: PostToolUseCommandOutputWire = parse_json(stdout)?;
|
||||
let universal = UniversalOutput::from(wire.universal);
|
||||
let invalid_reason = unsupported_post_tool_use_universal(&universal).or_else(|| {
|
||||
wire.hook_specific_output
|
||||
.as_ref()
|
||||
.and_then(unsupported_post_tool_use_hook_specific_output)
|
||||
});
|
||||
let should_block = matches!(wire.decision, Some(BlockDecisionWire::Block));
|
||||
let invalid_block_reason = if should_block
|
||||
&& match wire.reason.as_deref() {
|
||||
Some(reason) => reason.trim().is_empty(),
|
||||
None => true,
|
||||
} {
|
||||
Some(invalid_block_message("PostToolUse"))
|
||||
} else if !should_block && universal.continue_processing && wire.reason.is_some() {
|
||||
Some("PostToolUse hook returned reason without decision".to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let additional_context = wire
|
||||
.hook_specific_output
|
||||
.and_then(|output| output.additional_context);
|
||||
|
||||
Some(PostToolUseOutput {
|
||||
universal,
|
||||
should_block: should_block && invalid_reason.is_none() && invalid_block_reason.is_none(),
|
||||
reason: wire.reason,
|
||||
invalid_block_reason,
|
||||
additional_context,
|
||||
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));
|
||||
@@ -190,6 +235,24 @@ fn unsupported_pre_tool_use_universal(universal: &UniversalOutput) -> Option<Str
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported_post_tool_use_universal(universal: &UniversalOutput) -> Option<String> {
|
||||
if universal.suppress_output {
|
||||
Some("PostToolUse hook returned unsupported suppressOutput".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported_post_tool_use_hook_specific_output(
|
||||
output: &crate::schema::PostToolUseHookSpecificOutputWire,
|
||||
) -> Option<String> {
|
||||
if output.updated_mcp_tool_output.is_some() {
|
||||
Some("PostToolUse hook returned unsupported updatedMCPToolOutput".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported_pre_tool_use_hook_specific_output(
|
||||
output: &crate::schema::PreToolUseHookSpecificOutputWire,
|
||||
) -> Option<String> {
|
||||
|
||||
@@ -4,6 +4,8 @@ use serde_json::Value;
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) struct GeneratedHookSchemas {
|
||||
pub post_tool_use_command_input: Value,
|
||||
pub post_tool_use_command_output: Value,
|
||||
pub pre_tool_use_command_input: Value,
|
||||
pub pre_tool_use_command_output: Value,
|
||||
pub session_start_command_input: Value,
|
||||
@@ -17,6 +19,14 @@ pub(crate) struct GeneratedHookSchemas {
|
||||
pub(crate) fn generated_hook_schemas() -> &'static GeneratedHookSchemas {
|
||||
static SCHEMAS: OnceLock<GeneratedHookSchemas> = OnceLock::new();
|
||||
SCHEMAS.get_or_init(|| GeneratedHookSchemas {
|
||||
post_tool_use_command_input: parse_json_schema(
|
||||
"post-tool-use.command.input",
|
||||
include_str!("../../schema/generated/post-tool-use.command.input.schema.json"),
|
||||
),
|
||||
post_tool_use_command_output: parse_json_schema(
|
||||
"post-tool-use.command.output",
|
||||
include_str!("../../schema/generated/post-tool-use.command.output.schema.json"),
|
||||
),
|
||||
pre_tool_use_command_input: parse_json_schema(
|
||||
"pre-tool-use.command.input",
|
||||
include_str!("../../schema/generated/pre-tool-use.command.input.schema.json"),
|
||||
@@ -66,6 +76,8 @@ mod tests {
|
||||
fn loads_generated_hook_schemas() {
|
||||
let schemas = generated_hook_schemas();
|
||||
|
||||
assert_eq!(schemas.post_tool_use_command_input["type"], "object");
|
||||
assert_eq!(schemas.post_tool_use_command_output["type"], "object");
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user