[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:
Andrei Eternal
2026-03-25 19:18:03 -07:00
committed by GitHub
parent 78799c1bcf
commit c4d9887f9a
43 changed files with 2078 additions and 165 deletions

View File

@@ -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"));
}
}