Support PreToolUse updatedInput rewrites (#20527)

## Why

`PreToolUse` already exposes `updatedInput` in its hook output schema,
but Codex currently rejects it instead of applying the rewrite. That
leaves hook authors unable to make the documented pre-execution
adjustment to a tool call before it runs.

## What

- Accept `updatedInput` from `PreToolUse` hooks when paired with
`permissionDecision: "allow"`.
- Apply the rewritten input before dispatch so the tool executes the
updated payload, not the original one.
- Preserve the stable hook-facing compatibility shapes that
participating tool handlers expose:
- Bash-like tools (`shell`, `container.exec`, `local_shell`,
`shell_command`, `exec_command`) use `{ "command": ... }`.
- `apply_patch` exposes its patch body through the same command-shaped
hook contract.
  - MCP tools expose their JSON argument object directly.
- Keep each participating tool handler responsible for translating
hook-facing `updatedInput` back into its concrete invocation shape.

## Verification

Direct Bash-like rewrite coverage:

- `pre_tool_use_rewrites_shell_before_execution`
- `pre_tool_use_rewrites_container_exec_before_execution`
- `pre_tool_use_rewrites_local_shell_before_execution`
- `pre_tool_use_rewrites_shell_command_before_execution`
- `pre_tool_use_rewrites_exec_command_before_execution`

These cases assert that each supported Bash-like surface runs only the
rewritten command while the hook still observes the original `{
"command": ... }` input.

`pre_tool_use_rewrites_apply_patch_before_execution`

- Model emits one patch.
- Hook swaps in a different patch.
- Asserts only the rewritten file is created, and the hook saw the
original patch.

`pre_tool_use_rewrites_code_mode_nested_exec_command_before_execution`

- Model runs one nested shell command from code mode.
- Hook rewrites it.
- Asserts only the rewritten command runs, and the hook saw the original
nested input.

`pre_tool_use_rewrites_mcp_tool_before_execution`

- Model calls the RMCP echo tool.
- Hook rewrites the MCP arguments.
- Asserts the MCP server receives and returns the rewritten message, not
the original one.
This commit is contained in:
Abhinav
2026-05-11 22:27:24 -04:00
committed by GitHub
Unverified
parent 17ed5ad0b0
commit d08906a944
22 changed files with 1021 additions and 47 deletions
+21 -12
View File
@@ -1,6 +1,7 @@
use std::path::Path;
use futures::future::join_all;
use futures::StreamExt;
use futures::stream::FuturesUnordered;
use codex_protocol::protocol::HookCompletedEvent;
use codex_protocol::protocol::HookEventName;
@@ -20,6 +21,7 @@ use crate::events::common::matches_matcher;
pub(crate) struct ParsedHandler<T> {
pub completed: HookCompletedEvent,
pub data: T,
pub completion_order: usize,
}
pub(crate) fn select_handlers(
@@ -90,18 +92,25 @@ pub(crate) async fn execute_handlers<T>(
turn_id: Option<String>,
parse: fn(&ConfiguredHandler, CommandRunResult, Option<String>) -> ParsedHandler<T>,
) -> Vec<ParsedHandler<T>> {
let results = join_all(
handlers
.iter()
.map(|handler| run_command(shell, handler, &input_json, cwd)),
)
.await;
let mut pending = FuturesUnordered::new();
for (configured_order, handler) in handlers.into_iter().enumerate() {
let input_json = input_json.clone();
let turn_id = turn_id.clone();
pending.push(async move {
let result = run_command(shell, &handler, &input_json, cwd).await;
(configured_order, parse(&handler, result, turn_id))
});
}
handlers
.into_iter()
.zip(results)
.map(|(handler, result)| parse(&handler, result, turn_id.clone()))
.collect()
let mut completed = Vec::new();
let mut completion_order = 0;
while let Some((configured_order, mut parsed)) = pending.next().await {
parsed.completion_order = completion_order;
completion_order += 1;
completed.push((configured_order, parsed));
}
completed.sort_by_key(|(configured_order, _)| *configured_order);
completed.into_iter().map(|(_, parsed)| parsed).collect()
}
pub(crate) fn completed_summary(
+24 -3
View File
@@ -17,6 +17,7 @@ pub(crate) struct PreToolUseOutput {
pub universal: UniversalOutput,
pub block_reason: Option<String>,
pub additional_context: Option<String>,
pub updated_input: Option<serde_json::Value>,
pub invalid_reason: Option<String>,
}
@@ -139,11 +140,24 @@ pub(crate) fn parse_pre_tool_use(stdout: &str) -> Option<PreToolUseOutput> {
} else {
None
};
let updated_input = if invalid_reason.is_none() {
hook_specific_output.and_then(|output| {
matches!(
output.permission_decision,
Some(PreToolUsePermissionDecisionWire::Allow)
)
.then(|| output.updated_input.clone())
.flatten()
})
} else {
None
};
Some(PreToolUseOutput {
universal,
block_reason,
additional_context,
updated_input,
invalid_reason,
})
}
@@ -377,12 +391,19 @@ fn unsupported_post_tool_use_hook_specific_output(
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())
if output.updated_input.is_some()
&& !matches!(
output.permission_decision,
Some(PreToolUsePermissionDecisionWire::Allow)
)
{
Some("PreToolUse hook returned updatedInput without permissionDecision:allow".to_string())
} else {
match output.permission_decision {
Some(PreToolUsePermissionDecisionWire::Allow) => {
Some("PreToolUse hook returned unsupported permissionDecision:allow".to_string())
output.updated_input.is_none().then(|| {
"PreToolUse hook returned unsupported permissionDecision:allow".to_string()
})
}
Some(PreToolUsePermissionDecisionWire::Ask) => {
Some("PreToolUse hook returned unsupported permissionDecision:ask".to_string())