mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[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:
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user