[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
Unverified
parent 18f1a08bc9
commit 73bbb07ba8
38 changed files with 1877 additions and 55 deletions
@@ -10774,6 +10774,7 @@ fn extract_first_bold(s: &str) -> Option<String> {
fn hook_event_label(event_name: codex_protocol::protocol::HookEventName) -> &'static str {
match event_name {
codex_protocol::protocol::HookEventName::PreToolUse => "PreToolUse",
codex_protocol::protocol::HookEventName::SessionStart => "SessionStart",
codex_protocol::protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit",
codex_protocol::protocol::HookEventName::Stop => "Stop",
@@ -1,10 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 8586
expression: combined
---
• Running SessionStart hook: warming the shell
SessionStart hook (completed)
warning: Heads up from the hook
hook context: Remember the startup checklist.
@@ -0,0 +1,9 @@
---
source: tui_app_server/src/chatwidget/tests.rs
expression: combined
---
• Running PreToolUse hook: warming the shell
PreToolUse hook (completed)
warning: Heads up from the hook
hook context: Remember the startup checklist.
+36 -10
View File
@@ -12465,7 +12465,33 @@ async fn deltas_then_same_final_message_are_rendered_snapshot() {
}
#[tokio::test]
async fn hook_events_render_snapshot() {
async fn pre_tool_use_hook_events_render_snapshot() {
assert_hook_events_snapshot(
codex_protocol::protocol::HookEventName::PreToolUse,
"pre-tool-use:0:/tmp/hooks.json",
"warming the shell",
"pre_tool_use_hook_events_render_snapshot",
)
.await;
}
#[tokio::test]
async fn session_start_hook_events_render_snapshot() {
assert_hook_events_snapshot(
codex_protocol::protocol::HookEventName::SessionStart,
"session-start:0:/tmp/hooks.json",
"warming the shell",
"session_start_hook_events_render_snapshot",
)
.await;
}
async fn assert_hook_events_snapshot(
event_name: codex_protocol::protocol::HookEventName,
run_id: &str,
status_message: &str,
snapshot_name: &str,
) {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.handle_codex_event(Event {
@@ -12473,15 +12499,15 @@ async fn hook_events_render_snapshot() {
msg: EventMsg::HookStarted(codex_protocol::protocol::HookStartedEvent {
turn_id: None,
run: codex_protocol::protocol::HookRunSummary {
id: "session-start:0:/tmp/hooks.json".to_string(),
event_name: codex_protocol::protocol::HookEventName::SessionStart,
id: run_id.to_string(),
event_name,
handler_type: codex_protocol::protocol::HookHandlerType::Command,
execution_mode: codex_protocol::protocol::HookExecutionMode::Sync,
scope: codex_protocol::protocol::HookScope::Thread,
scope: codex_protocol::protocol::HookScope::Turn,
source_path: PathBuf::from("/tmp/hooks.json"),
display_order: 0,
status: codex_protocol::protocol::HookRunStatus::Running,
status_message: Some("warming the shell".to_string()),
status_message: Some(status_message.to_string()),
started_at: 1,
completed_at: None,
duration_ms: None,
@@ -12495,15 +12521,15 @@ async fn hook_events_render_snapshot() {
msg: EventMsg::HookCompleted(codex_protocol::protocol::HookCompletedEvent {
turn_id: None,
run: codex_protocol::protocol::HookRunSummary {
id: "session-start:0:/tmp/hooks.json".to_string(),
event_name: codex_protocol::protocol::HookEventName::SessionStart,
id: run_id.to_string(),
event_name,
handler_type: codex_protocol::protocol::HookHandlerType::Command,
execution_mode: codex_protocol::protocol::HookExecutionMode::Sync,
scope: codex_protocol::protocol::HookScope::Thread,
scope: codex_protocol::protocol::HookScope::Turn,
source_path: PathBuf::from("/tmp/hooks.json"),
display_order: 0,
status: codex_protocol::protocol::HookRunStatus::Completed,
status_message: Some("warming the shell".to_string()),
status_message: Some(status_message.to_string()),
started_at: 1,
completed_at: Some(11),
duration_ms: Some(10),
@@ -12526,7 +12552,7 @@ async fn hook_events_render_snapshot() {
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
assert_snapshot!("hook_events_render_snapshot", combined);
assert_snapshot!(snapshot_name, combined);
}
// Combined visual snapshot using vt100 for history + direct buffer overlay for UI.