Route MCP elicitations through the shared approval decision path (#43447)

## What changed

- Use `decide_approval` for MCP elicitations, carrying the effective approval policy, reviewer, and synchronous-review requirement into the decision.
- Preserve unsupported form and URL elicitations for user review.
- Use `computer_use_review_required()` for review evidence, session policy, and turn metadata so model policy takes precedence over the legacy flag.
- Derive strict-review notifications from each assessment's review reason.

## Testing

Add coverage for model policy precedence, user and full-access modes, sensitive elicitations, independent code-mode and nested-tool policies, cached score reuse, and user review of unsupported elicitations.

GitOrigin-RevId: d0a9c40572dca7b3dbf876230d2162b72728084d
This commit is contained in:
jif
2026-09-07 12:04:27 +00:00
committed by copyberry
parent c0b6285711
commit ce5c4133bd
12 changed files with 585 additions and 129 deletions
@@ -32,7 +32,7 @@ pub enum NodeReplReviewEvidenceMode {
pub(crate) fn node_repl_review_evidence_mode(turn: &TurnContext) -> NodeReplReviewEvidenceMode {
let features = &turn.config.features;
if turn.model_info().node_repl_auto_review_required
if turn.model_info().computer_use_review_required()
|| features.enabled(Feature::GuardianEnhancedNodeReplTranscripts)
&& features.enabled(Feature::GuardianNodeReplTranscriptImages)
{
+1
View File
@@ -48,6 +48,7 @@ pub(crate) use review::new_guardian_review_id;
pub(crate) use review::record_guardian_denial_for_test;
#[cfg(test)]
pub(crate) use review::review_approval_request;
#[cfg(test)]
pub(crate) use review::review_approval_request_with_cancel;
pub(crate) use review::routes_approval_policy_to_guardian;
pub(crate) use review::routes_approval_to_guardian;
+7 -1
View File
@@ -5,9 +5,12 @@ use codex_analytics::GuardianReviewFailureReason;
use codex_analytics::GuardianReviewTerminalStatus;
use codex_analytics::GuardianReviewTrackContext;
use codex_analytics::GuardianReviewedAction;
#[cfg(test)]
use codex_analytics::GuardianV2Event;
#[cfg(test)]
use codex_analytics::GuardianV2EventKind;
use codex_core_plugins::PluginCommandAttribution;
#[cfg(test)]
use codex_extension_api::GuardianV2Enabled;
use codex_extension_api::ThreadIdleCause;
use codex_features::Feature;
@@ -53,6 +56,7 @@ use super::GuardianAssessmentOutcome;
use super::GuardianRejectionCircuitBreakerAction;
use super::GuardianRejectionCircuitBreakerPolicy;
use super::GuardianReviewContext;
#[cfg(test)]
use super::approval_request::format_guardian_action_compact;
use super::approval_request::format_guardian_action_pretty;
use super::approval_request::guardian_assessment_action;
@@ -340,6 +344,7 @@ pub(crate) async fn record_guardian_denial_for_test(
/// Runs Guardian unless Full Access or an installed extension resolves the review.
/// Guardian timeouts, review-session failures, and parse failures all block
/// execution, with timeouts surfaced separately from explicit denials.
#[cfg(test)]
async fn run_guardian_review(
session: Arc<Session>,
context: GuardianReviewContext,
@@ -927,6 +932,7 @@ pub(crate) async fn review_approval_request(
review.await
}
#[cfg(test)]
pub(crate) async fn review_approval_request_with_cancel(
session: &Arc<Session>,
context: impl Into<GuardianReviewContext>,
@@ -1038,7 +1044,7 @@ pub(super) async fn guardian_review_session_config(
guardian_reasoning_effort.clone(),
guardian_model_info.model_messages.as_ref(),
)?;
if turn.model_info().node_repl_auto_review_required {
if turn.model_info().computer_use_review_required() {
spawn_config
.features
.enable(Feature::RetainClientDeveloperMessages)
+3 -3
View File
@@ -500,7 +500,7 @@ impl GuardianReviewSessionManager {
parent_context
.turn()
.model_info()
.node_repl_auto_review_required,
.computer_use_review_required(),
)
.with_node_repl_policy(&session_config.node_repl_policy);
reuse_key.root_authorization_version = root_authorization_version;
@@ -613,7 +613,7 @@ impl GuardianReviewSessionManager {
.parent_context
.turn()
.model_info()
.node_repl_auto_review_required,
.computer_use_review_required(),
)
.with_node_repl_policy(&params.node_repl_policy);
next_reuse_key.root_authorization_version = root_authorization_version;
@@ -1424,7 +1424,7 @@ async fn ensure_guardian_node_repl_policy(
.parent_context
.turn()
.model_info()
.node_repl_auto_review_required
.computer_use_review_required()
|| !matches!(
&params.request,
GuardianApprovalRequest::McpToolCall { server, tool_name, .. }
+3 -3
View File
@@ -17,9 +17,9 @@ use crate::session::session::Session;
/// This lets the extension return AskUser before invoking that renderer.
#[derive(Clone)]
pub(crate) struct ReviewAction {
pub(super) action: Result<serde_json::Value, String>,
pub(super) category: codex_protocol::openai_models::GuardianScope,
pub(super) request: Result<GuardianApprovalRequest, String>,
pub(crate) action: Result<serde_json::Value, String>,
pub(crate) category: codex_protocol::openai_models::GuardianScope,
pub(crate) request: Result<GuardianApprovalRequest, String>,
}
impl From<GuardianApprovalRequest> for ReviewAction {
+95 -101
View File
@@ -807,13 +807,15 @@ async fn review_guardian_mcp_elicitation(
Some(Value::Bool(true))
);
if matches!(
let mut review_context = crate::guardian::GuardianReviewContext::from(&turn_context);
let strict_auto_review = matches!(
request
.elicitation
.meta()
.and_then(|meta| meta.get(MCP_ELICITATION_STRICT_AUTO_REVIEW_KEY)),
Some(Value::Bool(true))
) {
);
let guardian_request = if strict_auto_review {
let connector_id = elicitation_connector_id(&request.elicitation);
let trusted_guardian_request = if request.server_name == CODEX_APPS_MCP_SERVER_NAME {
let Some(call_id) = request
@@ -883,109 +885,96 @@ async fn review_guardian_mcp_elicitation(
else {
return Ok(None);
};
let guardian_request = trusted_guardian_request.unwrap_or(*guardian_request);
let decision = crate::guardian::review_approval_request_with_cancel(
&session,
&turn_context,
crate::guardian::new_guardian_review_id(),
guardian_request,
/*retry_reason*/ None,
crate::guardian::GuardianReviewOptions {
require_guardian: false,
plugin_attribution_override: None,
approval_request_source: codex_analytics::GuardianApprovalRequestSource::MainTurn,
external_cancel: Some(cancellation_token),
require_synchronous_review,
},
)
.await;
return Ok(matches!(
decision,
ReviewDecision::Approved
| ReviewDecision::Denied { .. }
| ReviewDecision::TimedOut
| ReviewDecision::Abort
)
.then(|| {
mcp_elicitation_response_from_guardian_decision(decision, turn_context.model_info())
}));
}
let approval_policy = mcp_config.approval_policy.value();
match approval_policy {
AskForApproval::Never => {
let Some(permission_profile) =
mcp_config.permission_profile_for_server(&request.server_name)
else {
trusted_guardian_request.unwrap_or(*guardian_request).into()
} else {
let approval_policy = mcp_config.approval_policy.value();
match approval_policy {
AskForApproval::Never => {
let Some(permission_profile) =
mcp_config.permission_profile_for_server(&request.server_name)
else {
return Ok(Some(mcp_elicitation_decline_without_message()));
};
if codex_mcp::mcp_permission_prompt_is_auto_approved(
approval_policy,
permission_profile,
codex_mcp::McpPermissionPromptAutoApproveContext::default(),
) && matches!(
&request.elicitation,
Elicitation::Mcp(
rmcp::model::ElicitRequestParams::FormElicitationParams {
requested_schema,
..
}
) if requested_schema.properties.is_empty()
) {
return Ok(Some(ElicitationResponse {
action: ElicitationAction::Accept,
content: Some(serde_json::json!({})),
meta: None,
}));
}
return Ok(Some(mcp_elicitation_decline_without_message()));
};
if codex_mcp::mcp_permission_prompt_is_auto_approved(
approval_policy,
permission_profile,
codex_mcp::McpPermissionPromptAutoApproveContext::default(),
) && matches!(
&request.elicitation,
Elicitation::Mcp(
rmcp::model::ElicitRequestParams::FormElicitationParams {
requested_schema,
..
}
) if requested_schema.properties.is_empty()
) {
return Ok(Some(ElicitationResponse {
action: ElicitationAction::Accept,
content: Some(serde_json::json!({})),
meta: None,
}));
}
return Ok(Some(mcp_elicitation_decline_without_message()));
AskForApproval::Granular(config) if !config.allows_mcp_elicitations() => {
return Ok(Some(mcp_elicitation_decline_without_message()));
}
AskForApproval::OnRequest
| AskForApproval::UnlessTrusted
| AskForApproval::Granular(_) => {}
}
AskForApproval::Granular(config) if !config.allows_mcp_elicitations() => {
return Ok(Some(mcp_elicitation_decline_without_message()));
}
AskForApproval::OnRequest | AskForApproval::UnlessTrusted | AskForApproval::Granular(_) => {
}
}
let approvals_reviewer = crate::connectors::mcp_approvals_reviewer_from_layers(
&mcp_config.config_layer_stack,
step_settings
.mcp_approvals_reviewer_override
.unwrap_or(mcp_config.approvals_reviewer),
Some(turn_context.model_info().slug.as_str()),
request.server_name.as_str(),
elicitation_connector_id(&request.elicitation),
/*link_id*/ None,
);
if !crate::guardian::routes_approval_policy_to_guardian(approval_policy, approvals_reviewer) {
return Ok(None);
}
let guardian_request = match guardian_elicitation_review_request(&request, originating_call_id)
{
GuardianElicitationReview::NotRequested => return Ok(None),
GuardianElicitationReview::Decline(reason) => {
warn!(
server_name = %request.server_name,
request_id = %mcp_elicitation_request_id(&request.request_id),
reason,
"declining Guardian MCP elicitation before review"
);
return Ok(Some(mcp_elicitation_decline_without_message()));
let approvals_reviewer = crate::connectors::mcp_approvals_reviewer_from_layers(
&mcp_config.config_layer_stack,
step_settings
.mcp_approvals_reviewer_override
.unwrap_or(mcp_config.approvals_reviewer),
Some(turn_context.model_info().slug.as_str()),
request.server_name.as_str(),
elicitation_connector_id(&request.elicitation),
/*link_id*/ None,
);
review_context.approval_policy = approval_policy;
review_context.approvals_reviewer = approvals_reviewer;
match guardian_elicitation_review_request(&request, originating_call_id) {
GuardianElicitationReview::NotRequested => return Ok(None),
GuardianElicitationReview::Decline(reason) => {
let Elicitation::Mcp(elicitation) = &request.elicitation else {
return Ok(None);
};
crate::guardian::ReviewAction {
action: serde_json::to_value(elicitation)
.map(|elicitation| {
serde_json::json!({
"tool": "mcp_elicitation",
"server": request.server_name,
"request": elicitation,
})
})
.map_err(|error| error.to_string()),
category: codex_protocol::openai_models::GuardianScope::for_mcp_server(
&request.server_name,
),
request: Err(reason.to_owned()),
}
}
GuardianElicitationReview::ApprovalRequest(guardian_request) => {
(*guardian_request).into()
}
}
GuardianElicitationReview::ApprovalRequest(guardian_request) => *guardian_request,
};
let review_id = crate::guardian::new_guardian_review_id();
let decision = crate::guardian::review_approval_request_with_cancel(
&session,
&turn_context,
review_id,
let declined_reason = guardian_request.request.as_ref().err().cloned();
let decision = crate::guardian::decide_approval(
session,
review_context,
crate::guardian::new_guardian_review_id(),
guardian_request,
/*retry_reason*/ None,
crate::tools::sandboxing::ApprovalRequestReasons {
approval: None,
retry: None,
},
crate::guardian::GuardianReviewOptions {
require_guardian: false,
require_guardian: strict_auto_review,
plugin_attribution_override: None,
approval_request_source: codex_analytics::GuardianApprovalRequestSource::MainTurn,
external_cancel: Some(cancellation_token),
@@ -993,10 +982,15 @@ async fn review_guardian_mcp_elicitation(
},
)
.await;
Ok(Some(mcp_elicitation_response_from_guardian_decision(
decision,
turn_context.model_info(),
)))
Ok(decision.map(|decision| {
if let Some(reason) = declined_reason {
warn!(server_name = %request.server_name,
request_id = %mcp_elicitation_request_id(&request.request_id), reason,
"declining Guardian MCP elicitation before review");
return mcp_elicitation_decline_without_message();
}
mcp_elicitation_response_from_guardian_decision(decision, turn_context.model_info())
}))
}
fn guardian_elicitation_review_request(
+1 -1
View File
@@ -218,7 +218,7 @@ impl TurnMetadataState {
turn_id,
sandbox_tags,
auto_review_enabled,
node_repl_auto_review_required: model_info.node_repl_auto_review_required,
node_repl_auto_review_required: model_info.computer_use_review_required(),
node_repl_disabled: model_info.node_repl_disabled,
enriched_workspaces: RwLock::new(None),
tool_namespaces_info: RwLock::new(None),