mirror of
https://github.com/openai/codex.git
synced 2026-11-15 21:50:46 +00:00
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:
@@ -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(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user