Files
codex/codex-rs/hooks/src/events/common.rs
T
Andrei Eternal c4d9887f9a [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.
```
2026-03-25 19:18:03 -07:00

187 lines
5.5 KiB
Rust

use codex_protocol::protocol::HookCompletedEvent;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookOutputEntry;
use codex_protocol::protocol::HookOutputEntryKind;
use codex_protocol::protocol::HookRunStatus;
use crate::engine::ConfiguredHandler;
use crate::engine::dispatcher;
pub(crate) fn join_text_chunks(chunks: Vec<String>) -> Option<String> {
if chunks.is_empty() {
None
} else {
Some(chunks.join("\n\n"))
}
}
pub(crate) fn trimmed_non_empty(text: &str) -> Option<String> {
let trimmed = text.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
pub(crate) fn append_additional_context(
entries: &mut Vec<HookOutputEntry>,
additional_contexts_for_model: &mut Vec<String>,
additional_context: String,
) {
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Context,
text: additional_context.clone(),
});
additional_contexts_for_model.push(additional_context);
}
pub(crate) fn flatten_additional_contexts<'a>(
additional_contexts: impl IntoIterator<Item = &'a [String]>,
) -> Vec<String> {
additional_contexts
.into_iter()
.flat_map(|chunk| chunk.iter().cloned())
.collect()
}
pub(crate) fn serialization_failure_hook_events(
handlers: Vec<ConfiguredHandler>,
turn_id: Option<String>,
error_message: String,
) -> Vec<HookCompletedEvent> {
handlers
.into_iter()
.map(|handler| {
let mut run = dispatcher::running_summary(&handler);
run.status = HookRunStatus::Failed;
run.completed_at = Some(run.started_at);
run.duration_ms = Some(0);
run.entries = vec![HookOutputEntry {
kind: HookOutputEntryKind::Error,
text: error_message.clone(),
}];
HookCompletedEvent {
turn_id: turn_id.clone(),
run,
}
})
.collect()
}
pub(crate) fn matcher_pattern_for_event(
event_name: HookEventName,
matcher: Option<&str>,
) -> Option<&str> {
match event_name {
HookEventName::PreToolUse | HookEventName::PostToolUse | HookEventName::SessionStart => {
matcher
}
HookEventName::UserPromptSubmit | HookEventName::Stop => None,
}
}
pub(crate) fn validate_matcher_pattern(matcher: &str) -> Result<(), regex::Error> {
if is_match_all_matcher(matcher) {
return Ok(());
}
regex::Regex::new(matcher).map(|_| ())
}
pub(crate) fn matches_matcher(matcher: Option<&str>, input: Option<&str>) -> bool {
match matcher {
None => true,
Some(matcher) if is_match_all_matcher(matcher) => true,
Some(matcher) => input
.and_then(|input| {
regex::Regex::new(matcher)
.ok()
.map(|regex| regex.is_match(input))
})
.unwrap_or(false),
}
}
fn is_match_all_matcher(matcher: &str) -> bool {
matcher.is_empty() || matcher == "*"
}
#[cfg(test)]
mod tests {
use codex_protocol::protocol::HookEventName;
use pretty_assertions::assert_eq;
use super::matcher_pattern_for_event;
use super::matches_matcher;
use super::validate_matcher_pattern;
#[test]
fn matcher_omitted_matches_all_occurrences() {
assert!(matches_matcher(None, Some("Bash")));
assert!(matches_matcher(None, Some("Write")));
}
#[test]
fn matcher_star_matches_all_occurrences() {
assert!(matches_matcher(Some("*"), Some("Bash")));
assert!(matches_matcher(Some("*"), Some("Edit")));
assert_eq!(validate_matcher_pattern("*"), Ok(()));
}
#[test]
fn matcher_empty_string_matches_all_occurrences() {
assert!(matches_matcher(Some(""), Some("Bash")));
assert!(matches_matcher(Some(""), Some("SessionStart")));
assert_eq!(validate_matcher_pattern(""), Ok(()));
}
#[test]
fn matcher_uses_regex_matching() {
assert!(matches_matcher(Some("Edit|Write"), Some("Edit")));
assert!(matches_matcher(Some("Edit|Write"), Some("Write")));
assert!(!matches_matcher(Some("Edit|Write"), Some("Bash")));
assert_eq!(validate_matcher_pattern("Edit|Write"), Ok(()));
}
#[test]
fn matcher_supports_anchored_regexes() {
assert!(matches_matcher(Some("^Bash$"), Some("Bash")));
assert!(!matches_matcher(Some("^Bash$"), Some("BashOutput")));
assert_eq!(validate_matcher_pattern("^Bash$"), Ok(()));
}
#[test]
fn invalid_regex_is_rejected() {
assert!(validate_matcher_pattern("[").is_err());
assert!(!matches_matcher(Some("["), Some("Bash")));
}
#[test]
fn unsupported_events_ignore_matchers() {
assert_eq!(
matcher_pattern_for_event(HookEventName::UserPromptSubmit, Some("^hello")),
None
);
assert_eq!(
matcher_pattern_for_event(HookEventName::Stop, Some("^done$")),
None
);
}
#[test]
fn supported_events_keep_matchers() {
assert_eq!(
matcher_pattern_for_event(HookEventName::PreToolUse, Some("Bash")),
Some("Bash")
);
assert_eq!(
matcher_pattern_for_event(HookEventName::PostToolUse, Some("Edit|Write")),
Some("Edit|Write")
);
assert_eq!(
matcher_pattern_for_event(HookEventName::SessionStart, Some("startup|resume")),
Some("startup|resume")
);
}
}