mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
## What changed - Recognize the `codex_strict_auto_review` MCP elicitation marker and route marked approval requests through the configured automatic reviewer. - Accept only canonical automatic-review approvals and fail closed without a user prompt when review is unavailable, denied, malformed, disallowed by policy, or requests persistence. - Validate Codex Apps approval metadata against the active MCP invocation and build the review request from the trusted invocation details. ## Testing - Add unit coverage for strict review decisions, malformed markers, reviewer failures, explicit policy denials, and lifecycle cleanup. - Add app-server round-trip coverage for approvals, denials, configuration constraints, spoofed metadata, persistent requests, and subsequent turns. GitOrigin-RevId: af73b45a9b63118f25f6429d95b3222c5dbb59fb
434 lines
16 KiB
Rust
434 lines
16 KiB
Rust
//! MCP elicitation request tracking and policy handling.
|
|
//!
|
|
//! RMCP clients call into this module when a server asks Codex to elicit data
|
|
//! from the user. It decides whether the request can be automatically accepted,
|
|
//! must be declined by policy, or should be surfaced as a Codex protocol event
|
|
//! and later resolved through the stored responder.
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use std::sync::Mutex as StdMutex;
|
|
use std::sync::atomic::AtomicBool;
|
|
use std::sync::atomic::AtomicU64;
|
|
use std::sync::atomic::Ordering;
|
|
|
|
use crate::mcp::McpPermissionPromptAutoApproveContext;
|
|
use crate::mcp::mcp_permission_prompt_is_auto_approved;
|
|
use anyhow::Context;
|
|
use anyhow::Result;
|
|
use anyhow::anyhow;
|
|
use async_channel::Sender;
|
|
use codex_protocol::approvals::ElicitationRequest;
|
|
use codex_protocol::approvals::ElicitationRequestEvent;
|
|
use codex_protocol::mcp::RequestId as ProtocolRequestId;
|
|
use codex_protocol::mcp_approval_meta::APPROVALS_REVIEWER_KEY;
|
|
use codex_protocol::mcp_approval_meta::STRICT_AUTO_REVIEW_KEY;
|
|
use codex_protocol::models::PermissionProfile;
|
|
use codex_protocol::protocol::AskForApproval;
|
|
use codex_protocol::protocol::Event;
|
|
use codex_protocol::protocol::EventMsg;
|
|
use codex_rmcp_client::Elicitation;
|
|
use codex_rmcp_client::ElicitationResponse;
|
|
use codex_rmcp_client::SendElicitation;
|
|
use futures::future::BoxFuture;
|
|
use futures::future::FutureExt;
|
|
use rmcp::model::ElicitationAction;
|
|
use rmcp::model::RequestId;
|
|
use serde_json::Value;
|
|
use tokio::sync::oneshot;
|
|
|
|
static NEXT_ELICITATION_REQUEST_ID: AtomicU64 = AtomicU64::new(0);
|
|
|
|
const STRICT_AUTO_REVIEW_DECLINE_MESSAGE: &str =
|
|
"Strict automated review failed. Do not proceed or ask the user for approval.";
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ElicitationReviewRequest {
|
|
pub server_name: String,
|
|
pub request_id: RequestId,
|
|
pub elicitation: Elicitation,
|
|
}
|
|
|
|
pub trait ElicitationReviewer: Send + Sync {
|
|
fn review(
|
|
&self,
|
|
request: ElicitationReviewRequest,
|
|
) -> BoxFuture<'static, Result<Option<ElicitationResponse>>>;
|
|
}
|
|
|
|
pub type ElicitationReviewerHandle = Arc<dyn ElicitationReviewer>;
|
|
|
|
/// Holds an owner-provided registration while an MCP elicitation is waiting for a response.
|
|
#[derive(Clone)]
|
|
pub struct ElicitationLifecycle {
|
|
register: Arc<dyn Fn() -> Box<dyn Send + Sync> + Send + Sync>,
|
|
}
|
|
|
|
impl ElicitationLifecycle {
|
|
pub fn new<T>(register: impl Fn() -> T + Send + Sync + 'static) -> Self
|
|
where
|
|
T: Send + Sync + 'static,
|
|
{
|
|
Self {
|
|
register: Arc::new(move || Box::new(register())),
|
|
}
|
|
}
|
|
|
|
fn start(&self) -> ActiveElicitation {
|
|
ActiveElicitation {
|
|
_registration: (self.register)(),
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ActiveElicitation {
|
|
_registration: Box<dyn Send + Sync>,
|
|
}
|
|
|
|
/// Routes model-visible elicitation response tokens to their exact pending responders.
|
|
///
|
|
/// One router is shared by every MCP runtime created for a thread. The public response token is
|
|
/// generated by Codex rather than copied from the MCP connection, so separate runtimes may reuse
|
|
/// the same server request ID without colliding.
|
|
#[derive(Clone, Default)]
|
|
pub(crate) struct ElicitationRequestRouter {
|
|
requests: Arc<StdMutex<ResponderMap>>,
|
|
auto_deny: Arc<AtomicBool>,
|
|
}
|
|
|
|
struct PendingElicitationRequest {
|
|
router: ElicitationRequestRouter,
|
|
key: (String, RequestId),
|
|
}
|
|
|
|
impl Drop for PendingElicitationRequest {
|
|
fn drop(&mut self) {
|
|
let responder = self
|
|
.router
|
|
.requests
|
|
.lock()
|
|
.ok()
|
|
.and_then(|mut requests| requests.remove(&self.key));
|
|
drop(responder);
|
|
}
|
|
}
|
|
|
|
impl ElicitationRequestRouter {
|
|
pub(crate) fn auto_deny(&self) -> bool {
|
|
self.auto_deny.load(Ordering::Relaxed)
|
|
}
|
|
|
|
pub(crate) fn set_auto_deny(&self, auto_deny: bool) {
|
|
self.auto_deny.store(auto_deny, Ordering::Relaxed);
|
|
}
|
|
|
|
pub(crate) async fn resolve(
|
|
&self,
|
|
server_name: String,
|
|
id: RequestId,
|
|
response: ElicitationResponse,
|
|
) -> Result<()> {
|
|
let responder = self
|
|
.requests
|
|
.lock()
|
|
.map_err(|_| anyhow!("elicitation request router unavailable"))?
|
|
.remove(&(server_name, id))
|
|
.ok_or_else(|| anyhow!("elicitation request not found"))?;
|
|
responder
|
|
.send(response)
|
|
.map_err(|e| anyhow!("failed to send elicitation response: {e:?}"))
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub(crate) struct ElicitationAuthority {
|
|
pub(crate) approval_policy: AskForApproval,
|
|
pub(crate) permission_profile: PermissionProfile,
|
|
reviewer: Option<ElicitationReviewerHandle>,
|
|
lifecycle: Option<ElicitationLifecycle>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub(crate) struct ElicitationRequestManager {
|
|
router: ElicitationRequestRouter,
|
|
pub(crate) authority: Arc<StdMutex<ElicitationAuthority>>,
|
|
}
|
|
|
|
impl ElicitationRequestManager {
|
|
pub(crate) fn new(
|
|
approval_policy: AskForApproval,
|
|
permission_profile: PermissionProfile,
|
|
reviewer: Option<ElicitationReviewerHandle>,
|
|
lifecycle: Option<ElicitationLifecycle>,
|
|
router: ElicitationRequestRouter,
|
|
) -> Self {
|
|
Self {
|
|
router,
|
|
authority: Arc::new(StdMutex::new(ElicitationAuthority {
|
|
approval_policy,
|
|
permission_profile,
|
|
reviewer,
|
|
lifecycle,
|
|
})),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn update(
|
|
&self,
|
|
approval_policy: AskForApproval,
|
|
permission_profile: PermissionProfile,
|
|
reviewer: Option<ElicitationReviewerHandle>,
|
|
lifecycle: Option<ElicitationLifecycle>,
|
|
) -> bool {
|
|
let Ok(mut authority) = self.authority.lock() else {
|
|
return false;
|
|
};
|
|
*authority = ElicitationAuthority {
|
|
approval_policy,
|
|
permission_profile,
|
|
reviewer,
|
|
lifecycle,
|
|
};
|
|
true
|
|
}
|
|
|
|
pub(crate) fn make_sender(
|
|
&self,
|
|
server_name: String,
|
|
tx_event: Option<Sender<Event>>,
|
|
) -> SendElicitation {
|
|
let router = self.router.clone();
|
|
let authority = self.authority.clone();
|
|
Box::new(move |id, elicitation| {
|
|
let router = router.clone();
|
|
let tx_event = tx_event.clone();
|
|
let server_name = server_name.clone();
|
|
let authority = authority.clone();
|
|
async move {
|
|
if router.auto_deny() {
|
|
return Ok(ElicitationResponse {
|
|
action: ElicitationAction::Decline,
|
|
content: None,
|
|
meta: None,
|
|
});
|
|
}
|
|
|
|
let Ok(authority) = authority.lock().map(|authority| authority.clone()) else {
|
|
return Ok(ElicitationResponse {
|
|
action: ElicitationAction::Decline,
|
|
content: None,
|
|
meta: None,
|
|
});
|
|
};
|
|
let ElicitationAuthority {
|
|
approval_policy,
|
|
permission_profile,
|
|
reviewer,
|
|
lifecycle,
|
|
} = authority;
|
|
|
|
match elicitation
|
|
.meta()
|
|
.and_then(|meta| meta.get(STRICT_AUTO_REVIEW_KEY))
|
|
{
|
|
Some(Value::Bool(true)) => {
|
|
if matches!(
|
|
approval_policy,
|
|
AskForApproval::Granular(config) if !config.allows_mcp_elicitations()
|
|
) {
|
|
return Ok(strict_auto_review_decline());
|
|
}
|
|
let Some(reviewer) = reviewer.as_ref() else {
|
|
return Ok(strict_auto_review_decline());
|
|
};
|
|
let _active_elicitation =
|
|
lifecycle.as_ref().map(ElicitationLifecycle::start);
|
|
return Ok(
|
|
match reviewer
|
|
.review(ElicitationReviewRequest {
|
|
server_name,
|
|
request_id: id,
|
|
elicitation,
|
|
})
|
|
.await
|
|
{
|
|
Ok(Some(response))
|
|
if response.action == ElicitationAction::Accept
|
|
&& response.content == Some(serde_json::json!({}))
|
|
&& response
|
|
.meta
|
|
.as_ref()
|
|
.and_then(Value::as_object)
|
|
.and_then(|meta| meta.get(APPROVALS_REVIEWER_KEY))
|
|
.and_then(Value::as_str)
|
|
== Some("auto_review") =>
|
|
{
|
|
response
|
|
}
|
|
Ok(Some(_)) | Ok(None) | Err(_) => strict_auto_review_decline(),
|
|
},
|
|
);
|
|
}
|
|
None | Some(Value::Bool(false)) => {}
|
|
Some(_) => return Ok(strict_auto_review_decline()),
|
|
}
|
|
|
|
if mcp_permission_prompt_is_auto_approved(
|
|
approval_policy,
|
|
&permission_profile,
|
|
McpPermissionPromptAutoApproveContext::default(),
|
|
) && can_auto_accept_elicitation(&elicitation)
|
|
{
|
|
return Ok(ElicitationResponse {
|
|
action: ElicitationAction::Accept,
|
|
content: Some(serde_json::json!({})),
|
|
meta: None,
|
|
});
|
|
}
|
|
|
|
if elicitation_is_rejected_by_policy(approval_policy) {
|
|
return Ok(ElicitationResponse {
|
|
action: ElicitationAction::Decline,
|
|
content: None,
|
|
meta: None,
|
|
});
|
|
}
|
|
|
|
if let Some(reviewer) = reviewer {
|
|
let request = ElicitationReviewRequest {
|
|
server_name: server_name.clone(),
|
|
request_id: id.clone(),
|
|
elicitation: elicitation.clone(),
|
|
};
|
|
if let Some(response) = reviewer.review(request).await? {
|
|
return Ok(response);
|
|
}
|
|
}
|
|
|
|
let Some(tx_event) = tx_event else {
|
|
return Ok(ElicitationResponse {
|
|
action: ElicitationAction::Decline,
|
|
content: None,
|
|
meta: None,
|
|
});
|
|
};
|
|
|
|
let public_request_id = format!(
|
|
"codex-mcp-elicitation-{}",
|
|
NEXT_ELICITATION_REQUEST_ID.fetch_add(1, Ordering::Relaxed)
|
|
);
|
|
let routed_request_id = RequestId::String(public_request_id.clone().into());
|
|
let request = match elicitation {
|
|
Elicitation::Mcp(rmcp::model::ElicitRequestParams::FormElicitationParams {
|
|
meta,
|
|
message,
|
|
requested_schema,
|
|
}) => ElicitationRequest::Form {
|
|
meta: meta
|
|
.map(serde_json::to_value)
|
|
.transpose()
|
|
.context("failed to serialize MCP elicitation metadata")?,
|
|
message,
|
|
requested_schema: serde_json::to_value(requested_schema)
|
|
.context("failed to serialize MCP elicitation schema")?,
|
|
},
|
|
Elicitation::Mcp(rmcp::model::ElicitRequestParams::UrlElicitationParams {
|
|
meta,
|
|
message,
|
|
url,
|
|
elicitation_id,
|
|
}) => ElicitationRequest::Url {
|
|
meta: meta
|
|
.map(serde_json::to_value)
|
|
.transpose()
|
|
.context("failed to serialize MCP elicitation metadata")?,
|
|
message,
|
|
url,
|
|
elicitation_id,
|
|
},
|
|
Elicitation::Mcp(_) => {
|
|
return Ok(ElicitationResponse {
|
|
action: ElicitationAction::Decline,
|
|
content: None,
|
|
meta: None,
|
|
});
|
|
}
|
|
Elicitation::OpenAiForm {
|
|
meta,
|
|
message,
|
|
requested_schema,
|
|
} => ElicitationRequest::OpenAiForm {
|
|
meta,
|
|
message,
|
|
requested_schema,
|
|
},
|
|
};
|
|
let (tx, rx) = oneshot::channel();
|
|
let _active_elicitation = lifecycle.as_ref().map(ElicitationLifecycle::start);
|
|
let request_key = (server_name.clone(), routed_request_id);
|
|
router
|
|
.requests
|
|
.lock()
|
|
.map_err(|_| anyhow!("elicitation request router unavailable"))?
|
|
.insert(request_key.clone(), tx);
|
|
let _pending_request = PendingElicitationRequest {
|
|
router: router.clone(),
|
|
key: request_key,
|
|
};
|
|
let _ = tx_event
|
|
.send(Event {
|
|
id: "mcp_elicitation_request".to_string(),
|
|
msg: EventMsg::ElicitationRequest(ElicitationRequestEvent {
|
|
turn_id: None,
|
|
server_name,
|
|
id: ProtocolRequestId::String(public_request_id),
|
|
request,
|
|
}),
|
|
})
|
|
.await;
|
|
rx.await
|
|
.context("elicitation request channel closed unexpectedly")
|
|
}
|
|
.boxed()
|
|
})
|
|
}
|
|
}
|
|
|
|
fn strict_auto_review_decline() -> ElicitationResponse {
|
|
ElicitationResponse {
|
|
action: ElicitationAction::Decline,
|
|
content: None,
|
|
meta: Some(serde_json::json!({
|
|
"message": STRICT_AUTO_REVIEW_DECLINE_MESSAGE,
|
|
})),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn elicitation_is_rejected_by_policy(approval_policy: AskForApproval) -> bool {
|
|
match approval_policy {
|
|
AskForApproval::Never => true,
|
|
AskForApproval::OnRequest => false,
|
|
AskForApproval::UnlessTrusted => false,
|
|
AskForApproval::Granular(granular_config) => !granular_config.allows_mcp_elicitations(),
|
|
}
|
|
}
|
|
|
|
type ResponderMap = HashMap<(String, RequestId), oneshot::Sender<ElicitationResponse>>;
|
|
|
|
fn can_auto_accept_elicitation(elicitation: &Elicitation) -> bool {
|
|
match elicitation {
|
|
Elicitation::Mcp(rmcp::model::ElicitRequestParams::FormElicitationParams {
|
|
requested_schema,
|
|
..
|
|
}) => {
|
|
// Auto-accept confirm/approval elicitations without schema requirements.
|
|
requested_schema.properties.is_empty()
|
|
}
|
|
Elicitation::Mcp(_) | Elicitation::OpenAiForm { .. } => false,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "elicitation_tests.rs"]
|
|
mod tests;
|