Support MCP tools in hooks (#18385)

## Summary

Lifecycle hooks currently treat `PreToolUse`, `PostToolUse`, and
`PermissionRequest` as Bash-only flows
- hook schema constrains `tool_name` to `Bash`
- hook input assumes a command-shaped `tool_input`
- core hook dispatch path passes only shell command strings

That means hooks cannot target MCP tools even though MCP tool names are
model-visible and stable

This change generalizes those hook paths so they can match and receive
payloads for MCP tools while preserving the existing Bash behavior.

## Reviewer Notes

I think these are the key files
- `codex-rs/core/src/tools/handlers/mcp.rs`
- `codex-rs/core/src/mcp_tool_call.rs`

Otherwise the changes across apply_patch, shell, and unified_exec are
mainly to rewire everything to be `tool_input` based instead of just
`command` so that it'll make sense for MCP tools.

## Changes

- Allow `PreToolUse`, `PostToolUse`, and `PermissionRequest` hook inputs
to carry arbitrary `tool_name` and `tool_input` values instead of
hard-coding `Bash` and command-only payloads.
- Add MCP hook payload support through `McpHandler`, using the
model-visible tool name from `ToolInvocation` and the raw MCP arguments
as `tool_input`.
- Include MCP tool responses in `PostToolUse` by serializing
`McpToolOutput` into the hook response payload.
- Run `PermissionRequest` hooks for MCP approval requests after
remembered approval checks and before falling back to user-facing MCP
elicitation.
- Preserve exact matching for literal hook matchers like `Bash` and
`mcp__memory__create_entities`, while keeping regex matcher support for
patterns like `mcp__memory__.*` and `mcp__.*__write.*`.

---------

Co-authored-by: Andrei Eternal <eternal@openai.com>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Abhinav
2026-04-23 00:33:57 -07:00
committed by GitHub
parent 8bc667b07b
commit 305825abd9
34 changed files with 1293 additions and 361 deletions

View File

@@ -35,6 +35,7 @@ use crate::context::HookAdditionalContext;
use crate::event_mapping::parse_turn_item;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use crate::tools::hook_names::HookToolName;
use crate::tools::sandboxing::PermissionRequestPayload;
pub(crate) struct HookRuntimeOutcome {
@@ -137,9 +138,8 @@ pub(crate) async fn run_pre_tool_use_hooks(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
tool_use_id: String,
tool_name: String,
matcher_aliases: Vec<String>,
command: String,
tool_name: &HookToolName,
tool_input: &Value,
) -> Option<String> {
let request = PreToolUseRequest {
session_id: sess.conversation_id,
@@ -148,10 +148,10 @@ pub(crate) async fn run_pre_tool_use_hooks(
transcript_path: sess.hook_transcript_path().await,
model: turn_context.model_info.slug.clone(),
permission_mode: hook_permission_mode(turn_context),
tool_name,
matcher_aliases,
tool_name: tool_name.name().to_string(),
matcher_aliases: tool_name.matcher_aliases().to_vec(),
tool_use_id,
command,
tool_input: tool_input.clone(),
};
let preview_runs = sess.hooks().preview_pre_tool_use(&request);
emit_hook_started_events(sess, turn_context, preview_runs).await;
@@ -163,7 +163,22 @@ pub(crate) async fn run_pre_tool_use_hooks(
} = sess.hooks().run_pre_tool_use(request).await;
emit_hook_completed_events(sess, turn_context, hook_events).await;
if should_block { block_reason } else { None }
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()
)
}
})
} else {
None
}
}
// PermissionRequest hooks share the same preview/start/completed event flow as
@@ -185,8 +200,7 @@ pub(crate) async fn run_permission_request_hooks(
tool_name: payload.tool_name.name().to_string(),
matcher_aliases: payload.tool_name.matcher_aliases().to_vec(),
run_id_suffix: run_id_suffix.to_string(),
command: payload.command,
description: payload.description,
tool_input: payload.tool_input,
};
let preview_runs = sess.hooks().preview_permission_request(&request);
emit_hook_started_events(sess, turn_context, preview_runs).await;
@@ -202,7 +216,7 @@ pub(crate) async fn run_permission_request_hooks(
/// Runs matching `PostToolUse` hooks after a tool has produced a successful output.
///
/// The `tool_name`, matcher aliases, `command`, and `tool_response` values are
/// The `tool_name`, matcher aliases, `tool_input`, and `tool_response` values are
/// already adapted by the tool handler into the stable hook contract. Passing
/// raw internal tool data here would leak implementation details into user hook
/// matchers and hook logs.
@@ -212,7 +226,7 @@ pub(crate) async fn run_post_tool_use_hooks(
tool_use_id: String,
tool_name: String,
matcher_aliases: Vec<String>,
command: String,
tool_input: Value,
tool_response: Value,
) -> PostToolUseOutcome {
let request = PostToolUseRequest {
@@ -225,7 +239,7 @@ pub(crate) async fn run_post_tool_use_hooks(
tool_name,
matcher_aliases,
tool_use_id,
command,
tool_input,
tool_response,
};
let preview_runs = sess.hooks().preview_post_tool_use(&request);

View File

@@ -25,16 +25,20 @@ use crate::guardian::guardian_timeout_message;
use crate::guardian::new_guardian_review_id;
use crate::guardian::review_approval_request;
use crate::guardian::routes_approval_to_guardian;
use crate::hook_runtime::run_permission_request_hooks;
use crate::mcp_openai_file::rewrite_mcp_tool_arguments_for_openai_files;
use crate::mcp_tool_approval_templates::RenderedMcpToolApprovalParam;
use crate::mcp_tool_approval_templates::render_mcp_tool_approval_template;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use crate::tools::hook_names::HookToolName;
use crate::tools::sandboxing::PermissionRequestPayload;
use codex_analytics::AppInvocation;
use codex_analytics::InvocationType;
use codex_analytics::build_track_events_context;
use codex_config::types::AppToolApproval;
use codex_features::Feature;
use codex_hooks::PermissionRequestDecision;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::SandboxState;
use codex_mcp::declared_openai_file_input_param_names;
@@ -59,6 +63,7 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use rmcp::model::ToolAnnotations;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
use std::sync::Arc;
use toml_edit::value;
use tracing::Instrument;
@@ -77,8 +82,9 @@ pub(crate) async fn handle_mcp_tool_call(
call_id: String,
server: String,
tool_name: String,
hook_tool_name: String,
arguments: String,
) -> CallToolResult {
) -> HandledMcpToolCall {
// Parse the `arguments` as JSON. An empty string is OK, but invalid JSON
// is not.
let arguments_value = if arguments.trim().is_empty() {
@@ -88,7 +94,10 @@ pub(crate) async fn handle_mcp_tool_call(
Ok(value) => Some(value),
Err(e) => {
error!("failed to parse tool call arguments: {e}");
return CallToolResult::from_error_text(format!("err: {e}"));
return HandledMcpToolCall {
result: CallToolResult::from_error_text(format!("err: {e}")),
tool_input: JsonValue::Object(serde_json::Map::new()),
};
}
}
};
@@ -144,7 +153,11 @@ pub(crate) async fn handle_mcp_tool_call(
/*inc*/ 1,
&[("status", status)],
);
return CallToolResult::from_result(result);
return HandledMcpToolCall {
result: CallToolResult::from_result(result),
tool_input: arguments_value
.unwrap_or_else(|| JsonValue::Object(serde_json::Map::new())),
};
}
let request_meta =
build_mcp_tool_call_request_meta(turn_context.as_ref(), &server, metadata.as_ref());
@@ -154,13 +167,6 @@ pub(crate) async fn handle_mcp_tool_call(
let connector_name = metadata
.as_ref()
.and_then(|metadata| metadata.connector_name.clone());
let server_origin = sess
.services
.mcp_connection_manager
.read()
.await
.server_origin(&server)
.map(str::to_string);
let tool_call_begin_event = EventMsg::McpToolCallBegin(McpToolCallBeginEvent {
call_id: call_id.clone(),
@@ -174,115 +180,64 @@ pub(crate) async fn handle_mcp_tool_call(
turn_context,
&call_id,
&invocation,
&hook_tool_name,
metadata.as_ref(),
approval_mode,
)
.await
{
let (result, call_duration) = match decision {
let result = match decision {
McpToolApprovalDecision::Accept
| McpToolApprovalDecision::AcceptForSession
| McpToolApprovalDecision::AcceptAndRemember => {
maybe_mark_thread_memory_mode_polluted(sess.as_ref(), turn_context.as_ref()).await;
let start = Instant::now();
let result = async {
execute_mcp_tool_call(
sess.as_ref(),
turn_context.as_ref(),
&server,
&tool_name,
arguments_value.clone(),
metadata.as_ref(),
request_meta.clone(),
)
.await
}
.instrument(mcp_tool_call_span(
return handle_approved_mcp_tool_call(
sess.as_ref(),
turn_context.as_ref(),
McpToolCallSpanFields {
server_name: &server,
tool_name: &tool_name,
call_id: &call_id,
server_origin: server_origin.as_deref(),
connector_id: connector_id.as_deref(),
connector_name: connector_name.as_deref(),
},
))
.await;
if let Err(error) = &result {
tracing::warn!("MCP tool call error: {error:?}");
}
let duration = start.elapsed();
let tool_call_end_event = EventMsg::McpToolCallEnd(McpToolCallEndEvent {
call_id: call_id.clone(),
&call_id,
invocation,
mcp_app_resource_uri: mcp_app_resource_uri.clone(),
duration,
result: result.clone(),
});
notify_mcp_tool_call_event(
sess.as_ref(),
turn_context.as_ref(),
tool_call_end_event.clone(),
metadata.as_ref(),
request_meta,
mcp_app_resource_uri,
)
.await;
maybe_track_codex_app_used(
sess.as_ref(),
turn_context.as_ref(),
&server,
&tool_name,
)
.await;
(result, Some(duration))
}
McpToolApprovalDecision::Decline { message } => {
let message = message.unwrap_or_else(|| "user rejected MCP tool call".to_string());
(
notify_mcp_tool_call_skip(
sess.as_ref(),
turn_context.as_ref(),
&call_id,
invocation,
mcp_app_resource_uri.clone(),
message,
/*already_started*/ true,
)
.await,
None,
notify_mcp_tool_call_skip(
sess.as_ref(),
turn_context.as_ref(),
&call_id,
invocation,
mcp_app_resource_uri.clone(),
message,
/*already_started*/ true,
)
.await
}
McpToolApprovalDecision::Cancel => {
let message = "user cancelled MCP tool call".to_string();
(
notify_mcp_tool_call_skip(
sess.as_ref(),
turn_context.as_ref(),
&call_id,
invocation,
mcp_app_resource_uri.clone(),
message,
/*already_started*/ true,
)
.await,
None,
notify_mcp_tool_call_skip(
sess.as_ref(),
turn_context.as_ref(),
&call_id,
invocation,
mcp_app_resource_uri.clone(),
message,
/*already_started*/ true,
)
.await
}
McpToolApprovalDecision::BlockedBySafetyMonitor(message) => {
(
notify_mcp_tool_call_skip(
sess.as_ref(),
turn_context.as_ref(),
&call_id,
invocation,
mcp_app_resource_uri.clone(),
message,
/*already_started*/ true,
)
.await,
None,
notify_mcp_tool_call_skip(
sess.as_ref(),
turn_context.as_ref(),
&call_id,
invocation,
mcp_app_resource_uri.clone(),
message,
/*already_started*/ true,
)
.await
}
};
@@ -293,37 +248,93 @@ pub(crate) async fn handle_mcp_tool_call(
&tool_name,
connector_id.as_deref(),
connector_name.as_deref(),
call_duration,
/*duration*/ None,
);
return CallToolResult::from_result(result);
return HandledMcpToolCall {
result: CallToolResult::from_result(result),
tool_input: arguments_value
.unwrap_or_else(|| JsonValue::Object(serde_json::Map::new())),
};
}
maybe_mark_thread_memory_mode_polluted(sess.as_ref(), turn_context.as_ref()).await;
handle_approved_mcp_tool_call(
sess.as_ref(),
turn_context.as_ref(),
&call_id,
invocation,
metadata.as_ref(),
request_meta,
mcp_app_resource_uri,
)
.await
}
pub(crate) struct HandledMcpToolCall {
pub(crate) result: CallToolResult,
pub(crate) tool_input: JsonValue,
}
async fn handle_approved_mcp_tool_call(
sess: &Session,
turn_context: &TurnContext,
call_id: &str,
invocation: McpInvocation,
metadata: Option<&McpToolApprovalMetadata>,
request_meta: Option<JsonValue>,
mcp_app_resource_uri: Option<String>,
) -> HandledMcpToolCall {
maybe_mark_thread_memory_mode_polluted(sess, turn_context).await;
let server = invocation.server.clone();
let tool_name = invocation.tool.clone();
let arguments_value = invocation.arguments.clone();
let connector_id = metadata.and_then(|metadata| metadata.connector_id.as_deref());
let connector_name = metadata.and_then(|metadata| metadata.connector_name.as_deref());
let server_origin = sess
.services
.mcp_connection_manager
.read()
.await
.server_origin(&server)
.map(str::to_string);
let start = Instant::now();
let rewrite = rewrite_mcp_tool_arguments_for_openai_files(
sess,
turn_context,
arguments_value.clone(),
metadata.and_then(|metadata| metadata.openai_file_input_params.as_deref()),
)
.await;
let tool_input = match &rewrite {
Ok(Some(rewritten_arguments)) => rewritten_arguments.clone(),
Ok(None) | Err(_) => arguments_value
.clone()
.unwrap_or_else(|| JsonValue::Object(serde_json::Map::new())),
};
let result = async {
let rewritten_arguments = rewrite?;
execute_mcp_tool_call(
sess.as_ref(),
turn_context.as_ref(),
sess,
turn_context,
&server,
&tool_name,
arguments_value.clone(),
metadata.as_ref(),
rewritten_arguments,
request_meta,
)
.await
}
.instrument(mcp_tool_call_span(
sess.as_ref(),
turn_context.as_ref(),
sess,
turn_context,
McpToolCallSpanFields {
server_name: &server,
tool_name: &tool_name,
call_id: &call_id,
call_id,
server_origin: server_origin.as_deref(),
connector_id: connector_id.as_deref(),
connector_name: connector_name.as_deref(),
connector_id,
connector_name,
},
))
.await;
@@ -332,32 +343,29 @@ pub(crate) async fn handle_mcp_tool_call(
}
let duration = start.elapsed();
let tool_call_end_event = EventMsg::McpToolCallEnd(McpToolCallEndEvent {
call_id: call_id.clone(),
call_id: call_id.to_string(),
invocation,
mcp_app_resource_uri,
duration,
result: result.clone(),
});
notify_mcp_tool_call_event(
sess.as_ref(),
turn_context.as_ref(),
tool_call_end_event.clone(),
)
.await;
maybe_track_codex_app_used(sess.as_ref(), turn_context.as_ref(), &server, &tool_name).await;
notify_mcp_tool_call_event(sess, turn_context, tool_call_end_event.clone()).await;
maybe_track_codex_app_used(sess, turn_context, &server, &tool_name).await;
let status = if result.is_ok() { "ok" } else { "error" };
emit_mcp_call_metrics(
turn_context.as_ref(),
turn_context,
status,
&tool_name,
connector_id.as_deref(),
connector_name.as_deref(),
connector_id,
connector_name,
Some(duration),
);
CallToolResult::from_result(result)
HandledMcpToolCall {
result: CallToolResult::from_result(result),
tool_input,
}
}
fn emit_mcp_call_metrics(
@@ -466,17 +474,9 @@ async fn execute_mcp_tool_call(
turn_context: &TurnContext,
server: &str,
tool_name: &str,
arguments_value: Option<serde_json::Value>,
metadata: Option<&McpToolApprovalMetadata>,
request_meta: Option<serde_json::Value>,
rewritten_arguments: Option<JsonValue>,
request_meta: Option<JsonValue>,
) -> Result<CallToolResult, String> {
let rewritten_arguments = rewrite_mcp_tool_arguments_for_openai_files(
sess,
turn_context,
arguments_value,
metadata.and_then(|metadata| metadata.openai_file_input_params.as_deref()),
)
.await?;
let request_meta =
with_mcp_tool_call_thread_id_meta(request_meta, &sess.conversation_id.to_string());
let request_meta =
@@ -814,6 +814,7 @@ async fn maybe_request_mcp_tool_approval(
turn_context: &Arc<TurnContext>,
call_id: &str,
invocation: &McpInvocation,
hook_tool_name: &str,
metadata: Option<&McpToolApprovalMetadata>,
approval_mode: AppToolApproval,
) -> Option<McpToolApprovalDecision> {
@@ -863,6 +864,32 @@ async fn maybe_request_mcp_tool_approval(
{
return Some(McpToolApprovalDecision::Accept);
}
match run_permission_request_hooks(
sess,
turn_context,
call_id,
PermissionRequestPayload {
tool_name: HookToolName::new(hook_tool_name),
tool_input: invocation
.arguments
.clone()
.unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())),
},
)
.await
{
Some(PermissionRequestDecision::Allow) => {
return Some(McpToolApprovalDecision::Accept);
}
Some(PermissionRequestDecision::Deny { message }) => {
return Some(McpToolApprovalDecision::Decline {
message: Some(message),
});
}
None => {}
}
let tool_call_mcp_elicitation_enabled = turn_context
.config
.features

View File

@@ -12,6 +12,8 @@ use codex_config::types::ApprovalsReviewer;
use codex_config::types::AppsConfigToml;
use codex_config::types::McpServerConfig;
use codex_config::types::McpServerToolConfig;
use codex_hooks::Hooks;
use codex_hooks::HooksConfig;
use codex_model_provider::create_model_provider;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
@@ -76,6 +78,81 @@ fn prompt_options(
}
}
fn install_mcp_permission_request_hook(
session: &mut Session,
turn_context: &TurnContext,
matcher: &str,
hook_output: &serde_json::Value,
) -> std::path::PathBuf {
let script_path = turn_context
.config
.codex_home
.join("mcp_permission_request_hook.py");
let log_path = turn_context
.config
.codex_home
.join("mcp_permission_request_hook_log.jsonl");
let hook_output = hook_output.to_string();
std::fs::create_dir_all(&turn_context.config.codex_home)
.expect("create codex home for MCP permission hook");
let script = format!(
r#"import json
from pathlib import Path
import sys
payload = json.load(sys.stdin)
with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
handle.write(json.dumps(payload) + "\n")
print({hook_output:?})
"#,
log_path = log_path.display(),
hook_output = hook_output,
);
std::fs::write(&script_path, script).expect("write MCP permission hook script");
let python = if cfg!(windows) { "python" } else { "python3" };
let script_path_arg = if cfg!(windows) {
script_path.display().to_string()
} else {
format!(
"'{}'",
script_path.display().to_string().replace('\'', "'\\''")
)
};
std::fs::write(
turn_context.config.codex_home.join("hooks.json"),
serde_json::json!({
"hooks": {
"PermissionRequest": [{
"matcher": matcher,
"hooks": [{
"type": "command",
"command": format!("{python} {script_path_arg}"),
"timeout_sec": 5,
}]
}]
}
})
.to_string(),
)
.expect("write hooks.json");
session.services.hooks = Hooks::new(HooksConfig {
feature_enabled: true,
config_layer_stack: Some(turn_context.config.config_layer_stack.clone()),
shell_program: (!cfg!(windows)).then_some("/bin/sh".to_string()),
shell_args: if cfg!(windows) {
Vec::new()
} else {
vec!["-c".to_string()]
},
..HooksConfig::default()
});
log_path.to_path_buf()
}
#[test]
fn mcp_app_resource_uri_reads_known_tool_meta_keys() {
let nested = serde_json::json!({
@@ -1381,6 +1458,7 @@ async fn approve_mode_skips_when_annotations_do_not_require_approval() {
&turn_context,
"call-1",
&invocation,
"mcp__test__tool",
Some(&metadata),
AppToolApproval::Approve,
)
@@ -1453,6 +1531,7 @@ async fn guardian_mode_skips_auto_when_annotations_do_not_require_approval() {
&turn_context,
"call-guardian",
&invocation,
"mcp__test__tool",
Some(&metadata),
AppToolApproval::Auto,
)
@@ -1461,6 +1540,203 @@ async fn guardian_mode_skips_auto_when_annotations_do_not_require_approval() {
assert_eq!(decision, None);
}
#[tokio::test]
async fn permission_request_hook_allows_mcp_tool_call() {
let (mut session, turn_context) = make_session_and_context().await;
let log_path = install_mcp_permission_request_hook(
&mut session,
&turn_context,
"mcp__memory__.*",
&serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": { "behavior": "allow" }
}
}),
);
let session = Arc::new(session);
let turn_context = Arc::new(turn_context);
let invocation = McpInvocation {
server: "memory".to_string(),
tool: "create_entities".to_string(),
arguments: Some(serde_json::json!({
"entities": [{
"name": "Ada",
"entityType": "person"
}]
})),
};
let metadata = McpToolApprovalMetadata {
annotations: Some(annotations(
Some(false),
Some(true),
/*open_world*/ None,
)),
connector_id: None,
connector_name: None,
connector_description: None,
tool_title: Some("Create entities".to_string()),
tool_description: None,
mcp_app_resource_uri: None,
codex_apps_meta: None,
openai_file_input_params: None,
};
let decision = maybe_request_mcp_tool_approval(
&session,
&turn_context,
"call-mcp-hook",
&invocation,
"mcp__memory__create_entities",
Some(&metadata),
AppToolApproval::Auto,
)
.await;
assert_eq!(decision, Some(McpToolApprovalDecision::Accept));
let log = std::fs::read_to_string(log_path).expect("read MCP permission hook log");
let inputs = log
.lines()
.map(|line| serde_json::from_str::<serde_json::Value>(line).expect("parse hook input"))
.collect::<Vec<_>>();
assert_eq!(
inputs,
vec![serde_json::json!({
"session_id": session.conversation_id,
"turn_id": "turn_id",
"cwd": turn_context.cwd,
"transcript_path": null,
"model": turn_context.model_info.slug,
"permission_mode": "default",
"tool_name": "mcp__memory__create_entities",
"hook_event_name": "PermissionRequest",
"tool_input": {
"entities": [{
"name": "Ada",
"entityType": "person"
}]
}
})]
);
}
#[tokio::test]
async fn permission_request_hook_uses_hook_tool_name_without_metadata() {
let (mut session, turn_context) = make_session_and_context().await;
let log_path = install_mcp_permission_request_hook(
&mut session,
&turn_context,
"mcp__memory__.*",
&serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": { "behavior": "allow" }
}
}),
);
let session = Arc::new(session);
let turn_context = Arc::new(turn_context);
let invocation = McpInvocation {
server: "memory".to_string(),
tool: "create_entities".to_string(),
arguments: Some(serde_json::json!({ "entities": [] })),
};
let decision = maybe_request_mcp_tool_approval(
&session,
&turn_context,
"call-mcp-hook-no-metadata",
&invocation,
"mcp__memory__create_entities",
/*metadata*/ None,
AppToolApproval::Auto,
)
.await;
assert_eq!(decision, Some(McpToolApprovalDecision::Accept));
let log = std::fs::read_to_string(log_path).expect("read MCP permission hook log");
let inputs = log
.lines()
.map(|line| serde_json::from_str::<serde_json::Value>(line).expect("parse hook input"))
.collect::<Vec<_>>();
assert_eq!(
inputs,
vec![serde_json::json!({
"session_id": session.conversation_id,
"turn_id": "turn_id",
"cwd": turn_context.cwd,
"transcript_path": null,
"model": turn_context.model_info.slug,
"permission_mode": "default",
"tool_name": "mcp__memory__create_entities",
"hook_event_name": "PermissionRequest",
"tool_input": { "entities": [] }
})]
);
}
#[tokio::test]
async fn permission_request_hook_runs_after_remembered_mcp_approval() {
let (mut session, turn_context) = make_session_and_context().await;
let log_path = install_mcp_permission_request_hook(
&mut session,
&turn_context,
"mcp__memory__.*",
&serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": {
"behavior": "deny",
"message": "should be skipped"
}
}
}),
);
let invocation = McpInvocation {
server: "memory".to_string(),
tool: "create_entities".to_string(),
arguments: Some(serde_json::json!({ "entities": [] })),
};
let metadata = McpToolApprovalMetadata {
annotations: Some(annotations(
Some(false),
Some(true),
/*open_world*/ None,
)),
connector_id: None,
connector_name: None,
connector_description: None,
tool_title: Some("Create entities".to_string()),
tool_description: None,
mcp_app_resource_uri: None,
codex_apps_meta: None,
openai_file_input_params: None,
};
let remembered_key =
session_mcp_tool_approval_key(&invocation, Some(&metadata), AppToolApproval::Auto)
.expect("memory MCP tool should support session approval");
remember_mcp_tool_approval(&session, remembered_key).await;
let session = Arc::new(session);
let turn_context = Arc::new(turn_context);
let decision = maybe_request_mcp_tool_approval(
&session,
&turn_context,
"call-mcp-remembered",
&invocation,
"mcp__memory__create_entities",
Some(&metadata),
AppToolApproval::Auto,
)
.await;
assert_eq!(decision, Some(McpToolApprovalDecision::Accept));
assert!(
!log_path.exists(),
"remembered approval should skip PermissionRequest hooks"
);
}
#[tokio::test]
async fn guardian_mode_mcp_denial_returns_rationale_message() {
let server = start_mock_server().await;
@@ -1528,6 +1804,7 @@ async fn guardian_mode_mcp_denial_returns_rationale_message() {
&turn_context,
"call-guardian-deny",
&invocation,
"mcp__test__tool",
Some(&metadata),
AppToolApproval::Auto,
)
@@ -1584,6 +1861,7 @@ async fn prompt_mode_waits_for_approval_when_annotations_do_not_require_approval
&turn_context,
"call-prompt",
&invocation,
"mcp__test__tool",
Some(&metadata),
AppToolApproval::Prompt,
)
@@ -1658,6 +1936,7 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_for_model() {
&turn_context,
"call-2",
&invocation,
"mcp__test__tool",
Some(&metadata),
AppToolApproval::Approve,
)
@@ -1729,6 +2008,7 @@ async fn custom_approve_mode_blocks_when_arc_returns_interrupt_for_model() {
&turn_context,
"call-2-custom",
&invocation,
"mcp__test__tool",
Some(&metadata),
AppToolApproval::Approve,
)
@@ -1800,6 +2080,7 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_without_annotations() {
&turn_context,
"call-3",
&invocation,
"mcp__test__tool",
Some(&metadata),
AppToolApproval::Approve,
)
@@ -1884,6 +2165,7 @@ async fn full_access_mode_skips_arc_monitor_for_all_approval_modes() {
&turn_context,
"call-2",
&invocation,
"mcp__test__tool",
Some(&metadata),
approval_mode,
)
@@ -1985,6 +2267,7 @@ async fn approve_mode_routes_arc_ask_user_to_guardian_when_guardian_reviewer_is_
&turn_context,
"call-3",
&invocation,
"mcp__test__tool",
Some(&metadata),
AppToolApproval::Approve,
)

View File

@@ -132,6 +132,7 @@ impl ToolOutput for CallToolResult {
#[derive(Clone, Debug)]
pub struct McpToolOutput {
pub result: CallToolResult,
pub tool_input: JsonValue,
pub wall_time: Duration,
pub original_image_detail_supported: bool,
}
@@ -162,6 +163,10 @@ impl ToolOutput for McpToolOutput {
JsonValue::String(format!("failed to serialize mcp result: {err}"))
})
}
fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option<JsonValue> {
serde_json::to_value(&self.result).ok()
}
}
impl McpToolOutput {

View File

@@ -98,6 +98,7 @@ fn mcp_tool_output_response_item_includes_wall_time() {
is_error: Some(false),
meta: None,
},
tool_input: json!({}),
wall_time: std::time::Duration::from_millis(1250),
original_image_detail_supported: false,
};
@@ -150,6 +151,7 @@ fn mcp_tool_output_response_item_preserves_content_items() {
is_error: Some(false),
meta: None,
},
tool_input: json!({}),
wall_time: std::time::Duration::from_millis(500),
original_image_detail_supported: false,
};
@@ -203,6 +205,7 @@ fn mcp_tool_output_code_mode_result_stays_raw_call_tool_result() {
is_error: Some(false),
meta: None,
},
tool_input: json!({}),
wall_time: std::time::Duration::from_millis(1250),
original_image_detail_supported: false,
};

View File

@@ -316,21 +316,23 @@ impl ToolHandler for ApplyPatchHandler {
fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
apply_patch_payload_command(&invocation.payload).map(|command| PreToolUsePayload {
tool_name: HookToolName::apply_patch(),
command,
tool_input: serde_json::json!({ "command": command }),
})
}
fn post_tool_use_payload(
&self,
call_id: &str,
payload: &ToolPayload,
invocation: &ToolInvocation,
result: &Self::Output,
) -> Option<PostToolUsePayload> {
let tool_response = result.post_tool_use_response(call_id, payload)?;
let tool_response =
result.post_tool_use_response(&invocation.call_id, &invocation.payload)?;
Some(PostToolUsePayload {
tool_name: HookToolName::apply_patch(),
tool_use_id: call_id.to_string(),
command: apply_patch_payload_command(payload)?,
tool_use_id: invocation.call_id.clone(),
tool_input: serde_json::json!({
"command": apply_patch_payload_command(&invocation.payload)?,
}),
tool_response,
})
}

View File

@@ -54,7 +54,7 @@ async fn pre_tool_use_payload_uses_json_patch_input() {
handler.pre_tool_use_payload(&invocation),
Some(PreToolUsePayload {
tool_name: HookToolName::apply_patch(),
command: patch.to_string(),
tool_input: json!({ "command": patch }),
})
);
}
@@ -72,26 +72,27 @@ async fn pre_tool_use_payload_uses_freeform_patch_input() {
handler.pre_tool_use_payload(&invocation),
Some(PreToolUsePayload {
tool_name: HookToolName::apply_patch(),
command: patch.to_string(),
tool_input: json!({ "command": patch }),
})
);
}
#[test]
fn post_tool_use_payload_uses_patch_input_and_tool_output() {
#[tokio::test]
async fn post_tool_use_payload_uses_patch_input_and_tool_output() {
let patch = sample_patch();
let payload = ToolPayload::Custom {
input: patch.to_string(),
};
let invocation = invocation_for_payload(payload).await;
let output = ApplyPatchToolOutput::from_text("Success. Updated files.".to_string());
let handler = ApplyPatchHandler;
assert_eq!(
handler.post_tool_use_payload("call-apply-patch", &payload, &output),
handler.post_tool_use_payload(&invocation, &output),
Some(PostToolUsePayload {
tool_name: HookToolName::apply_patch(),
tool_use_id: "call-apply-patch".to_string(),
command: patch.to_string(),
tool_input: json!({ "command": patch }),
tool_response: json!("Success. Updated files."),
})
);

View File

@@ -6,9 +6,14 @@ use crate::mcp_tool_call::handle_mcp_tool_call;
use crate::original_image_detail::can_request_original_image_detail;
use crate::tools::context::McpToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::hook_names::HookToolName;
use crate::tools::registry::PostToolUsePayload;
use crate::tools::registry::PreToolUsePayload;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use serde_json::Value;
pub struct McpHandler;
impl ToolHandler for McpHandler {
@@ -18,11 +23,42 @@ impl ToolHandler for McpHandler {
ToolKind::Mcp
}
fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
let ToolPayload::Mcp { raw_arguments, .. } = &invocation.payload else {
return None;
};
Some(PreToolUsePayload {
tool_name: HookToolName::new(invocation.tool_name.display()),
tool_input: mcp_hook_tool_input(raw_arguments),
})
}
fn post_tool_use_payload(
&self,
invocation: &ToolInvocation,
result: &Self::Output,
) -> Option<PostToolUsePayload> {
let ToolPayload::Mcp { .. } = &invocation.payload else {
return None;
};
let tool_response =
result.post_tool_use_response(&invocation.call_id, &invocation.payload)?;
Some(PostToolUsePayload {
tool_name: HookToolName::new(invocation.tool_name.display()),
tool_use_id: invocation.call_id.clone(),
tool_input: result.tool_input.clone(),
tool_response,
})
}
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let ToolInvocation {
session,
turn,
call_id,
tool_name: model_tool_name,
payload,
..
} = invocation;
@@ -50,14 +86,133 @@ impl ToolHandler for McpHandler {
call_id.clone(),
server,
tool,
model_tool_name.display(),
arguments_str,
)
.await;
Ok(McpToolOutput {
result,
result: result.result,
tool_input: result.tool_input,
wall_time: started.elapsed(),
original_image_detail_supported: can_request_original_image_detail(&turn.model_info),
})
}
}
fn mcp_hook_tool_input(raw_arguments: &str) -> Value {
if raw_arguments.trim().is_empty() {
return Value::Object(serde_json::Map::new());
}
serde_json::from_str(raw_arguments).unwrap_or_else(|_| Value::String(raw_arguments.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::tests::make_session_and_context;
use crate::turn_diff_tracker::TurnDiffTracker;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::time::Duration;
use tokio::sync::Mutex;
#[tokio::test]
async fn mcp_pre_tool_use_payload_uses_model_tool_name_and_raw_args() {
let payload = ToolPayload::Mcp {
server: "memory".to_string(),
tool: "create_entities".to_string(),
raw_arguments: json!({
"entities": [{
"name": "Ada",
"entityType": "person"
}]
})
.to_string(),
};
let (session, turn) = make_session_and_context().await;
assert_eq!(
McpHandler.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".to_string(),
tool_name: codex_tools::ToolName::namespaced("mcp__memory__", "create_entities"),
payload,
}),
Some(PreToolUsePayload {
tool_name: HookToolName::new("mcp__memory__create_entities"),
tool_input: json!({
"entities": [{
"name": "Ada",
"entityType": "person"
}]
}),
})
);
}
#[tokio::test]
async fn mcp_post_tool_use_payload_uses_model_tool_name_args_and_result() {
let payload = ToolPayload::Mcp {
server: "filesystem".to_string(),
tool: "read_file".to_string(),
raw_arguments: json!({ "path": "/tmp/notes.txt" }).to_string(),
};
let output = McpToolOutput {
result: codex_protocol::mcp::CallToolResult {
content: vec![json!({
"type": "text",
"text": "notes"
})],
structured_content: Some(json!({ "bytes": 5 })),
is_error: None,
meta: None,
},
tool_input: json!({
"path": {
"file_id": "file_123"
}
}),
wall_time: Duration::from_millis(42),
original_image_detail_supported: true,
};
let (session, turn) = make_session_and_context().await;
let invocation = 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-post".to_string(),
tool_name: codex_tools::ToolName::namespaced("mcp__filesystem__", "read_file"),
payload,
};
assert_eq!(
McpHandler.post_tool_use_payload(&invocation, &output),
Some(PostToolUsePayload {
tool_name: HookToolName::new("mcp__filesystem__read_file"),
tool_use_id: "call-mcp-post".to_string(),
tool_input: json!({
"path": {
"file_id": "file_123"
}
}),
tool_response: json!({
"content": [{
"type": "text",
"text": "notes"
}],
"structuredContent": { "bytes": 5 }
}),
})
);
}
#[test]
fn mcp_hook_tool_input_defaults_empty_args_to_object() {
assert_eq!(mcp_hook_tool_input(" "), json!({}));
}
}

View File

@@ -208,21 +208,22 @@ impl ToolHandler for ShellHandler {
fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
shell_payload_command(&invocation.payload).map(|command| PreToolUsePayload {
tool_name: HookToolName::bash(),
command,
tool_input: serde_json::json!({ "command": command }),
})
}
fn post_tool_use_payload(
&self,
call_id: &str,
payload: &ToolPayload,
invocation: &ToolInvocation,
result: &Self::Output,
) -> Option<PostToolUsePayload> {
let tool_response = result.post_tool_use_response(call_id, payload)?;
let tool_response =
result.post_tool_use_response(&invocation.call_id, &invocation.payload)?;
let command = shell_payload_command(&invocation.payload)?;
Some(PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id: call_id.to_string(),
command: shell_payload_command(payload)?,
tool_use_id: invocation.call_id.clone(),
tool_input: serde_json::json!({ "command": command }),
tool_response,
})
}
@@ -321,21 +322,22 @@ impl ToolHandler for ShellCommandHandler {
fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
shell_command_payload_command(&invocation.payload).map(|command| PreToolUsePayload {
tool_name: HookToolName::bash(),
command,
tool_input: serde_json::json!({ "command": command }),
})
}
fn post_tool_use_payload(
&self,
call_id: &str,
payload: &ToolPayload,
invocation: &ToolInvocation,
result: &Self::Output,
) -> Option<PostToolUsePayload> {
let tool_response = result.post_tool_use_response(call_id, payload)?;
let tool_response =
result.post_tool_use_response(&invocation.call_id, &invocation.payload)?;
let command = shell_command_payload_command(&invocation.payload)?;
Some(PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id: call_id.to_string(),
command: shell_command_payload_command(payload)?,
tool_use_id: invocation.call_id.clone(),
tool_input: serde_json::json!({ "command": command }),
tool_response,
})
}

View File

@@ -234,7 +234,7 @@ async fn shell_pre_tool_use_payload_uses_joined_command() {
}),
Some(crate::tools::registry::PreToolUsePayload {
tool_name: HookToolName::bash(),
command: "bash -lc 'printf hi'".to_string(),
tool_input: json!({ "command": "bash -lc 'printf hi'" }),
})
);
}
@@ -261,13 +261,13 @@ async fn shell_command_pre_tool_use_payload_uses_raw_command() {
}),
Some(crate::tools::registry::PreToolUsePayload {
tool_name: HookToolName::bash(),
command: "printf shell command".to_string(),
tool_input: json!({ "command": "printf shell command" }),
})
);
}
#[test]
fn build_post_tool_use_payload_uses_tool_output_wire_value() {
#[tokio::test]
async fn build_post_tool_use_payload_uses_tool_output_wire_value() {
let payload = ToolPayload::Function {
arguments: json!({ "command": "printf shell command" }).to_string(),
};
@@ -279,13 +279,22 @@ fn build_post_tool_use_payload_uses_tool_output_wire_value() {
let handler = ShellCommandHandler {
backend: super::ShellCommandBackend::Classic,
};
let (session, turn) = make_session_and_context().await;
let invocation = ToolInvocation {
session: session.into(),
turn: turn.into(),
cancellation_token: tokio_util::sync::CancellationToken::new(),
tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
call_id: "call-42".to_string(),
tool_name: codex_tools::ToolName::plain("shell_command"),
payload,
};
assert_eq!(
handler.post_tool_use_payload("call-42", &payload, &output),
handler.post_tool_use_payload(&invocation, &output),
Some(crate::tools::registry::PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id: "call-42".to_string(),
command: "printf shell command".to_string(),
tool_input: json!({ "command": "printf shell command" }),
tool_response: json!("shell output"),
})
);

View File

@@ -139,31 +139,30 @@ impl ToolHandler for UnifiedExecHandler {
.ok()
.map(|args| PreToolUsePayload {
tool_name: HookToolName::bash(),
command: args.cmd,
tool_input: serde_json::json!({ "command": args.cmd }),
})
}
fn post_tool_use_payload(
&self,
call_id: &str,
payload: &ToolPayload,
invocation: &ToolInvocation,
result: &Self::Output,
) -> Option<PostToolUsePayload> {
let ToolPayload::Function { .. } = payload else {
let ToolPayload::Function { .. } = &invocation.payload else {
return None;
};
let command = result.hook_command.clone()?;
let tool_use_id = if result.event_call_id.is_empty() {
call_id.to_string()
invocation.call_id.clone()
} else {
result.event_call_id.clone()
};
let tool_response = result.post_tool_use_response(&tool_use_id, payload)?;
let tool_response = result.post_tool_use_response(&tool_use_id, &invocation.payload)?;
Some(PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id,
command,
tool_input: serde_json::json!({ "command": command }),
tool_response,
})
}

View File

@@ -22,6 +22,23 @@ use crate::tools::registry::ToolHandler;
use crate::turn_diff_tracker::TurnDiffTracker;
use tokio::sync::Mutex;
async fn invocation_for_payload(
tool_name: &str,
call_id: &str,
payload: ToolPayload,
) -> ToolInvocation {
let (session, turn) = make_session_and_context().await;
ToolInvocation {
session: session.into(),
turn: turn.into(),
cancellation_token: tokio_util::sync::CancellationToken::new(),
tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
call_id: call_id.to_string(),
tool_name: codex_tools::ToolName::plain(tool_name),
payload,
}
}
#[test]
fn test_get_command_uses_default_shell_when_unspecified() -> anyhow::Result<()> {
let json = r#"{"cmd": "echo hello"}"#;
@@ -219,7 +236,7 @@ async fn exec_command_pre_tool_use_payload_uses_raw_command() {
}),
Some(crate::tools::registry::PreToolUsePayload {
tool_name: HookToolName::bash(),
command: "printf exec command".to_string(),
tool_input: serde_json::json!({ "command": "printf exec command" }),
})
);
}
@@ -246,8 +263,8 @@ async fn exec_command_pre_tool_use_payload_skips_write_stdin() {
);
}
#[test]
fn exec_command_post_tool_use_payload_uses_output_for_noninteractive_one_shot_commands() {
#[tokio::test]
async fn exec_command_post_tool_use_payload_uses_output_for_noninteractive_one_shot_commands() {
let payload = ToolPayload::Function {
arguments: serde_json::json!({ "cmd": "echo three", "tty": false }).to_string(),
};
@@ -262,20 +279,20 @@ fn exec_command_post_tool_use_payload_uses_output_for_noninteractive_one_shot_co
original_token_count: None,
hook_command: Some("echo three".to_string()),
};
let invocation = invocation_for_payload("exec_command", "call-43", payload).await;
assert_eq!(
UnifiedExecHandler.post_tool_use_payload("call-43", &payload, &output),
UnifiedExecHandler.post_tool_use_payload(&invocation, &output),
Some(crate::tools::registry::PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id: "call-43".to_string(),
command: "echo three".to_string(),
tool_input: serde_json::json!({ "command": "echo three" }),
tool_response: serde_json::json!("three"),
})
);
}
#[test]
fn exec_command_post_tool_use_payload_uses_output_for_interactive_completion() {
#[tokio::test]
async fn exec_command_post_tool_use_payload_uses_output_for_interactive_completion() {
let payload = ToolPayload::Function {
arguments: serde_json::json!({ "cmd": "echo three", "tty": true }).to_string(),
};
@@ -290,20 +307,21 @@ fn exec_command_post_tool_use_payload_uses_output_for_interactive_completion() {
original_token_count: None,
hook_command: Some("echo three".to_string()),
};
let invocation = invocation_for_payload("exec_command", "call-44", payload).await;
assert_eq!(
UnifiedExecHandler.post_tool_use_payload("call-44", &payload, &output),
UnifiedExecHandler.post_tool_use_payload(&invocation, &output),
Some(crate::tools::registry::PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id: "call-44".to_string(),
command: "echo three".to_string(),
tool_input: serde_json::json!({ "command": "echo three" }),
tool_response: serde_json::json!("three"),
})
);
}
#[test]
fn exec_command_post_tool_use_payload_skips_running_sessions() {
#[tokio::test]
async fn exec_command_post_tool_use_payload_skips_running_sessions() {
let payload = ToolPayload::Function {
arguments: serde_json::json!({ "cmd": "echo three", "tty": false }).to_string(),
};
@@ -318,15 +336,15 @@ fn exec_command_post_tool_use_payload_skips_running_sessions() {
original_token_count: None,
hook_command: Some("echo three".to_string()),
};
let invocation = invocation_for_payload("exec_command", "call-45", payload).await;
assert_eq!(
UnifiedExecHandler.post_tool_use_payload("call-45", &payload, &output),
UnifiedExecHandler.post_tool_use_payload(&invocation, &output),
None
);
}
#[test]
fn write_stdin_post_tool_use_payload_uses_original_exec_call_id_and_command_on_completion() {
#[tokio::test]
async fn write_stdin_post_tool_use_payload_uses_original_exec_call_id_and_command_on_completion() {
let payload = ToolPayload::Function {
arguments: serde_json::json!({
"session_id": 45,
@@ -345,20 +363,21 @@ fn write_stdin_post_tool_use_payload_uses_original_exec_call_id_and_command_on_c
original_token_count: None,
hook_command: Some("sleep 1; echo finished".to_string()),
};
let invocation = invocation_for_payload("write_stdin", "write-stdin-call", payload).await;
assert_eq!(
UnifiedExecHandler.post_tool_use_payload("write-stdin-call", &payload, &output),
UnifiedExecHandler.post_tool_use_payload(&invocation, &output),
Some(crate::tools::registry::PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id: "exec-call-45".to_string(),
command: "sleep 1; echo finished".to_string(),
tool_input: serde_json::json!({ "command": "sleep 1; echo finished" }),
tool_response: serde_json::json!("finished\n"),
})
);
}
#[test]
fn write_stdin_post_tool_use_payload_keeps_parallel_session_metadata_separate() {
#[tokio::test]
async fn write_stdin_post_tool_use_payload_keeps_parallel_session_metadata_separate() {
let payload = ToolPayload::Function {
arguments: serde_json::json!({ "session_id": 45, "chars": "" }).to_string(),
};
@@ -384,10 +403,12 @@ fn write_stdin_post_tool_use_payload_keeps_parallel_session_metadata_separate()
original_token_count: None,
hook_command: Some("sleep 1; echo beta".to_string()),
};
let invocation_b = invocation_for_payload("write_stdin", "write-call-b", payload.clone()).await;
let invocation_a = invocation_for_payload("write_stdin", "write-call-a", payload).await;
let payloads = [
UnifiedExecHandler.post_tool_use_payload("write-call-b", &payload, &output_b),
UnifiedExecHandler.post_tool_use_payload("write-call-a", &payload, &output_a),
UnifiedExecHandler.post_tool_use_payload(&invocation_b, &output_b),
UnifiedExecHandler.post_tool_use_payload(&invocation_a, &output_a),
];
assert_eq!(
@@ -396,13 +417,13 @@ fn write_stdin_post_tool_use_payload_keeps_parallel_session_metadata_separate()
Some(crate::tools::registry::PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id: "exec-call-b".to_string(),
command: "sleep 1; echo beta".to_string(),
tool_input: serde_json::json!({ "command": "sleep 1; echo beta" }),
tool_response: serde_json::json!("beta\n"),
}),
Some(crate::tools::registry::PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id: "exec-call-a".to_string(),
command: "sleep 2; echo alpha".to_string(),
tool_input: serde_json::json!({ "command": "sleep 2; echo alpha" }),
tool_response: serde_json::json!("alpha\n"),
}),
]

View File

@@ -8,7 +8,6 @@ use crate::guardian::routes_approval_to_guardian;
use crate::hook_runtime::run_permission_request_hooks;
use crate::network_policy_decision::denied_network_policy_message;
use crate::session::session::Session;
use crate::tools::hook_names::HookToolName;
use crate::tools::sandboxing::PermissionRequestPayload;
use crate::tools::sandboxing::ToolError;
use codex_hooks::PermissionRequestDecision;
@@ -393,11 +392,7 @@ impl NetworkApprovalService {
&session,
&turn_context,
&guardian_approval_id,
PermissionRequestPayload {
tool_name: HookToolName::bash(),
command,
description: Some(format!("network-access {target}")),
},
PermissionRequestPayload::bash(command, Some(format!("network-access {target}"))),
)
.await
{

View File

@@ -70,8 +70,7 @@ pub trait ToolHandler: Send + Sync {
fn post_tool_use_payload(
&self,
_call_id: &str,
_payload: &ToolPayload,
_invocation: &ToolInvocation,
_result: &Self::Output,
) -> Option<PostToolUsePayload> {
None
@@ -136,8 +135,11 @@ pub(crate) struct PreToolUsePayload {
/// The canonical name is serialized to hook stdin, while aliases are used
/// only for matcher compatibility.
pub(crate) tool_name: HookToolName,
/// Command-shaped input exposed at `tool_input.command`.
pub(crate) command: String,
/// Tool-specific input exposed at `tool_input`.
///
/// Shell-like tools use `{ "command": ... }`; MCP tools use their resolved
/// JSON arguments.
pub(crate) tool_input: Value,
}
#[derive(Debug, Clone, PartialEq)]
@@ -149,8 +151,8 @@ pub(crate) struct PostToolUsePayload {
pub(crate) tool_name: HookToolName,
/// The originating tool-use id exposed at `tool_use_id`.
pub(crate) tool_use_id: String,
/// Command-shaped input exposed at `tool_input.command`.
pub(crate) command: String,
/// Tool-specific input exposed at `tool_input`.
pub(crate) tool_input: Value,
/// Tool result exposed at `tool_response`.
pub(crate) tool_response: Value,
}
@@ -195,9 +197,9 @@ where
Box::pin(async move {
let call_id = invocation.call_id.clone();
let payload = invocation.payload.clone();
let output = self.handle(invocation).await?;
let output = self.handle(invocation.clone()).await?;
let post_tool_use_payload =
ToolHandler::post_tool_use_payload(self, &call_id, &payload, &output);
ToolHandler::post_tool_use_payload(self, &invocation, &output);
Ok(AnyToolResult {
call_id,
payload,
@@ -328,20 +330,16 @@ impl ToolRegistry {
}
if let Some(pre_tool_use_payload) = handler.pre_tool_use_payload(&invocation)
&& let Some(reason) = run_pre_tool_use_hooks(
&& let Some(message) = run_pre_tool_use_hooks(
&invocation.session,
&invocation.turn,
invocation.call_id.clone(),
pre_tool_use_payload.tool_name.name().to_string(),
pre_tool_use_payload.tool_name.matcher_aliases().to_vec(),
pre_tool_use_payload.command.clone(),
&pre_tool_use_payload.tool_name,
&pre_tool_use_payload.tool_input,
)
.await
{
return Err(FunctionCallError::RespondToModel(format!(
"Command blocked by PreToolUse hook: {reason}. Command: {}",
pre_tool_use_payload.command
)));
return Err(FunctionCallError::RespondToModel(message));
}
let is_mutating = handler.is_mutating(&invocation).await;
@@ -402,7 +400,7 @@ impl ToolRegistry {
post_tool_use_payload.tool_use_id,
post_tool_use_payload.tool_name.name().to_string(),
post_tool_use_payload.tool_name.matcher_aliases().to_vec(),
post_tool_use_payload.command,
post_tool_use_payload.tool_input,
post_tool_use_payload.tool_response,
)
.await,

View File

@@ -204,8 +204,7 @@ impl Approvable<ApplyPatchRequest> for ApplyPatchRuntime {
) -> Option<PermissionRequestPayload> {
Some(PermissionRequestPayload {
tool_name: HookToolName::apply_patch(),
command: req.action.patch.clone(),
description: None,
tool_input: serde_json::json!({ "command": req.action.patch }),
})
}
}

View File

@@ -105,8 +105,10 @@ fn permission_request_payload_uses_apply_patch_hook_name_and_aliases() {
payload.tool_name.matcher_aliases(),
&["Write".to_string(), "Edit".to_string()]
);
assert_eq!(payload.command, expected_patch);
assert_eq!(payload.description, None);
assert_eq!(
payload.tool_input,
serde_json::json!({ "command": expected_patch })
);
}
#[test]

View File

@@ -17,7 +17,6 @@ use crate::sandboxing::ExecOptions;
use crate::sandboxing::SandboxPermissions;
use crate::sandboxing::execute_env;
use crate::shell::ShellType;
use crate::tools::hook_names::HookToolName;
use crate::tools::network_approval::NetworkApprovalMode;
use crate::tools::network_approval::NetworkApprovalSpec;
use crate::tools::runtimes::build_sandbox_command;
@@ -202,11 +201,10 @@ impl Approvable<ShellRequest> for ShellRuntime {
}
fn permission_request_payload(&self, req: &ShellRequest) -> Option<PermissionRequestPayload> {
Some(PermissionRequestPayload {
tool_name: HookToolName::bash(),
command: req.hook_command.clone(),
description: req.justification.clone(),
})
Some(PermissionRequestPayload::bash(
req.hook_command.clone(),
req.justification.clone(),
))
}
fn sandbox_mode_for_first_attempt(&self, req: &ShellRequest) -> SandboxOverride {

View File

@@ -13,7 +13,6 @@ use crate::sandboxing::ExecOptions;
use crate::sandboxing::ExecRequest;
use crate::sandboxing::SandboxPermissions;
use crate::shell::ShellType;
use crate::tools::hook_names::HookToolName;
use crate::tools::runtimes::build_sandbox_command;
use crate::tools::sandboxing::PermissionRequestPayload;
use crate::tools::sandboxing::SandboxAttempt;
@@ -402,11 +401,10 @@ impl CoreShellActionProvider {
Ok(stopwatch
.pause_for(async move {
// 1) Run PermissionRequest hooks
let permission_request = PermissionRequestPayload {
tool_name: HookToolName::bash(),
command: codex_shell_command::parse_command::shlex_join(&command),
description: None,
};
let permission_request = PermissionRequestPayload::bash(
codex_shell_command::parse_command::shlex_join(&command),
/*description*/ None,
);
let effective_approval_id = approval_id.clone().unwrap_or_else(|| call_id.clone());
match run_permission_request_hooks(
&session,

View File

@@ -14,7 +14,6 @@ use crate::sandboxing::ExecOptions;
use crate::sandboxing::ExecServerEnvConfig;
use crate::sandboxing::SandboxPermissions;
use crate::shell::ShellType;
use crate::tools::hook_names::HookToolName;
use crate::tools::network_approval::NetworkApprovalMode;
use crate::tools::network_approval::NetworkApprovalSpec;
use crate::tools::runtimes::build_sandbox_command;
@@ -187,11 +186,10 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
&self,
req: &UnifiedExecRequest,
) -> Option<PermissionRequestPayload> {
Some(PermissionRequestPayload {
tool_name: HookToolName::bash(),
command: req.hook_command.clone(),
description: req.justification.clone(),
})
Some(PermissionRequestPayload::bash(
req.hook_command.clone(),
req.justification.clone(),
))
}
fn sandbox_mode_for_first_attempt(&self, req: &UnifiedExecRequest) -> SandboxOverride {

View File

@@ -135,8 +135,25 @@ pub(crate) struct ApprovalCtx<'a> {
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct PermissionRequestPayload {
pub tool_name: HookToolName,
pub command: String,
pub description: Option<String>,
pub tool_input: serde_json::Value,
}
impl PermissionRequestPayload {
pub(crate) fn bash(command: String, description: Option<String>) -> Self {
let mut tool_input = serde_json::Map::new();
tool_input.insert("command".to_string(), serde_json::Value::String(command));
if let Some(description) = description {
tool_input.insert(
"description".to_string(),
serde_json::Value::String(description),
);
}
Self {
tool_name: HookToolName::bash(),
tool_input: serde_json::Value::Object(tool_input),
}
}
}
// Specifies what tool orchestrator should do with a given tool call.

View File

@@ -1,8 +1,38 @@
use super::*;
use crate::sandboxing::SandboxPermissions;
use crate::tools::hook_names::HookToolName;
use codex_protocol::protocol::GranularApprovalConfig;
use codex_protocol::protocol::NetworkAccess;
use pretty_assertions::assert_eq;
use serde_json::json;
#[test]
fn bash_permission_request_payload_omits_missing_description() {
assert_eq!(
PermissionRequestPayload::bash("echo hi".to_string(), /*description*/ None),
PermissionRequestPayload {
tool_name: HookToolName::bash(),
tool_input: json!({ "command": "echo hi" }),
}
);
}
#[test]
fn bash_permission_request_payload_includes_description_when_present() {
assert_eq!(
PermissionRequestPayload::bash(
"echo hi".to_string(),
Some("network-access example.com".to_string()),
),
PermissionRequestPayload {
tool_name: HookToolName::bash(),
tool_input: json!({
"command": "echo hi",
"description": "network-access example.com",
}),
}
);
}
#[test]
fn external_sandbox_skips_exec_approval_on_request() {