Add opt-in synchronous Guardian review for native tool calls (#57433)

## What changed

- Add `CoreToolRuntime::requires_guardian_review` to review opted-in function calls before dispatch and stop execution when review rejects the call.
- Deliver proposed calls as native review evidence, preserving encrypted argument metadata. When hooks rewrite arguments, review the updated arguments and discard the original encrypted replay content.
- Deduplicate native call evidence against reviewer history and include paired evidence outputs in input budgets.
- Keep review accounting and metrics for native calls without emitting action-specific client assessment events, and omit approval payloads from debug output.

## Testing

Extend regression coverage for rewritten native calls across review retries, denied calls that never execute their handlers, oversized native evidence, and review metrics without client events.

GitOrigin-RevId: c3b22c0220344581c2b38f233001abaed49cba34
This commit is contained in:
jif
2026-10-27 11:37:24 +00:00
committed by copyberry
parent b6ad8ea1fc
commit ed1b92fa45
34 changed files with 669 additions and 184 deletions
+2
View File
@@ -11,6 +11,8 @@ pub(crate) mod hosted_spec;
pub(crate) mod lifecycle;
pub(crate) mod metadata_metrics;
mod multi_agent_tool;
mod native_tool_review;
pub(crate) use native_tool_review::review as review_native_tool_call;
pub(crate) mod network_approval;
pub(crate) mod orchestrator;
pub(crate) mod parallel;
@@ -0,0 +1,113 @@
//! Reviews opted-in function calls synchronously before dispatch, preserving their native wire data.
use crate::function_tool::FunctionCallError;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::GuardianReviewContext;
use crate::guardian::GuardianReviewOptions;
use crate::guardian::new_guardian_review_id;
use crate::guardian::spawn_approval_decision;
use crate::tools::context::ToolCallSource;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::sandboxing::ApprovalRequestReasons;
use codex_analytics::GuardianApprovalRequestSource;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::ReviewDecision;
pub(crate) async fn review(invocation: &ToolInvocation) -> Result<(), FunctionCallError> {
let ToolPayload::Function { arguments } = &invocation.payload else {
return Err(FunctionCallError::RespondToModel(
"Native review requires a function call".to_owned(),
));
};
let mut item = invocation
.session
.clone_history()
.await
.raw_items()
.rev()
.find(|item| {
matches!(
item,
ResponseItem::FunctionCall { call_id, .. } if call_id == &invocation.call_id
)
})
.cloned()
.unwrap_or_else(|| ResponseItem::FunctionCall {
id: None,
name: invocation.tool_name.name.clone(),
namespace: invocation.tool_name.namespace.clone(),
arguments: arguments.clone(),
call_id: invocation.call_id.clone(),
encrypted_content: None,
status: None,
encrypted_function_args: matches!(
invocation.source,
ToolCallSource::DirectPlaintextMessage | ToolCallSource::CodeMode { .. }
)
.then(Vec::new),
internal_chat_message_metadata_passthrough: None,
});
if let ResponseItem::FunctionCall {
arguments: reviewed_arguments,
encrypted_content,
..
} = &mut item
&& reviewed_arguments != arguments
{
// A hook rewrite invalidates replay of the original call, but not encrypted argument fields.
reviewed_arguments.clone_from(arguments);
*encrypted_content = None;
}
let context = GuardianReviewContext::from(&invocation.step_context);
// Native tool review has no user-approval fallback. An unsuccessful required review must stop dispatch.
let require_guardian = codex_guardian_reviewer::routes_approval_policy_to_guardian(
context.approval_policy,
context.approvals_reviewer,
);
let decision = spawn_approval_decision(
invocation.session.clone(),
context,
new_guardian_review_id(),
GuardianApprovalRequest::NativeToolCall {
id: invocation.call_id.clone(),
call: Box::new(item),
},
ApprovalRequestReasons::default(),
GuardianReviewOptions {
require_guardian,
require_synchronous_review: true,
plugin_attribution_override: None,
approval_request_source: GuardianApprovalRequestSource::MainTurn,
external_cancel: Some(invocation.cancellation_token.clone()),
},
)
.await
.map_err(|_| {
FunctionCallError::RespondToModel("automatic approval review did not complete".to_owned())
})?;
match decision {
None | Some(ReviewDecision::Approved | ReviewDecision::ApprovedForSession) => Ok(()),
Some(ReviewDecision::Denied { rejection }) => {
Err(FunctionCallError::RespondToModel(rejection))
}
Some(ReviewDecision::TimedOut) => Err(FunctionCallError::RespondToModel(
codex_prompts::ResolvedModelMessages::from_model(
&invocation.step_context.settings.model_info,
)
.auto_review()
.timeout_instructions
.to_owned(),
)),
Some(ReviewDecision::Abort) => Err(FunctionCallError::RespondToModel(
"automatic approval review was cancelled".to_owned(),
)),
Some(
ReviewDecision::ApprovedExecpolicyAmendment { .. }
| ReviewDecision::ApprovedMcpPolicyAmendment
| ReviewDecision::NetworkPolicyAmendment { .. },
) => Err(FunctionCallError::RespondToModel(
"automatic approval review returned an unsupported decision".to_owned(),
)),
}
}
+11
View File
@@ -62,6 +62,11 @@ pub use codex_tools::ToolExposure;
/// Implementers provide the shared `ToolExecutor` behavior plus optional
/// core-owned metadata for hooks, telemetry, tool search, and argument diffs.
pub(crate) trait CoreToolRuntime: ToolExecutor<ToolInvocation> {
/// Opts this invocation into synchronous native-call review before the handler runs.
fn requires_guardian_review(&self, _invocation: &ToolInvocation) -> bool {
false
}
/// The handler observes its cancellation token and must finish before dispatch is aborted.
fn finishes_on_cancellation(&self) -> bool {
false
@@ -869,6 +874,12 @@ impl ToolRegistry {
}
}
if tool.requires_guardian_review(&invocation)
&& let Err(err) = super::review_native_tool_call(&invocation).await
{
return Err(fail_before_execution(&invocation, err).await);
}
if tool.mcp_server_name().is_none() {
notify_tool_start(&invocation, /*mcp_tool*/ None)
.or_cancel_tool(&cancellation_token)
+44 -3
View File
@@ -120,7 +120,25 @@ impl LifecycleTestHandler {
}
}
impl CoreToolRuntime for LifecycleTestHandler {}
impl CoreToolRuntime for LifecycleTestHandler {
fn requires_guardian_review(&self, _invocation: &ToolInvocation) -> bool {
self.tool_name.name == "reviewed_tool"
}
}
impl codex_extension_api::ApprovalReviewContributor for LifecycleTestHandler {
fn decide<'a>(
&'a self,
_input: &'a codex_extension_api::ApprovalDecisionInput<'_>,
) -> codex_extension_api::ExtensionFuture<'a, Option<codex_extension_api::ApprovalDecision>>
{
Box::pin(async {
Some(codex_extension_api::ApprovalDecision::Reviewed(
codex_protocol::protocol::ReviewDecision::denied("review denied"),
))
})
}
}
fn test_spec(tool_name: &codex_tools::ToolName) -> codex_tools::ToolSpec {
codex_tools::ToolSpec::Function(codex_tools::ResponsesApiTool {
@@ -742,7 +760,6 @@ async fn dispatch_uses_canonical_tool_names_for_lifecycle_contributors() -> anyh
builder.tool_lifecycle_contributor(Arc::new(ToolLifecycleRecorder {
records: Arc::clone(&records),
}));
session.services.extensions = Arc::new(builder.build());
let ok_tool = codex_tools::ToolName::plain("ok_tool");
let failing_tool = codex_tools::ToolName::namespaced("extensions", "failing_tool");
@@ -754,7 +771,14 @@ async fn dispatch_uses_canonical_tool_names_for_lifecycle_contributors() -> anyh
tool_name: failing_tool.clone(),
result: LifecycleTestResult::Err,
}) as Arc<dyn CoreToolRuntime>;
let registry = ToolRegistry::from_tools([ok_handler, failing_handler]);
let reviewed_tool = codex_tools::ToolName::namespaced("extensions", "reviewed_tool");
let reviewed_handler = Arc::new(LifecycleTestHandler {
tool_name: reviewed_tool.clone(),
result: LifecycleTestResult::Err,
});
builder.approval_review_contributor(reviewed_handler.clone());
session.services.extensions = Arc::new(builder.build());
let registry = ToolRegistry::from_tools([ok_handler, failing_handler, reviewed_handler]);
let session = Arc::new(session);
let turn = Arc::new(turn);
@@ -785,6 +809,16 @@ async fn dispatch_uses_canonical_tool_names_for_lifecycle_contributors() -> anyh
Err(err) => err,
};
assert_eq!(err.to_string(), "handler failed");
let denied = registry
.dispatch_any_with_state(
test_invocation(session, turn, "reviewed-call", reviewed_tool.clone()),
/*call_state*/ None,
)
.await;
assert_eq!(
denied.err().expect("review must deny").to_string(),
"review denied"
);
let expected = vec![
RecordedToolLifecycle::Start {
@@ -809,6 +843,13 @@ async fn dispatch_uses_canonical_tool_names_for_lifecycle_contributors() -> anyh
handler_executed: true,
},
},
RecordedToolLifecycle::Finish {
call_id: "reviewed-call".to_string(),
tool_name: reviewed_tool,
outcome: codex_extension_api::ToolCallOutcome::Failed {
handler_executed: false,
},
},
];
let actual = records
.lock()