mirror of
https://github.com/openai/codex.git
synced 2026-06-01 19:02:59 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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."),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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!({}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user