mirror of
https://github.com/openai/codex.git
synced 2026-10-29 19:08:43 +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:
@@ -9,8 +9,12 @@ use codex_utils_path_uri::PathUri;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub(crate) enum GuardianApprovalRequest {
|
||||
NativeToolCall {
|
||||
id: String,
|
||||
call: Box<codex_protocol::models::ResponseItem>,
|
||||
},
|
||||
ExecCommand {
|
||||
id: String,
|
||||
environment_id: String,
|
||||
@@ -73,6 +77,16 @@ pub(crate) enum GuardianApprovalRequest {
|
||||
},
|
||||
}
|
||||
|
||||
// Approval arguments may contain credentials or encrypted tool messages.
|
||||
impl std::fmt::Debug for GuardianApprovalRequest {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("GuardianApprovalRequest")
|
||||
.field("kind", &std::mem::discriminant(self))
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl GuardianApprovalRequest {
|
||||
/// The environment owning this action, independent of the reviewer's default environment.
|
||||
pub(super) fn target_environment_id(&self) -> Option<&str> {
|
||||
@@ -82,7 +96,7 @@ impl GuardianApprovalRequest {
|
||||
| Self::ApplyPatch { environment_id, .. }
|
||||
| Self::RequestPermissions { environment_id, .. }
|
||||
| Self::NetworkAccess { environment_id, .. } => Some(environment_id),
|
||||
Self::McpToolCall { .. } => None,
|
||||
Self::McpToolCall { .. } | Self::NativeToolCall { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -231,6 +245,7 @@ pub(crate) fn guardian_approval_request_to_json(
|
||||
action: &GuardianApprovalRequest,
|
||||
) -> serde_json::Result<Value> {
|
||||
let mut value = match action {
|
||||
GuardianApprovalRequest::NativeToolCall { call, .. } => serde_json::to_value(call.as_ref()),
|
||||
GuardianApprovalRequest::ExecCommand {
|
||||
id: _,
|
||||
environment_id: _,
|
||||
@@ -348,8 +363,9 @@ pub(crate) fn guardian_approval_request_to_json(
|
||||
|
||||
pub(crate) fn guardian_assessment_action(
|
||||
action: &GuardianApprovalRequest,
|
||||
) -> GuardianAssessmentAction {
|
||||
match action {
|
||||
) -> Option<GuardianAssessmentAction> {
|
||||
Some(match action {
|
||||
GuardianApprovalRequest::NativeToolCall { .. } => return None,
|
||||
GuardianApprovalRequest::ExecCommand {
|
||||
command,
|
||||
guardian_cwd,
|
||||
@@ -410,13 +426,14 @@ pub(crate) fn guardian_assessment_action(
|
||||
reason: reason.clone(),
|
||||
permissions: permissions.clone(),
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn guardian_reviewed_action(
|
||||
request: &GuardianApprovalRequest,
|
||||
) -> GuardianReviewedAction {
|
||||
match request {
|
||||
GuardianApprovalRequest::NativeToolCall { .. } => GuardianReviewedAction::NativeToolCall {},
|
||||
GuardianApprovalRequest::ExecCommand {
|
||||
sandbox_permissions,
|
||||
additional_permissions,
|
||||
@@ -463,7 +480,8 @@ pub(crate) fn guardian_request_target_item_id(request: &GuardianApprovalRequest)
|
||||
| GuardianApprovalRequest::WriteStdin { id, .. }
|
||||
| GuardianApprovalRequest::ApplyPatch { id, .. }
|
||||
| GuardianApprovalRequest::McpToolCall { id, .. }
|
||||
| GuardianApprovalRequest::RequestPermissions { id, .. } => Some(id),
|
||||
| GuardianApprovalRequest::RequestPermissions { id, .. }
|
||||
| GuardianApprovalRequest::NativeToolCall { id, .. } => Some(id),
|
||||
GuardianApprovalRequest::NetworkAccess { .. } => None,
|
||||
}
|
||||
}
|
||||
@@ -478,7 +496,8 @@ pub(crate) fn guardian_request_turn_id<'a>(
|
||||
GuardianApprovalRequest::ExecCommand { .. }
|
||||
| GuardianApprovalRequest::WriteStdin { .. }
|
||||
| GuardianApprovalRequest::ApplyPatch { .. }
|
||||
| GuardianApprovalRequest::McpToolCall { .. } => default_turn_id,
|
||||
| GuardianApprovalRequest::McpToolCall { .. }
|
||||
| GuardianApprovalRequest::NativeToolCall { .. } => default_turn_id,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ impl GuardianApprovalRequest {
|
||||
Self::McpToolCall { server, .. } => GuardianScope::for_mcp_server(server),
|
||||
Self::NetworkAccess { .. } => GuardianScope::Network,
|
||||
Self::RequestPermissions { .. } => GuardianScope::Permissions,
|
||||
Self::NativeToolCall { .. } => GuardianScope::NativeTool,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ pub(crate) fn decide_approval(
|
||||
| GuardianApprovalRequest::WriteStdin { .. }
|
||||
| GuardianApprovalRequest::ApplyPatch { .. }
|
||||
| GuardianApprovalRequest::McpToolCall { .. }
|
||||
| GuardianApprovalRequest::NativeToolCall { .. }
|
||||
| GuardianApprovalRequest::RequestPermissions { .. },
|
||||
)
|
||||
| Err(_) => request.tool_call_id.as_deref(),
|
||||
|
||||
@@ -91,7 +91,7 @@ pub(crate) async fn check_pending(session: &Session, turn: &TurnContext) -> Code
|
||||
.for_prompt_annotated(&turn.model_info().input_modalities);
|
||||
context.retain_new_instructions(&history);
|
||||
context
|
||||
.retain_new_agent_messages(&history)
|
||||
.retain_new_native_messages(&history)
|
||||
.map_err(|error| CodexErr::InvalidRequest(error.to_string()))?;
|
||||
if context.estimated_tokens().saturating_add(minimum_prefix) > maximum {
|
||||
context
|
||||
@@ -156,7 +156,7 @@ pub(crate) async fn finalize(
|
||||
// compaction retry; evidence removed by compaction must be delivered again.
|
||||
context.retain_new_instructions(&history);
|
||||
context
|
||||
.retain_new_agent_messages(&history)
|
||||
.retain_new_native_messages(&history)
|
||||
.map_err(|error| CodexErr::InvalidRequest(error.to_string()))?;
|
||||
let prompt = build_prompt(
|
||||
history
|
||||
@@ -285,14 +285,14 @@ pub(crate) async fn finalize(
|
||||
}
|
||||
}
|
||||
Err(codex_guardian_context::SectionError::UnsupportedDelivery {
|
||||
section: "conversation_transcript",
|
||||
section: "conversation_transcript" | "planned_action",
|
||||
}) => {
|
||||
*input = context
|
||||
.into_annotated_messages()
|
||||
.into_iter()
|
||||
.map(|mut envelope| {
|
||||
// Preserve the ordinary user-content annotations on each text segment;
|
||||
// native messages retain the original author, recipient and ciphertext.
|
||||
// native evidence retains its original identity and ciphertext.
|
||||
if let ResponseItem::Message { role, content, .. } = &envelope.item
|
||||
&& role == "user"
|
||||
{
|
||||
|
||||
@@ -144,8 +144,7 @@ async fn finalization_exports_loss_totals_only_for_accepted_input(window: i64) {
|
||||
fn required_context(text: String) -> ComposedContext {
|
||||
let action = PlannedAction {
|
||||
tool_descriptions: None,
|
||||
json: text,
|
||||
kind: PlannedActionKind::Command,
|
||||
kind: PlannedActionKind::Command { json: text },
|
||||
reason: None,
|
||||
};
|
||||
let context = super::super::prompt::collect_guardian_context(
|
||||
|
||||
@@ -118,9 +118,7 @@ pub(crate) async fn build_guardian_prompt_items_with_parent_turn(
|
||||
.retained_context()
|
||||
.map(codex_guardian_context::collect_verified_answer_candidates)
|
||||
.unwrap_or_default();
|
||||
let planned_action_json = format_guardian_action_pretty(&request)?;
|
||||
let planned_action = PlannedAction {
|
||||
json: planned_action_json,
|
||||
tool_descriptions: if let GuardianApprovalRequest::McpToolCall {
|
||||
tool_description,
|
||||
connector_description,
|
||||
@@ -135,14 +133,22 @@ pub(crate) async fn build_guardian_prompt_items_with_parent_turn(
|
||||
None
|
||||
},
|
||||
kind: match &request {
|
||||
GuardianApprovalRequest::NativeToolCall { call, .. } => {
|
||||
PlannedActionKind::ToolCall(call.clone())
|
||||
}
|
||||
GuardianApprovalRequest::NetworkAccess { trigger, .. } => PlannedActionKind::Network {
|
||||
json: format_guardian_action_pretty(&request)?,
|
||||
has_trigger: trigger.is_some(),
|
||||
},
|
||||
GuardianApprovalRequest::WriteStdin { .. } => PlannedActionKind::TerminalInput,
|
||||
GuardianApprovalRequest::WriteStdin { .. } => PlannedActionKind::TerminalInput {
|
||||
json: format_guardian_action_pretty(&request)?,
|
||||
},
|
||||
GuardianApprovalRequest::ExecCommand { .. }
|
||||
| GuardianApprovalRequest::ApplyPatch { .. }
|
||||
| GuardianApprovalRequest::McpToolCall { .. }
|
||||
| GuardianApprovalRequest::RequestPermissions { .. } => PlannedActionKind::Command,
|
||||
| GuardianApprovalRequest::RequestPermissions { .. } => PlannedActionKind::Command {
|
||||
json: format_guardian_action_pretty(&request)?,
|
||||
},
|
||||
},
|
||||
reason: reasons.retry.or(reasons.approval),
|
||||
};
|
||||
|
||||
@@ -650,10 +650,10 @@ async fn run_review_on_session(
|
||||
let items = match prompt_items.context.clone().into_user_inputs() {
|
||||
Ok(items) => items,
|
||||
Err(codex_guardian_context::SectionError::UnsupportedDelivery {
|
||||
section: "conversation_transcript",
|
||||
section: "conversation_transcript" | "planned_action",
|
||||
}) => {
|
||||
// Final admission replaces this turn-start marker with the complete,
|
||||
// budgeted context, including native encrypted agent-message evidence.
|
||||
// budgeted context, including native encrypted messages and tool calls.
|
||||
vec![codex_protocol::user_input::UserInput::Text {
|
||||
text: super::prompt::GUARDIAN_TRANSCRIPT_START.to_owned(),
|
||||
text_elements: Vec::new(),
|
||||
|
||||
@@ -32,7 +32,8 @@ impl From<GuardianApprovalRequest> for ReviewAction {
|
||||
| GuardianApprovalRequest::ApplyPatch { .. }
|
||||
| GuardianApprovalRequest::NetworkAccess { .. }
|
||||
| GuardianApprovalRequest::McpToolCall { .. }
|
||||
| GuardianApprovalRequest::RequestPermissions { .. } => {
|
||||
| GuardianApprovalRequest::RequestPermissions { .. }
|
||||
| GuardianApprovalRequest::NativeToolCall { .. } => {
|
||||
super::approval_request::guardian_request_target_item_id(&request)
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
@@ -1543,7 +1543,7 @@ fn guardian_write_stdin_preserves_input_and_foreign_cwd(
|
||||
}
|
||||
assert_eq!(guardian_approval_request_to_json(&action)?, expected);
|
||||
assert_eq!(
|
||||
guardian_assessment_action(&action),
|
||||
guardian_assessment_action(&action).expect("reported approval action"),
|
||||
GuardianAssessmentAction::WriteStdin {
|
||||
approval_id: "terminal-write".to_string(),
|
||||
process_id: "1000".to_string(),
|
||||
@@ -1599,7 +1599,7 @@ fn guardian_exec_command_uses_executor_cwd_convention(
|
||||
}),
|
||||
);
|
||||
assert_eq!(
|
||||
guardian_assessment_action(&action),
|
||||
guardian_assessment_action(&action).expect("reported approval action"),
|
||||
GuardianAssessmentAction::Command {
|
||||
source: codex_protocol::approvals::GuardianCommandSource::UnifiedExec,
|
||||
command: "git status".to_string(),
|
||||
@@ -3314,30 +3314,68 @@ async fn guardian_review_retries_two_parse_failures_then_approves() -> anyhow::R
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let (session, turn) = guardian_test_session_and_turn(&server).await;
|
||||
let (session, mut turn) = guardian_test_session_and_turn(&server).await;
|
||||
let config = Arc::make_mut(&mut Arc::get_mut(&mut turn).unwrap().config);
|
||||
config.approvals_reviewer = ApprovalsReviewer::AutoReview;
|
||||
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
seed_guardian_parent_history(&session, &turn).await;
|
||||
|
||||
let (outcome, metadata) = run_guardian_review_session_for_test(
|
||||
Arc::clone(&session),
|
||||
Arc::clone(&turn),
|
||||
guardian_exec_command_request("shell-parse-retry"),
|
||||
ApprovalRequestReasons::default(),
|
||||
/*external_cancel*/ None,
|
||||
/*max_attempts*/ 3,
|
||||
)
|
||||
.await;
|
||||
|
||||
let GuardianReviewOutcome::Completed(assessment) = outcome else {
|
||||
panic!("expected guardian assessment");
|
||||
let original = serde_json::json!({
|
||||
"type": "function_call", "id": "fc_action", "call_id": "action",
|
||||
"namespace": "team", "name": "send_message",
|
||||
"arguments": r#"{"target":"worker","message":"opaque-message"}"#,
|
||||
"encrypted_content": "opaque-replay", "encrypted_function_args": ["message"],
|
||||
});
|
||||
session
|
||||
.record_conversation_items(
|
||||
&turn,
|
||||
turn.model_info(),
|
||||
&[serde_json::from_value(original.clone())?],
|
||||
)
|
||||
.await;
|
||||
let mut expected = original;
|
||||
expected
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("encrypted_content");
|
||||
expected["arguments"] = serde_json::json!(r#"{"target":"/root","message":"opaque-message"}"#);
|
||||
let invocation = crate::tools::context::ToolInvocation {
|
||||
session: Arc::clone(&session),
|
||||
step_context: crate::session::step_context::StepContext::for_test(Arc::clone(&turn)),
|
||||
turn,
|
||||
cancellation_token: CancellationToken::new(),
|
||||
tracker: Arc::new(tokio::sync::Mutex::new(
|
||||
crate::turn_diff_tracker::TurnDiffTracker::new(),
|
||||
)),
|
||||
call_id: "action".to_owned(),
|
||||
tool_name: codex_tools::ToolName::namespaced("team", "send_message"),
|
||||
source: crate::tools::context::ToolCallSource::Direct,
|
||||
environments: Default::default(),
|
||||
payload: crate::tools::context::ToolPayload::Function {
|
||||
arguments: expected["arguments"].as_str().unwrap().to_owned(),
|
||||
},
|
||||
};
|
||||
assert_eq!(assessment.outcome, GuardianAssessmentOutcome::Allow);
|
||||
assert_eq!(assessment.rationale, "retry succeeded");
|
||||
assert_eq!(metadata.attempt_count, 3);
|
||||
assert!(matches!(
|
||||
metadata.guardian_session_kind,
|
||||
Some(codex_analytics::GuardianReviewSessionKind::TrunkReused)
|
||||
));
|
||||
assert_eq!(request_log.requests().len(), 3);
|
||||
crate::tools::review_native_tool_call(&invocation).await?;
|
||||
let requests = request_log.requests();
|
||||
assert_eq!(requests.len(), 3);
|
||||
let reviewer = requests[0].body_json()["client_metadata"]["thread_id"].clone();
|
||||
for request in &requests {
|
||||
assert_eq!(
|
||||
request.body_json()["client_metadata"]["thread_id"],
|
||||
reviewer
|
||||
);
|
||||
let calls = request
|
||||
.inputs_of_type("function_call")
|
||||
.into_iter()
|
||||
.map(|mut item| {
|
||||
item.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("internal_chat_message_metadata_passthrough");
|
||||
item
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(calls, vec![expected.clone()]);
|
||||
assert_eq!(request.inputs_of_type("function_call_output").len(), 1);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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