[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
+111 -1
View File
@@ -13,6 +13,8 @@ use std::path::Path;
use std::path::PathBuf;
const GENERATED_DIR: &str = "generated";
const PRE_TOOL_USE_INPUT_FIXTURE: &str = "pre-tool-use.command.input.schema.json";
const PRE_TOOL_USE_OUTPUT_FIXTURE: &str = "pre-tool-use.command.output.schema.json";
const SESSION_START_INPUT_FIXTURE: &str = "session-start.command.input.schema.json";
const SESSION_START_OUTPUT_FIXTURE: &str = "session-start.command.output.schema.json";
const USER_PROMPT_SUBMIT_INPUT_FIXTURE: &str = "user-prompt-submit.command.input.schema.json";
@@ -63,6 +65,8 @@ pub(crate) struct HookUniversalOutputWire {
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub(crate) enum HookEventNameWire {
#[serde(rename = "PreToolUse")]
PreToolUse,
#[serde(rename = "SessionStart")]
SessionStart,
#[serde(rename = "UserPromptSubmit")]
@@ -71,6 +75,81 @@ pub(crate) enum HookEventNameWire {
Stop,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
#[schemars(rename = "pre-tool-use.command.output")]
pub(crate) struct PreToolUseCommandOutputWire {
#[serde(flatten)]
pub universal: HookUniversalOutputWire,
#[serde(default)]
pub decision: Option<PreToolUseDecisionWire>,
#[serde(default)]
pub reason: Option<String>,
#[serde(default)]
pub hook_specific_output: Option<PreToolUseHookSpecificOutputWire>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub(crate) struct PreToolUseHookSpecificOutputWire {
pub hook_event_name: HookEventNameWire,
#[serde(default)]
pub permission_decision: Option<PreToolUsePermissionDecisionWire>,
#[serde(default)]
pub permission_decision_reason: Option<String>,
#[serde(default)]
pub updated_input: Option<Value>,
#[serde(default)]
pub additional_context: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub(crate) enum PreToolUsePermissionDecisionWire {
#[serde(rename = "allow")]
Allow,
#[serde(rename = "deny")]
Deny,
#[serde(rename = "ask")]
Ask,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub(crate) enum PreToolUseDecisionWire {
#[serde(rename = "approve")]
Approve,
#[serde(rename = "block")]
Block,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
pub(crate) struct PreToolUseToolInput {
pub command: String,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(rename = "pre-tool-use.command.input")]
pub(crate) struct PreToolUseCommandInput {
pub session_id: String,
/// Codex extension: expose the active turn id to internal turn-scoped hooks.
pub turn_id: String,
pub transcript_path: NullableString,
pub cwd: String,
#[schemars(schema_with = "pre_tool_use_hook_event_name_schema")]
pub hook_event_name: String,
pub model: String,
#[schemars(schema_with = "permission_mode_schema")]
pub permission_mode: String,
#[schemars(schema_with = "pre_tool_use_tool_name_schema")]
pub tool_name: String,
pub tool_input: PreToolUseToolInput,
pub tool_use_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[serde(deny_unknown_fields)]
@@ -212,6 +291,14 @@ pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> {
let generated_dir = schema_root.join(GENERATED_DIR);
ensure_empty_dir(&generated_dir)?;
write_schema(
&generated_dir.join(PRE_TOOL_USE_INPUT_FIXTURE),
schema_json::<PreToolUseCommandInput>()?,
)?;
write_schema(
&generated_dir.join(PRE_TOOL_USE_OUTPUT_FIXTURE),
schema_json::<PreToolUseCommandOutputWire>()?,
)?;
write_schema(
&generated_dir.join(SESSION_START_INPUT_FIXTURE),
schema_json::<SessionStartCommandInput>()?,
@@ -295,6 +382,14 @@ fn session_start_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
string_const_schema("SessionStart")
}
fn pre_tool_use_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
string_const_schema("PreToolUse")
}
fn pre_tool_use_tool_name_schema(_gen: &mut SchemaGenerator) -> Schema {
string_const_schema("Bash")
}
fn user_prompt_submit_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
string_const_schema("UserPromptSubmit")
}
@@ -346,6 +441,9 @@ fn default_continue() -> bool {
#[cfg(test)]
mod tests {
use super::PRE_TOOL_USE_INPUT_FIXTURE;
use super::PRE_TOOL_USE_OUTPUT_FIXTURE;
use super::PreToolUseCommandInput;
use super::SESSION_START_INPUT_FIXTURE;
use super::SESSION_START_OUTPUT_FIXTURE;
use super::STOP_INPUT_FIXTURE;
@@ -362,6 +460,12 @@ mod tests {
fn expected_fixture(name: &str) -> &'static str {
match name {
PRE_TOOL_USE_INPUT_FIXTURE => {
include_str!("../schema/generated/pre-tool-use.command.input.schema.json")
}
PRE_TOOL_USE_OUTPUT_FIXTURE => {
include_str!("../schema/generated/pre-tool-use.command.output.schema.json")
}
SESSION_START_INPUT_FIXTURE => {
include_str!("../schema/generated/session-start.command.input.schema.json")
}
@@ -395,6 +499,8 @@ mod tests {
write_schema_fixtures(&schema_root).expect("write generated hook schemas");
for fixture in [
PRE_TOOL_USE_INPUT_FIXTURE,
PRE_TOOL_USE_OUTPUT_FIXTURE,
SESSION_START_INPUT_FIXTURE,
SESSION_START_OUTPUT_FIXTURE,
USER_PROMPT_SUBMIT_INPUT_FIXTURE,
@@ -414,6 +520,10 @@ mod tests {
fn turn_scoped_hook_inputs_include_codex_turn_id_extension() {
// Codex intentionally diverges from Claude's public hook docs here so
// internal hook consumers can key off the active turn.
let pre_tool_use: Value = serde_json::from_slice(
&schema_json::<PreToolUseCommandInput>().expect("serialize pre tool use input schema"),
)
.expect("parse pre tool use input schema");
let user_prompt_submit: Value = serde_json::from_slice(
&schema_json::<UserPromptSubmitCommandInput>()
.expect("serialize user prompt submit input schema"),
@@ -424,7 +534,7 @@ mod tests {
)
.expect("parse stop input schema");
for schema in [&user_prompt_submit, &stop] {
for schema in [&pre_tool_use, &user_prompt_submit, &stop] {
assert_eq!(schema["properties"]["turn_id"]["type"], "string");
assert!(
schema["required"]