Respect blocking PostToolUse hooks in code mode (#28365)

## Summary

Make blocking hook behavior reliable for tools invoked from code mode.

Previously, a `PostToolUse` hook could block a completed tool result,
but code mode would still return the original typed result to
JavaScript. The hook appeared blocked in hook telemetry while the
running script continued with the result.

This change:

- rejects the nested JavaScript tool promise when `PostToolUse` blocks
- normalizes `decision: "block"` and exit code 2 to the same blocking
behavior
- surfaces the hook feedback as the rejected promise's error
- adds end-to-end coverage for the relevant PreToolUse and PostToolUse
interactions

## Hook semantics in code mode

| Hook behavior | Code-mode result |
|---|---|
| PreToolUse block | Reject the promise before the tool executes |
| PreToolUse `updatedInput` | Execute the rewritten invocation and
return its result |
| PostToolUse `decision: "block"` | Execute the tool, then reject the
promise with the hook reason |
| PostToolUse exit code 2 | Same behavior as `decision: "block"` |
| PostToolUse `continue: false` | Preserve the existing feedback-only
behavior; do not reject the promise |

## Test coverage

Added or strengthened end-to-end coverage proving that:

- a PreToolUse block rejects the JavaScript promise before execution
- a PreToolUse input rewrite executes only the rewritten command
- JavaScript receives the rewritten command's result
- PostToolUse `decision: "block"` rejects after the command executes
- PostToolUse exit code 2 has the same behavior
- the hook observes the original completed tool response
- the blocked original result does not reach JavaScript
- existing direct-mode replacement behavior remains intact
- `continue: false` without a reason produces deterministic fallback
feedback
This commit is contained in:
Abhinav
2026-06-15 15:12:26 -07:00
committed by GitHub
parent 709f19e111
commit d7f298fe20
3 changed files with 284 additions and 72 deletions
+45 -34
View File
@@ -38,16 +38,14 @@ pub struct PostToolUseRequest {
#[derive(Debug)]
pub struct PostToolUseOutcome {
pub hook_events: Vec<HookCompletedEvent>,
pub should_stop: bool,
pub stop_reason: Option<String>,
pub should_block: bool,
pub additional_contexts: Vec<String>,
pub feedback_message: Option<String>,
}
#[derive(Debug, Default, PartialEq, Eq)]
struct PostToolUseHandlerData {
should_stop: bool,
stop_reason: Option<String>,
should_block: bool,
additional_contexts_for_model: Vec<String>,
feedback_messages_for_model: Vec<String>,
}
@@ -83,8 +81,7 @@ pub(crate) async fn run(
if matched.is_empty() {
return PostToolUseOutcome {
hook_events: Vec::new(),
should_stop: false,
stop_reason: None,
should_block: false,
additional_contexts: Vec::new(),
feedback_message: None,
};
@@ -118,10 +115,7 @@ pub(crate) async fn run(
.iter()
.map(|result| result.data.additional_contexts_for_model.as_slice()),
);
let should_stop = results.iter().any(|result| result.data.should_stop);
let stop_reason = results
.iter()
.find_map(|result| result.data.stop_reason.clone());
let should_block = results.iter().any(|result| result.data.should_block);
let feedback_message = common::join_text_chunks(
results
.iter()
@@ -136,8 +130,7 @@ pub(crate) async fn run(
common::hook_completed_for_tool_use(result.completed, &request.tool_use_id)
})
.collect(),
should_stop,
stop_reason,
should_block,
additional_contexts,
feedback_message,
}
@@ -175,8 +168,7 @@ fn parse_completed(
) -> dispatcher::ParsedHandler<PostToolUseHandlerData> {
let mut entries = Vec::new();
let mut status = HookRunStatus::Completed;
let mut should_stop = false;
let mut stop_reason = None;
let mut should_block = false;
let mut additional_contexts_for_model = Vec::new();
let mut feedback_messages_for_model = Vec::new();
@@ -212,8 +204,6 @@ fn parse_completed(
}
if !parsed.universal.continue_processing {
status = HookRunStatus::Stopped;
should_stop = true;
stop_reason = parsed.universal.stop_reason.clone();
let stop_text = parsed
.universal
.stop_reason
@@ -242,6 +232,7 @@ fn parse_completed(
});
} else if parsed.should_block {
status = HookRunStatus::Blocked;
should_block = true;
if let Some(reason) = parsed.reason {
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Feedback,
@@ -260,6 +251,8 @@ fn parse_completed(
}
Some(2) => {
if let Some(reason) = common::trimmed_non_empty(&run_result.stderr) {
status = HookRunStatus::Blocked;
should_block = true;
entries.push(HookOutputEntry {
kind: HookOutputEntryKind::Feedback,
text: reason.clone(),
@@ -298,8 +291,7 @@ fn parse_completed(
dispatcher::ParsedHandler {
completed,
data: PostToolUseHandlerData {
should_stop,
stop_reason,
should_block,
additional_contexts_for_model,
feedback_messages_for_model,
},
@@ -310,8 +302,7 @@ fn parse_completed(
fn serialization_failure_outcome(hook_events: Vec<HookCompletedEvent>) -> PostToolUseOutcome {
PostToolUseOutcome {
hook_events,
should_stop: false,
stop_reason: None,
should_block: false,
additional_contexts: Vec::new(),
feedback_message: None,
}
@@ -364,8 +355,7 @@ mod tests {
assert_eq!(
parsed.data,
PostToolUseHandlerData {
should_stop: false,
stop_reason: None,
should_block: true,
additional_contexts_for_model: Vec::new(),
feedback_messages_for_model: vec!["bash output looked sketchy".to_string()],
}
@@ -388,8 +378,7 @@ mod tests {
assert_eq!(
parsed.data,
PostToolUseHandlerData {
should_stop: false,
stop_reason: None,
should_block: false,
additional_contexts_for_model: vec!["Remember the bash cleanup note.".to_string()],
feedback_messages_for_model: Vec::new(),
}
@@ -418,8 +407,7 @@ mod tests {
assert_eq!(
parsed.data,
PostToolUseHandlerData {
should_stop: false,
stop_reason: None,
should_block: false,
additional_contexts_for_model: Vec::new(),
feedback_messages_for_model: Vec::new(),
}
@@ -435,7 +423,7 @@ mod tests {
}
#[test]
fn exit_two_surfaces_feedback_to_model_without_blocking() {
fn exit_two_blocks_with_feedback() {
let parsed = parse_completed(
&handler(),
run_result(Some(2), "", "post hook says pause"),
@@ -445,13 +433,12 @@ mod tests {
assert_eq!(
parsed.data,
PostToolUseHandlerData {
should_stop: false,
stop_reason: None,
should_block: true,
additional_contexts_for_model: Vec::new(),
feedback_messages_for_model: vec!["post hook says pause".to_string()],
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Completed);
assert_eq!(parsed.completed.run.status, HookRunStatus::Blocked);
}
#[test]
@@ -469,8 +456,7 @@ mod tests {
assert_eq!(
parsed.data,
PostToolUseHandlerData {
should_stop: true,
stop_reason: Some("halt after bash output".to_string()),
should_block: false,
additional_contexts_for_model: Vec::new(),
feedback_messages_for_model: vec!["post-tool hook says stop".to_string()],
}
@@ -485,6 +471,32 @@ mod tests {
);
}
#[test]
fn continue_false_without_reason_synthesizes_feedback() {
let parsed = parse_completed(
&handler(),
run_result(Some(0), r#"{"continue":false}"#, ""),
Some("turn-1".to_string()),
);
assert_eq!(
parsed.data,
PostToolUseHandlerData {
should_block: false,
additional_contexts_for_model: Vec::new(),
feedback_messages_for_model: vec!["PostToolUse hook stopped execution".to_string()],
}
);
assert_eq!(parsed.completed.run.status, HookRunStatus::Stopped);
assert_eq!(
parsed.completed.run.entries,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Stop,
text: "PostToolUse hook stopped execution".to_string(),
}]
);
}
#[test]
fn plain_stdout_is_ignored_for_post_tool_use() {
let parsed = parse_completed(
@@ -496,8 +508,7 @@ mod tests {
assert_eq!(
parsed.data,
PostToolUseHandlerData {
should_stop: false,
stop_reason: None,
should_block: false,
additional_contexts_for_model: Vec::new(),
feedback_messages_for_model: Vec::new(),
}