[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
parent 18f1a08bc9
commit 73bbb07ba8
38 changed files with 1877 additions and 55 deletions
+152
View File
@@ -12,6 +12,13 @@ pub(crate) struct SessionStartOutput {
pub additional_context: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct PreToolUseOutput {
pub universal: UniversalOutput,
pub block_reason: Option<String>,
pub invalid_reason: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct UserPromptSubmitOutput {
pub universal: UniversalOutput,
@@ -31,6 +38,9 @@ pub(crate) struct StopOutput {
use crate::schema::BlockDecisionWire;
use crate::schema::HookUniversalOutputWire;
use crate::schema::PreToolUseCommandOutputWire;
use crate::schema::PreToolUseDecisionWire;
use crate::schema::PreToolUsePermissionDecisionWire;
use crate::schema::SessionStartCommandOutputWire;
use crate::schema::StopCommandOutputWire;
use crate::schema::UserPromptSubmitCommandOutputWire;
@@ -46,6 +56,54 @@ pub(crate) fn parse_session_start(stdout: &str) -> Option<SessionStartOutput> {
})
}
pub(crate) fn parse_pre_tool_use(stdout: &str) -> Option<PreToolUseOutput> {
let PreToolUseCommandOutputWire {
universal: universal_wire,
decision,
reason,
hook_specific_output,
} = parse_json(stdout)?;
let universal = UniversalOutput::from(universal_wire);
let hook_specific_output = hook_specific_output.as_ref();
let use_hook_specific_decision = hook_specific_output.is_some_and(|output| {
output.permission_decision.is_some()
|| output.permission_decision_reason.is_some()
|| output.updated_input.is_some()
|| output.additional_context.is_some()
});
let invalid_reason = unsupported_pre_tool_use_universal(&universal).or_else(|| {
if use_hook_specific_decision {
hook_specific_output.and_then(unsupported_pre_tool_use_hook_specific_output)
} else {
unsupported_pre_tool_use_legacy_decision(decision.as_ref(), reason.as_deref())
}
});
let block_reason = if invalid_reason.is_none() {
if use_hook_specific_decision {
hook_specific_output.and_then(|output| match output.permission_decision {
Some(PreToolUsePermissionDecisionWire::Deny) => output
.permission_decision_reason
.as_deref()
.and_then(trimmed_reason),
_ => None,
})
} else {
match decision.as_ref() {
Some(PreToolUseDecisionWire::Block) => reason.as_deref().and_then(trimmed_reason),
Some(PreToolUseDecisionWire::Approve) | None => None,
}
}
} else {
None
};
Some(PreToolUseOutput {
universal,
block_reason,
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));
@@ -119,3 +177,97 @@ where
fn invalid_block_message(event_name: &str) -> String {
format!("{event_name} hook returned decision:block without a non-empty reason")
}
fn unsupported_pre_tool_use_universal(universal: &UniversalOutput) -> Option<String> {
if !universal.continue_processing {
Some("PreToolUse hook returned unsupported continue:false".to_string())
} else if universal.stop_reason.is_some() {
Some("PreToolUse hook returned unsupported stopReason".to_string())
} else if universal.suppress_output {
Some("PreToolUse hook returned unsupported suppressOutput".to_string())
} else {
None
}
}
fn unsupported_pre_tool_use_hook_specific_output(
output: &crate::schema::PreToolUseHookSpecificOutputWire,
) -> Option<String> {
if output.updated_input.is_some() {
Some("PreToolUse hook returned unsupported updatedInput".to_string())
} else if output
.additional_context
.as_deref()
.and_then(trimmed_reason)
.is_some()
{
Some("PreToolUse hook returned unsupported additionalContext".to_string())
} else {
match output.permission_decision {
Some(PreToolUsePermissionDecisionWire::Allow) => {
Some("PreToolUse hook returned unsupported permissionDecision:allow".to_string())
}
Some(PreToolUsePermissionDecisionWire::Ask) => {
Some("PreToolUse hook returned unsupported permissionDecision:ask".to_string())
}
Some(PreToolUsePermissionDecisionWire::Deny) => {
if output
.permission_decision_reason
.as_deref()
.and_then(trimmed_reason)
.is_none()
{
Some(invalid_pre_tool_use_reason_message())
} else {
None
}
}
None => {
if output.permission_decision_reason.is_some() {
Some("PreToolUse hook returned permissionDecisionReason without permissionDecision".to_string())
} else {
None
}
}
}
}
}
fn unsupported_pre_tool_use_legacy_decision(
decision: Option<&PreToolUseDecisionWire>,
reason: Option<&str>,
) -> Option<String> {
match decision {
Some(PreToolUseDecisionWire::Approve) => {
Some("PreToolUse hook returned unsupported decision:approve".to_string())
}
Some(PreToolUseDecisionWire::Block) => {
if reason.and_then(trimmed_reason).is_none() {
Some(invalid_block_message("PreToolUse"))
} else {
None
}
}
None => {
if reason.is_some() {
Some("PreToolUse hook returned reason without decision".to_string())
} else {
None
}
}
}
}
fn invalid_pre_tool_use_reason_message() -> String {
"PreToolUse hook returned permissionDecision:deny without a non-empty permissionDecisionReason"
.to_string()
}
fn trimmed_reason(reason: &str) -> Option<String> {
let trimmed = reason.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}