Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
61724ae
support hook input rewrites
abhinav-oai Apr 30, 2026
bcccc6c
address hook rewrite review feedback
abhinav-oai Apr 30, 2026
7932199
simplify hook input rewrites
abhinav-oai May 4, 2026
b1ea712
simplify hook rewrite helpers
abhinav-oai May 5, 2026
607d254
merge main into hooks-updated-input
abhinav-oai May 5, 2026
f2888ed
Merge remote-tracking branch 'origin/main' into HEAD
abhinav-oai May 5, 2026
26651a0
Merge branch 'main' into abhinav/hooks-updated-input
abhinav-oai May 5, 2026
a417679
Trust hook fixtures in rewrite tests
abhinav-oai May 6, 2026
2f423b1
Split out PermissionRequest rewrites
abhinav-oai May 6, 2026
c92387f
Use the PR merge base for the split
abhinav-oai May 6, 2026
f8dbb08
Merge origin/main into hooks-updated-input pretool base
abhinav-oai May 6, 2026
afa8cb9
Document hook input inversions
abhinav-oai May 6, 2026
bbfa80c
Keep base handler result ordering unchanged
abhinav-oai May 6, 2026
108ecb0
Merge branch 'main' into abhinav/hooks-updated-input
abhinav-oai May 6, 2026
04ae865
support rewrites for legacy shell handlers
abhinav-oai May 6, 2026
786882a
Log rewritten tool payloads
abhinav-oai May 6, 2026
173156b
Test code mode PreToolUse rewrites
abhinav-oai May 6, 2026
1cd3e86
Centralize pre-hook tool compatibility
abhinav-oai May 6, 2026
ca7a4f3
Merge origin/main into abhinav/hooks-updated-input
abhinav-oai May 6, 2026
be8d719
Polish hook compatibility cleanup
abhinav-oai May 6, 2026
5ecbcf6
Move tool hook compatibility under hook runtime
abhinav-oai May 6, 2026
240213a
Address updatedInput review feedback
abhinav-oai May 8, 2026
8cb4c6f
Clarify non-MCP hook payload fallback
abhinav-oai May 8, 2026
1caf245
Merge origin/main into abhinav/hooks-updated-input
abhinav-oai May 8, 2026
fba306e
Merge remote-tracking branch 'origin/main' into abhinav/hooks-updated…
abhinav-oai May 12, 2026
1e479e0
Move hook input rewrites back to handlers
abhinav-oai May 12, 2026
8201584
Merge remote-tracking branch 'origin/main' into abhinav/hooks-updated…
abhinav-oai May 12, 2026
ebbcdbd
Trim updatedInput merge churn
abhinav-oai May 12, 2026
7dcba7d
Cover Bash-like updatedInput rewrites
abhinav-oai May 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 27 additions & 15 deletions codex-rs/core/src/hook_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> },
Expand Down Expand Up @@ -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(),
Expand All @@ -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()
))
}
}

Expand Down
16 changes: 16 additions & 0 deletions codex-rs/core/src/tools/handlers/apply_patch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
82 changes: 80 additions & 2 deletions codex-rs/core/src/tools/handlers/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()))
Expand Down Expand Up @@ -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(),
Expand All @@ -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 {
Expand Down
44 changes: 43 additions & 1 deletion codex-rs/core/src/tools/handlers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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>,
{
Expand All @@ -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,
Expand Down
28 changes: 28 additions & 0 deletions codex-rs/core/src/tools/handlers/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions codex-rs/core/src/tools/handlers/shell/container_exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
21 changes: 21 additions & 0 deletions codex-rs/core/src/tools/handlers/shell/local_shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading