Skip to content

Commit d08906a

Browse files
authored
Support PreToolUse updatedInput rewrites (openai#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.
1 parent 17ed5ad commit d08906a

22 files changed

Lines changed: 1021 additions & 47 deletions

codex-rs/core/src/hook_runtime.rs

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ pub(crate) struct HookRuntimeOutcome {
4444
pub additional_contexts: Vec<String>,
4545
}
4646

47+
pub(crate) enum PreToolUseHookResult {
48+
Continue { updated_input: Option<Value> },
49+
Blocked(String),
50+
}
51+
4752
pub(crate) enum PendingInputHookDisposition {
4853
Accepted(Box<PendingInputRecord>),
4954
Blocked { additional_contexts: Vec<String> },
@@ -141,7 +146,7 @@ pub(crate) async fn run_pre_tool_use_hooks(
141146
tool_use_id: String,
142147
tool_name: &HookToolName,
143148
tool_input: &Value,
144-
) -> Option<String> {
149+
) -> PreToolUseHookResult {
145150
let request = PreToolUseRequest {
146151
session_id: sess.conversation_id,
147152
turn_id: turn_context.sub_id.clone(),
@@ -163,25 +168,32 @@ pub(crate) async fn run_pre_tool_use_hooks(
163168
should_block,
164169
block_reason,
165170
additional_contexts,
171+
updated_input,
166172
} = hooks.run_pre_tool_use(request).await;
167173
emit_hook_completed_events(sess, turn_context, hook_events).await;
168174
record_additional_contexts(sess, turn_context, additional_contexts).await;
169175

170-
if should_block {
171-
block_reason.map(|reason| {
172-
if (tool_name.name() == "Bash" || tool_name.name() == "apply_patch")
173-
&& let Some(command) = tool_input.get("command").and_then(Value::as_str)
174-
{
175-
format!("Command blocked by PreToolUse hook: {reason}. Command: {command}")
176-
} else {
177-
format!(
178-
"Tool call blocked by PreToolUse hook: {reason}. Tool: {}",
179-
tool_name.name()
180-
)
181-
}
182-
})
176+
if !should_block {
177+
return PreToolUseHookResult::Continue { updated_input };
178+
}
179+
180+
let Some(reason) = block_reason else {
181+
return PreToolUseHookResult::Continue {
182+
updated_input: None,
183+
};
184+
};
185+
186+
if (tool_name.name() == "Bash" || tool_name.name() == "apply_patch")
187+
&& let Some(command) = tool_input.get("command").and_then(Value::as_str)
188+
{
189+
PreToolUseHookResult::Blocked(format!(
190+
"Command blocked by PreToolUse hook: {reason}. Command: {command}"
191+
))
183192
} else {
184-
None
193+
PreToolUseHookResult::Blocked(format!(
194+
"Tool call blocked by PreToolUse hook: {reason}. Tool: {}",
195+
tool_name.name()
196+
))
185197
}
186198
}
187199

codex-rs/core/src/tools/handlers/apply_patch.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ use crate::tools::events::ToolEventCtx;
2424
use crate::tools::handlers::apply_granted_turn_permissions;
2525
use crate::tools::handlers::apply_patch_spec::create_apply_patch_freeform_tool;
2626
use crate::tools::handlers::resolve_tool_environment;
27+
use crate::tools::handlers::updated_hook_command;
2728
use crate::tools::hook_names::HookToolName;
2829
use crate::tools::orchestrator::ToolOrchestrator;
2930
use crate::tools::registry::PostToolUsePayload;
@@ -325,6 +326,21 @@ impl ToolHandler for ApplyPatchHandler {
325326
})
326327
}
327328

329+
fn with_updated_hook_input(
330+
&self,
331+
mut invocation: ToolInvocation,
332+
updated_input: serde_json::Value,
333+
) -> Result<ToolInvocation, FunctionCallError> {
334+
let patch = updated_hook_command(&updated_input)?;
335+
invocation.payload = match invocation.payload {
336+
ToolPayload::Custom { .. } => ToolPayload::Custom {
337+
input: patch.to_string(),
338+
},
339+
payload => payload,
340+
};
341+
Ok(invocation)
342+
}
343+
328344
fn post_tool_use_payload(
329345
&self,
330346
invocation: &ToolInvocation,

codex-rs/core/src/tools/handlers/mcp.rs

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ use crate::tools::registry::ToolHandler;
1515
use crate::tools::registry::ToolTelemetryTags;
1616
use codex_mcp::ToolInfo;
1717
use codex_tools::ToolName;
18+
use serde_json::Map;
1819
use serde_json::Value;
1920

2021
pub struct McpHandler {
@@ -57,6 +58,28 @@ impl ToolHandler for McpHandler {
5758
})
5859
}
5960

61+
fn with_updated_hook_input(
62+
&self,
63+
mut invocation: ToolInvocation,
64+
updated_input: Value,
65+
) -> Result<ToolInvocation, FunctionCallError> {
66+
invocation.payload = match invocation.payload {
67+
ToolPayload::Function { .. } => ToolPayload::Function {
68+
arguments: serde_json::to_string(&updated_input).map_err(|err| {
69+
FunctionCallError::RespondToModel(format!(
70+
"failed to serialize rewritten MCP arguments: {err}"
71+
))
72+
})?,
73+
},
74+
payload => {
75+
return Err(FunctionCallError::RespondToModel(format!(
76+
"tool {} does not support hook input rewriting for payload {payload:?}",
77+
self.tool_name()
78+
)));
79+
}
80+
};
81+
Ok(invocation)
82+
}
6083
fn post_tool_use_payload(
6184
&self,
6285
invocation: &ToolInvocation,
@@ -118,7 +141,7 @@ impl ToolHandler for McpHandler {
118141

119142
fn mcp_hook_tool_input(raw_arguments: &str) -> Value {
120143
if raw_arguments.trim().is_empty() {
121-
return Value::Object(serde_json::Map::new());
144+
return Value::Object(Map::new());
122145
}
123146

124147
serde_json::from_str(raw_arguments).unwrap_or_else(|_| Value::String(raw_arguments.to_string()))
@@ -148,7 +171,6 @@ mod tests {
148171
};
149172
let (session, turn) = make_session_and_context().await;
150173
let handler = McpHandler::new(tool_info("memory", "mcp__memory__", "create_entities"));
151-
152174
assert_eq!(
153175
handler.pre_tool_use_payload(&ToolInvocation {
154176
session: session.into(),
@@ -172,6 +194,62 @@ mod tests {
172194
);
173195
}
174196

197+
#[tokio::test]
198+
async fn mcp_pre_tool_use_payload_keeps_builtin_like_tool_names_namespaced() {
199+
let payload = ToolPayload::Function {
200+
arguments: json!({ "message": "hello" }).to_string(),
201+
};
202+
let (session, turn) = make_session_and_context().await;
203+
let handler = McpHandler::new(tool_info("foo", "mcp__foo__", "exec_command"));
204+
205+
assert_eq!(
206+
handler.pre_tool_use_payload(&ToolInvocation {
207+
session: session.into(),
208+
turn: turn.into(),
209+
cancellation_token: tokio_util::sync::CancellationToken::new(),
210+
tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
211+
call_id: "call-mcp-pre-builtin-like".to_string(),
212+
tool_name: codex_tools::ToolName::namespaced("mcp__foo__", "exec_command"),
213+
source: ToolCallSource::Direct,
214+
payload,
215+
}),
216+
Some(PreToolUsePayload {
217+
tool_name: HookToolName::new("mcp__foo__exec_command"),
218+
tool_input: json!({ "message": "hello" }),
219+
})
220+
);
221+
}
222+
223+
#[tokio::test]
224+
async fn mcp_updated_input_rewrites_builtin_like_tool_names_as_mcp() {
225+
let payload = ToolPayload::Function {
226+
arguments: json!({ "message": "hello" }).to_string(),
227+
};
228+
let (session, turn) = make_session_and_context().await;
229+
let handler = McpHandler::new(tool_info("foo", "mcp__foo__", "exec_command"));
230+
231+
let invocation = handler
232+
.with_updated_hook_input(
233+
ToolInvocation {
234+
session: session.into(),
235+
turn: turn.into(),
236+
cancellation_token: tokio_util::sync::CancellationToken::new(),
237+
tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
238+
call_id: "call-mcp-rewrite-builtin-like".to_string(),
239+
tool_name: codex_tools::ToolName::namespaced("mcp__foo__", "exec_command"),
240+
source: ToolCallSource::Direct,
241+
payload,
242+
},
243+
json!({ "message": "rewritten" }),
244+
)
245+
.expect("MCP rewrite should succeed");
246+
247+
let ToolPayload::Function { arguments } = invocation.payload else {
248+
panic!("builtin-like MCP tool should stay function-shaped");
249+
};
250+
assert_eq!(arguments, json!({ "message": "rewritten" }).to_string());
251+
}
252+
175253
#[tokio::test]
176254
async fn mcp_post_tool_use_payload_uses_model_tool_name_args_and_result() {
177255
let payload = ToolPayload::Function {

codex-rs/core/src/tools/handlers/mod.rs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ use codex_sandboxing::policy_transforms::normalize_additional_permissions;
3737
use codex_utils_absolute_path::AbsolutePathBuf;
3838
use codex_utils_absolute_path::AbsolutePathBufGuard;
3939
use serde::Deserialize;
40+
use serde_json::Map;
4041
use serde_json::Value;
4142
use std::path::Path;
4243

@@ -76,7 +77,7 @@ pub(crate) use unified_exec::ExecCommandHandlerOptions;
7677
pub use unified_exec::WriteStdinHandler;
7778
pub use view_image::ViewImageHandler;
7879

79-
fn parse_arguments<T>(arguments: &str) -> Result<T, FunctionCallError>
80+
pub(crate) fn parse_arguments<T>(arguments: &str) -> Result<T, FunctionCallError>
8081
where
8182
T: for<'de> Deserialize<'de>,
8283
{
@@ -85,6 +86,47 @@ where
8586
})
8687
}
8788

89+
fn updated_hook_command(updated_input: &Value) -> Result<&str, FunctionCallError> {
90+
updated_input
91+
.get("command")
92+
.and_then(Value::as_str)
93+
.ok_or_else(|| {
94+
FunctionCallError::RespondToModel(
95+
"hook returned updatedInput without string field `command`".to_string(),
96+
)
97+
})
98+
}
99+
100+
fn rewrite_function_arguments(
101+
arguments: &str,
102+
tool_name: &str,
103+
rewrite: impl FnOnce(&mut Map<String, Value>),
104+
) -> Result<String, FunctionCallError> {
105+
let mut arguments: Value = parse_arguments(arguments)?;
106+
let Value::Object(arguments) = &mut arguments else {
107+
return Err(FunctionCallError::RespondToModel(format!(
108+
"{tool_name} arguments must be an object"
109+
)));
110+
};
111+
rewrite(arguments);
112+
serde_json::to_string(&arguments).map_err(|err| {
113+
FunctionCallError::RespondToModel(format!(
114+
"failed to serialize rewritten {tool_name} arguments: {err}"
115+
))
116+
})
117+
}
118+
119+
fn rewrite_function_string_argument(
120+
arguments: &str,
121+
tool_name: &str,
122+
field_name: &str,
123+
value: &str,
124+
) -> Result<String, FunctionCallError> {
125+
rewrite_function_arguments(arguments, tool_name, |arguments| {
126+
arguments.insert(field_name.to_string(), Value::String(value.to_string()));
127+
})
128+
}
129+
88130
fn parse_arguments_with_base_path<T>(
89131
arguments: &str,
90132
base_path: &AbsolutePathBuf,

codex-rs/core/src/tools/handlers/shell.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ use crate::tools::handlers::apply_patch::intercept_apply_patch;
1919
use crate::tools::handlers::implicit_granted_permissions;
2020
use crate::tools::handlers::normalize_and_validate_additional_permissions;
2121
use crate::tools::handlers::parse_arguments;
22+
use crate::tools::handlers::rewrite_function_arguments;
23+
use crate::tools::handlers::updated_hook_command;
2224
use crate::tools::hook_names::HookToolName;
2325
use crate::tools::orchestrator::ToolOrchestrator;
2426
use crate::tools::registry::PostToolUsePayload;
@@ -93,6 +95,32 @@ fn shell_function_pre_tool_use_payload(invocation: &ToolInvocation) -> Option<Pr
9395
})
9496
}
9597

98+
fn rewrite_shell_function_updated_hook_input(
99+
mut invocation: ToolInvocation,
100+
updated_input: JsonValue,
101+
tool_name: &str,
102+
) -> Result<ToolInvocation, FunctionCallError> {
103+
let ToolPayload::Function { arguments } = invocation.payload else {
104+
return Err(FunctionCallError::RespondToModel(format!(
105+
"hook input rewrite received unsupported {tool_name} payload"
106+
)));
107+
};
108+
let command = shlex::split(updated_hook_command(&updated_input)?).ok_or_else(|| {
109+
FunctionCallError::RespondToModel(
110+
"hook returned shell input with an invalid command string".to_string(),
111+
)
112+
})?;
113+
invocation.payload = ToolPayload::Function {
114+
arguments: rewrite_function_arguments(&arguments, tool_name, |arguments| {
115+
arguments.insert(
116+
"command".to_string(),
117+
JsonValue::Array(command.into_iter().map(JsonValue::String).collect()),
118+
);
119+
})?,
120+
};
121+
Ok(invocation)
122+
}
123+
96124
fn shell_function_post_tool_use_payload(
97125
invocation: &ToolInvocation,
98126
result: &FunctionToolOutput,

codex-rs/core/src/tools/handlers/shell/container_exec.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use crate::tools::registry::ToolHandler;
1414
use crate::tools::runtimes::shell::ShellRuntimeBackend;
1515

1616
use super::RunExecLikeArgs;
17+
use super::rewrite_shell_function_updated_hook_input;
1718
use super::run_exec_like;
1819
use super::shell_function_post_tool_use_payload;
1920
use super::shell_function_pre_tool_use_payload;
@@ -46,6 +47,14 @@ impl ToolHandler for ContainerExecHandler {
4647
shell_function_pre_tool_use_payload(invocation)
4748
}
4849

50+
fn with_updated_hook_input(
51+
&self,
52+
invocation: ToolInvocation,
53+
updated_input: serde_json::Value,
54+
) -> Result<ToolInvocation, FunctionCallError> {
55+
rewrite_shell_function_updated_hook_input(invocation, updated_input, "container.exec")
56+
}
57+
4958
fn post_tool_use_payload(
5059
&self,
5160
invocation: &ToolInvocation,

codex-rs/core/src/tools/handlers/shell/local_shell.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use crate::tools::context::FunctionToolOutput;
66
use crate::tools::context::ToolInvocation;
77
use crate::tools::context::ToolOutput;
88
use crate::tools::context::ToolPayload;
9+
use crate::tools::handlers::updated_hook_command;
910
use crate::tools::hook_names::HookToolName;
1011
use crate::tools::registry::PostToolUsePayload;
1112
use crate::tools::registry::PreToolUsePayload;
@@ -64,6 +65,26 @@ impl ToolHandler for LocalShellHandler {
6465
})
6566
}
6667

68+
fn with_updated_hook_input(
69+
&self,
70+
mut invocation: ToolInvocation,
71+
updated_input: serde_json::Value,
72+
) -> Result<ToolInvocation, FunctionCallError> {
73+
let command = updated_hook_command(&updated_input)?;
74+
invocation.payload = match invocation.payload {
75+
ToolPayload::LocalShell { mut params } => {
76+
params.command = shlex::split(command).ok_or_else(|| {
77+
FunctionCallError::RespondToModel(
78+
"hook returned shell input with an invalid command string".to_string(),
79+
)
80+
})?;
81+
ToolPayload::LocalShell { params }
82+
}
83+
payload => payload,
84+
};
85+
Ok(invocation)
86+
}
87+
6788
fn post_tool_use_payload(
6889
&self,
6990
invocation: &ToolInvocation,

0 commit comments

Comments
 (0)