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
+27 -15
View File
@@ -44,6 +44,11 @@ pub(crate) struct HookRuntimeOutcome {
pub additional_contexts: Vec<String>,
}
pub(crate) enum PreToolUseHookResult {
Continue { updated_input: Option<Value> },
Blocked(String),
}
pub(crate) enum PendingInputHookDisposition {
Accepted(Box<PendingInputRecord>),
Blocked { additional_contexts: Vec<String> },
@@ -141,7 +146,7 @@ pub(crate) async fn run_pre_tool_use_hooks(
tool_use_id: String,
tool_name: &HookToolName,
tool_input: &Value,
) -> Option<String> {
) -> PreToolUseHookResult {
let request = PreToolUseRequest {
session_id: sess.conversation_id,
turn_id: turn_context.sub_id.clone(),
@@ -163,25 +168,32 @@ pub(crate) async fn run_pre_tool_use_hooks(
should_block,
block_reason,
additional_contexts,
updated_input,
} = hooks.run_pre_tool_use(request).await;
emit_hook_completed_events(sess, turn_context, hook_events).await;
record_additional_contexts(sess, turn_context, additional_contexts).await;
if should_block {
block_reason.map(|reason| {
if (tool_name.name() == "Bash" || tool_name.name() == "apply_patch")
&& let Some(command) = tool_input.get("command").and_then(Value::as_str)
{
format!("Command blocked by PreToolUse hook: {reason}. Command: {command}")
} else {
format!(
"Tool call blocked by PreToolUse hook: {reason}. Tool: {}",
tool_name.name()
)
}
})
if !should_block {
return PreToolUseHookResult::Continue { updated_input };
}
let Some(reason) = block_reason else {
return PreToolUseHookResult::Continue {
updated_input: None,
};
};
if (tool_name.name() == "Bash" || tool_name.name() == "apply_patch")
&& let Some(command) = tool_input.get("command").and_then(Value::as_str)
{
PreToolUseHookResult::Blocked(format!(
"Command blocked by PreToolUse hook: {reason}. Command: {command}"
))
} else {
None
PreToolUseHookResult::Blocked(format!(
"Tool call blocked by PreToolUse hook: {reason}. Tool: {}",
tool_name.name()
))
}
}
@@ -24,6 +24,7 @@ use crate::tools::events::ToolEventCtx;
use crate::tools::handlers::apply_granted_turn_permissions;
use crate::tools::handlers::apply_patch_spec::create_apply_patch_freeform_tool;
use crate::tools::handlers::resolve_tool_environment;
use crate::tools::handlers::updated_hook_command;
use crate::tools::hook_names::HookToolName;
use crate::tools::orchestrator::ToolOrchestrator;
use crate::tools::registry::PostToolUsePayload;
@@ -325,6 +326,21 @@ impl ToolHandler for ApplyPatchHandler {
})
}
fn with_updated_hook_input(
&self,
mut invocation: ToolInvocation,
updated_input: serde_json::Value,
) -> Result<ToolInvocation, FunctionCallError> {
let patch = updated_hook_command(&updated_input)?;
invocation.payload = match invocation.payload {
ToolPayload::Custom { .. } => ToolPayload::Custom {
input: patch.to_string(),
},
payload => payload,
};
Ok(invocation)
}
fn post_tool_use_payload(
&self,
invocation: &ToolInvocation,
+80 -2
View File
@@ -15,6 +15,7 @@ use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolTelemetryTags;
use codex_mcp::ToolInfo;
use codex_tools::ToolName;
use serde_json::Map;
use serde_json::Value;
pub struct McpHandler {
@@ -57,6 +58,28 @@ impl ToolHandler for McpHandler {
})
}
fn with_updated_hook_input(
&self,
mut invocation: ToolInvocation,
updated_input: Value,
) -> Result<ToolInvocation, FunctionCallError> {
invocation.payload = match invocation.payload {
ToolPayload::Function { .. } => ToolPayload::Function {
arguments: serde_json::to_string(&updated_input).map_err(|err| {
FunctionCallError::RespondToModel(format!(
"failed to serialize rewritten MCP arguments: {err}"
))
})?,
},
payload => {
return Err(FunctionCallError::RespondToModel(format!(
"tool {} does not support hook input rewriting for payload {payload:?}",
self.tool_name()
)));
}
};
Ok(invocation)
}
fn post_tool_use_payload(
&self,
invocation: &ToolInvocation,
@@ -118,7 +141,7 @@ impl ToolHandler for McpHandler {
fn mcp_hook_tool_input(raw_arguments: &str) -> Value {
if raw_arguments.trim().is_empty() {
return Value::Object(serde_json::Map::new());
return Value::Object(Map::new());
}
serde_json::from_str(raw_arguments).unwrap_or_else(|_| Value::String(raw_arguments.to_string()))
@@ -148,7 +171,6 @@ mod tests {
};
let (session, turn) = make_session_and_context().await;
let handler = McpHandler::new(tool_info("memory", "mcp__memory__", "create_entities"));
assert_eq!(
handler.pre_tool_use_payload(&ToolInvocation {
session: session.into(),
@@ -172,6 +194,62 @@ mod tests {
);
}
#[tokio::test]
async fn mcp_pre_tool_use_payload_keeps_builtin_like_tool_names_namespaced() {
let payload = ToolPayload::Function {
arguments: json!({ "message": "hello" }).to_string(),
};
let (session, turn) = make_session_and_context().await;
let handler = McpHandler::new(tool_info("foo", "mcp__foo__", "exec_command"));
assert_eq!(
handler.pre_tool_use_payload(&ToolInvocation {
session: session.into(),
turn: turn.into(),
cancellation_token: tokio_util::sync::CancellationToken::new(),
tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
call_id: "call-mcp-pre-builtin-like".to_string(),
tool_name: codex_tools::ToolName::namespaced("mcp__foo__", "exec_command"),
source: ToolCallSource::Direct,
payload,
}),
Some(PreToolUsePayload {
tool_name: HookToolName::new("mcp__foo__exec_command"),
tool_input: json!({ "message": "hello" }),
})
);
}
#[tokio::test]
async fn mcp_updated_input_rewrites_builtin_like_tool_names_as_mcp() {
let payload = ToolPayload::Function {
arguments: json!({ "message": "hello" }).to_string(),
};
let (session, turn) = make_session_and_context().await;
let handler = McpHandler::new(tool_info("foo", "mcp__foo__", "exec_command"));
let invocation = handler
.with_updated_hook_input(
ToolInvocation {
session: session.into(),
turn: turn.into(),
cancellation_token: tokio_util::sync::CancellationToken::new(),
tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
call_id: "call-mcp-rewrite-builtin-like".to_string(),
tool_name: codex_tools::ToolName::namespaced("mcp__foo__", "exec_command"),
source: ToolCallSource::Direct,
payload,
},
json!({ "message": "rewritten" }),
)
.expect("MCP rewrite should succeed");
let ToolPayload::Function { arguments } = invocation.payload else {
panic!("builtin-like MCP tool should stay function-shaped");
};
assert_eq!(arguments, json!({ "message": "rewritten" }).to_string());
}
#[tokio::test]
async fn mcp_post_tool_use_payload_uses_model_tool_name_args_and_result() {
let payload = ToolPayload::Function {
+43 -1
View File
@@ -37,6 +37,7 @@ use codex_sandboxing::policy_transforms::normalize_additional_permissions;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::AbsolutePathBufGuard;
use serde::Deserialize;
use serde_json::Map;
use serde_json::Value;
use std::path::Path;
@@ -76,7 +77,7 @@ pub(crate) use unified_exec::ExecCommandHandlerOptions;
pub use unified_exec::WriteStdinHandler;
pub use view_image::ViewImageHandler;
fn parse_arguments<T>(arguments: &str) -> Result<T, FunctionCallError>
pub(crate) fn parse_arguments<T>(arguments: &str) -> Result<T, FunctionCallError>
where
T: for<'de> Deserialize<'de>,
{
@@ -85,6 +86,47 @@ where
})
}
fn updated_hook_command(updated_input: &Value) -> Result<&str, FunctionCallError> {
updated_input
.get("command")
.and_then(Value::as_str)
.ok_or_else(|| {
FunctionCallError::RespondToModel(
"hook returned updatedInput without string field `command`".to_string(),
)
})
}
fn rewrite_function_arguments(
arguments: &str,
tool_name: &str,
rewrite: impl FnOnce(&mut Map<String, Value>),
) -> Result<String, FunctionCallError> {
let mut arguments: Value = parse_arguments(arguments)?;
let Value::Object(arguments) = &mut arguments else {
return Err(FunctionCallError::RespondToModel(format!(
"{tool_name} arguments must be an object"
)));
};
rewrite(arguments);
serde_json::to_string(&arguments).map_err(|err| {
FunctionCallError::RespondToModel(format!(
"failed to serialize rewritten {tool_name} arguments: {err}"
))
})
}
fn rewrite_function_string_argument(
arguments: &str,
tool_name: &str,
field_name: &str,
value: &str,
) -> Result<String, FunctionCallError> {
rewrite_function_arguments(arguments, tool_name, |arguments| {
arguments.insert(field_name.to_string(), Value::String(value.to_string()));
})
}
fn parse_arguments_with_base_path<T>(
arguments: &str,
base_path: &AbsolutePathBuf,
+28
View File
@@ -19,6 +19,8 @@ use crate::tools::handlers::apply_patch::intercept_apply_patch;
use crate::tools::handlers::implicit_granted_permissions;
use crate::tools::handlers::normalize_and_validate_additional_permissions;
use crate::tools::handlers::parse_arguments;
use crate::tools::handlers::rewrite_function_arguments;
use crate::tools::handlers::updated_hook_command;
use crate::tools::hook_names::HookToolName;
use crate::tools::orchestrator::ToolOrchestrator;
use crate::tools::registry::PostToolUsePayload;
@@ -93,6 +95,32 @@ fn shell_function_pre_tool_use_payload(invocation: &ToolInvocation) -> Option<Pr
})
}
fn rewrite_shell_function_updated_hook_input(
mut invocation: ToolInvocation,
updated_input: JsonValue,
tool_name: &str,
) -> Result<ToolInvocation, FunctionCallError> {
let ToolPayload::Function { arguments } = invocation.payload else {
return Err(FunctionCallError::RespondToModel(format!(
"hook input rewrite received unsupported {tool_name} payload"
)));
};
let command = shlex::split(updated_hook_command(&updated_input)?).ok_or_else(|| {
FunctionCallError::RespondToModel(
"hook returned shell input with an invalid command string".to_string(),
)
})?;
invocation.payload = ToolPayload::Function {
arguments: rewrite_function_arguments(&arguments, tool_name, |arguments| {
arguments.insert(
"command".to_string(),
JsonValue::Array(command.into_iter().map(JsonValue::String).collect()),
);
})?,
};
Ok(invocation)
}
fn shell_function_post_tool_use_payload(
invocation: &ToolInvocation,
result: &FunctionToolOutput,
@@ -14,6 +14,7 @@ use crate::tools::registry::ToolHandler;
use crate::tools::runtimes::shell::ShellRuntimeBackend;
use super::RunExecLikeArgs;
use super::rewrite_shell_function_updated_hook_input;
use super::run_exec_like;
use super::shell_function_post_tool_use_payload;
use super::shell_function_pre_tool_use_payload;
@@ -46,6 +47,14 @@ impl ToolHandler for ContainerExecHandler {
shell_function_pre_tool_use_payload(invocation)
}
fn with_updated_hook_input(
&self,
invocation: ToolInvocation,
updated_input: serde_json::Value,
) -> Result<ToolInvocation, FunctionCallError> {
rewrite_shell_function_updated_hook_input(invocation, updated_input, "container.exec")
}
fn post_tool_use_payload(
&self,
invocation: &ToolInvocation,
@@ -6,6 +6,7 @@ use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::updated_hook_command;
use crate::tools::hook_names::HookToolName;
use crate::tools::registry::PostToolUsePayload;
use crate::tools::registry::PreToolUsePayload;
@@ -64,6 +65,26 @@ impl ToolHandler for LocalShellHandler {
})
}
fn with_updated_hook_input(
&self,
mut invocation: ToolInvocation,
updated_input: serde_json::Value,
) -> Result<ToolInvocation, FunctionCallError> {
let command = updated_hook_command(&updated_input)?;
invocation.payload = match invocation.payload {
ToolPayload::LocalShell { mut params } => {
params.command = shlex::split(command).ok_or_else(|| {
FunctionCallError::RespondToModel(
"hook returned shell input with an invalid command string".to_string(),
)
})?;
ToolPayload::LocalShell { params }
}
payload => payload,
};
Ok(invocation)
}
fn post_tool_use_payload(
&self,
invocation: &ToolInvocation,
@@ -17,6 +17,8 @@ use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::parse_arguments_with_base_path;
use crate::tools::handlers::resolve_workdir_base_path;
use crate::tools::handlers::rewrite_function_string_argument;
use crate::tools::handlers::updated_hook_command;
use crate::tools::hook_names::HookToolName;
use crate::tools::registry::PostToolUsePayload;
use crate::tools::registry::PreToolUsePayload;
@@ -175,6 +177,27 @@ impl ToolHandler for ShellCommandHandler {
})
}
fn with_updated_hook_input(
&self,
mut invocation: ToolInvocation,
updated_input: serde_json::Value,
) -> Result<ToolInvocation, FunctionCallError> {
let ToolPayload::Function { arguments } = invocation.payload else {
return Err(FunctionCallError::RespondToModel(
"hook input rewrite received unsupported shell_command payload".to_string(),
));
};
invocation.payload = ToolPayload::Function {
arguments: rewrite_function_string_argument(
&arguments,
"shell_command",
"command",
updated_hook_command(&updated_input)?,
)?,
};
Ok(invocation)
}
fn post_tool_use_payload(
&self,
invocation: &ToolInvocation,
@@ -22,6 +22,7 @@ use codex_tools::ToolSpec;
use super::super::shell_spec::ShellToolOptions;
use super::super::shell_spec::create_shell_tool;
use super::RunExecLikeArgs;
use super::rewrite_shell_function_updated_hook_input;
use super::run_exec_like;
use super::shell_function_post_tool_use_payload;
use super::shell_function_pre_tool_use_payload;
@@ -95,6 +96,14 @@ impl ToolHandler for ShellHandler {
shell_function_pre_tool_use_payload(invocation)
}
fn with_updated_hook_input(
&self,
invocation: ToolInvocation,
updated_input: serde_json::Value,
) -> Result<ToolInvocation, FunctionCallError> {
rewrite_shell_function_updated_hook_input(invocation, updated_input, "shell")
}
fn post_tool_use_payload(
&self,
invocation: &ToolInvocation,
@@ -12,6 +12,8 @@ use crate::tools::handlers::normalize_and_validate_additional_permissions;
use crate::tools::handlers::parse_arguments;
use crate::tools::handlers::parse_arguments_with_base_path;
use crate::tools::handlers::resolve_tool_environment;
use crate::tools::handlers::rewrite_function_string_argument;
use crate::tools::handlers::updated_hook_command;
use crate::tools::hook_names::HookToolName;
use crate::tools::registry::PostToolUsePayload;
use crate::tools::registry::PreToolUsePayload;
@@ -128,6 +130,27 @@ impl ToolHandler for ExecCommandHandler {
})
}
fn with_updated_hook_input(
&self,
mut invocation: ToolInvocation,
updated_input: serde_json::Value,
) -> Result<ToolInvocation, FunctionCallError> {
let ToolPayload::Function { arguments } = invocation.payload else {
return Err(FunctionCallError::RespondToModel(
"hook input rewrite received unsupported exec_command payload".to_string(),
));
};
invocation.payload = ToolPayload::Function {
arguments: rewrite_function_string_argument(
&arguments,
"exec_command",
"cmd",
updated_hook_command(&updated_input)?,
)?,
};
Ok(invocation)
}
fn post_tool_use_payload(
&self,
invocation: &ToolInvocation,
+54 -14
View File
@@ -4,6 +4,7 @@ use std::time::Duration;
use crate::function_tool::FunctionCallError;
use crate::goals::GoalRuntimeEvent;
use crate::hook_runtime::PreToolUseHookResult;
use crate::hook_runtime::record_additional_contexts;
use crate::hook_runtime::run_post_tool_use_hooks;
use crate::hook_runtime::run_pre_tool_use_hooks;
@@ -73,10 +74,6 @@ pub trait ToolHandler: Send + Sync {
async { false }
}
fn pre_tool_use_payload(&self, _invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
None
}
fn post_tool_use_payload(
&self,
_invocation: &ToolInvocation,
@@ -85,6 +82,24 @@ pub trait ToolHandler: Send + Sync {
None
}
fn pre_tool_use_payload(&self, _invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
None
}
/// Rebuilds a tool invocation from hook-facing `tool_input`.
///
/// Tools that opt into input-rewriting hooks should invert the same stable
/// hook contract they expose from `pre_tool_use_payload`.
fn with_updated_hook_input(
&self,
_invocation: ToolInvocation,
_updated_input: Value,
) -> Result<ToolInvocation, FunctionCallError> {
Err(FunctionCallError::RespondToModel(
"tool does not support hook input rewriting".to_string(),
))
}
/// Creates an optional consumer for streamed tool argument diffs.
fn create_diff_consumer(&self) -> Option<Box<dyn ToolArgumentDiffConsumer>> {
None
@@ -175,6 +190,12 @@ trait AnyToolHandler: Send + Sync {
fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload>;
fn with_updated_hook_input(
&self,
invocation: ToolInvocation,
updated_input: Value,
) -> Result<ToolInvocation, FunctionCallError>;
fn telemetry_tags<'a>(
&'a self,
invocation: &'a ToolInvocation,
@@ -207,6 +228,14 @@ where
ToolHandler::pre_tool_use_payload(self, invocation)
}
fn with_updated_hook_input(
&self,
invocation: ToolInvocation,
updated_input: Value,
) -> Result<ToolInvocation, FunctionCallError> {
ToolHandler::with_updated_hook_input(self, invocation, updated_input)
}
fn telemetry_tags<'a>(
&'a self,
invocation: &'a ToolInvocation,
@@ -286,14 +315,12 @@ impl ToolRegistry {
)]
pub(crate) async fn dispatch_any(
&self,
invocation: ToolInvocation,
mut invocation: ToolInvocation,
) -> Result<AnyToolResult, FunctionCallError> {
let tool_name = invocation.tool_name.clone();
let tool_name_flat = flat_tool_name(&tool_name);
let call_id_owned = invocation.call_id.clone();
let otel = invocation.turn.session_telemetry.clone();
let payload_for_response = invocation.payload.clone();
let log_payload = payload_for_response.log_payload();
let base_tool_result_tags = [
(
"sandbox",
@@ -325,6 +352,7 @@ impl ToolRegistry {
Some(handler) => handler,
None => {
let message = unsupported_tool_call_message(&invocation.payload, &tool_name);
let log_payload = invocation.payload.log_payload();
otel.tool_result_with_tags(
tool_name_flat.as_ref(),
&call_id_owned,
@@ -353,9 +381,9 @@ impl ToolRegistry {
tool_result_tags.push((*key, value.as_str()));
}
}
if !handler.matches_kind(&invocation.payload) {
let message = format!("tool {tool_name} invoked with incompatible payload");
let log_payload = invocation.payload.log_payload();
otel.tool_result_with_tags(
tool_name_flat.as_ref(),
&call_id_owned,
@@ -371,8 +399,8 @@ impl ToolRegistry {
return Err(err);
}
if let Some(pre_tool_use_payload) = handler.pre_tool_use_payload(&invocation)
&& let Some(message) = run_pre_tool_use_hooks(
if let Some(pre_tool_use_payload) = handler.pre_tool_use_payload(&invocation) {
match run_pre_tool_use_hooks(
&invocation.session,
&invocation.turn,
invocation.call_id.clone(),
@@ -380,15 +408,27 @@ impl ToolRegistry {
&pre_tool_use_payload.tool_input,
)
.await
{
let err = FunctionCallError::RespondToModel(message);
dispatch_trace.record_failed(&err);
return Err(err);
{
PreToolUseHookResult::Blocked(message) => {
let err = FunctionCallError::RespondToModel(message);
dispatch_trace.record_failed(&err);
return Err(err);
}
PreToolUseHookResult::Continue {
updated_input: Some(updated_input),
} => {
invocation = handler.with_updated_hook_input(invocation, updated_input)?;
}
PreToolUseHookResult::Continue {
updated_input: None,
} => {}
}
}
let is_mutating = handler.is_mutating(&invocation).await;
let response_cell = tokio::sync::Mutex::new(None);
let invocation_for_tool = invocation.clone();
let log_payload = invocation.payload.log_payload();
let result = otel
.log_tool_result_with_tags(