mirror of
https://github.com/openai/codex.git
synced 2026-05-26 05:55:36 +00:00
# Motivation Browser Use origin-access prompts are MCP elicitations, not direct tool-call approval prompts, so they were bypassing the Guardian approval path. We need a generic opt-in that lets eligible MCP elicitations use Guardian when the current turn already routes approvals there. # Description Add a generic elicitation reviewer hook in codex-mcp and wire codex-core to pass a Guardian reviewer callback when creating the MCP connection manager. The reviewer validates explicit mcp_tool_call opt-in metadata, builds a Guardian MCP tool-call review request from server/tool/connector metadata and tool params, and maps Guardian approval, denial, timeout, and cancellation decisions back to MCP elicitation responses. The new option to trigger this in the `_meta` object is: ``` "codex_request_type": "approval_request", ``` # Testing - RUST_MIN_STACK=8388608 NEXTEST_STATUS_LEVEL=leak cargo nextest run --no-fail-fast --cargo-profile ci-test --test-threads 2 - cargo clippy --tests -- -D warnings - cargo fmt -- --config imports_granularity=Item --check - cargo shear - pnpm run format - python3 .github/scripts/verify_cargo_workspace_manifests.py - python3 .github/scripts/verify_tui_core_boundary.py - python3 .github/scripts/verify_bazel_clippy_lints.py - git diff --check
257 lines
9.6 KiB
Rust
257 lines
9.6 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 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::models::PermissionProfile;
|
|
use codex_protocol::protocol::AskForApproval;
|
|
use codex_protocol::protocol::Event;
|
|
use codex_protocol::protocol::EventMsg;
|
|
use codex_rmcp_client::ElicitationResponse;
|
|
use codex_rmcp_client::SendElicitation;
|
|
use futures::future::BoxFuture;
|
|
use futures::future::FutureExt;
|
|
use rmcp::model::CreateElicitationRequestParams;
|
|
use rmcp::model::ElicitationAction;
|
|
use rmcp::model::RequestId;
|
|
use tokio::sync::Mutex;
|
|
use tokio::sync::oneshot;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ElicitationReviewRequest {
|
|
pub server_name: String,
|
|
pub request_id: RequestId,
|
|
pub elicitation: CreateElicitationRequestParams,
|
|
}
|
|
|
|
pub trait ElicitationReviewer: Send + Sync {
|
|
fn review(
|
|
&self,
|
|
request: ElicitationReviewRequest,
|
|
) -> BoxFuture<'static, Result<Option<ElicitationResponse>>>;
|
|
}
|
|
|
|
pub type ElicitationReviewerHandle = Arc<dyn ElicitationReviewer>;
|
|
|
|
#[derive(Clone)]
|
|
pub(crate) struct ElicitationRequestManager {
|
|
requests: Arc<Mutex<ResponderMap>>,
|
|
pub(crate) approval_policy: Arc<StdMutex<AskForApproval>>,
|
|
pub(crate) permission_profile: Arc<StdMutex<PermissionProfile>>,
|
|
auto_deny: Arc<StdMutex<bool>>,
|
|
reviewer: Option<ElicitationReviewerHandle>,
|
|
}
|
|
|
|
impl ElicitationRequestManager {
|
|
pub(crate) fn new(
|
|
approval_policy: AskForApproval,
|
|
permission_profile: PermissionProfile,
|
|
reviewer: Option<ElicitationReviewerHandle>,
|
|
) -> Self {
|
|
Self {
|
|
requests: Arc::new(Mutex::new(HashMap::new())),
|
|
approval_policy: Arc::new(StdMutex::new(approval_policy)),
|
|
permission_profile: Arc::new(StdMutex::new(permission_profile)),
|
|
auto_deny: Arc::new(StdMutex::new(false)),
|
|
reviewer,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn auto_deny(&self) -> bool {
|
|
self.auto_deny
|
|
.lock()
|
|
.map(|auto_deny| *auto_deny)
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
pub(crate) fn set_auto_deny(&self, auto_deny: bool) {
|
|
if let Ok(mut current) = self.auto_deny.lock() {
|
|
*current = auto_deny;
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn resolve(
|
|
&self,
|
|
server_name: String,
|
|
id: RequestId,
|
|
response: ElicitationResponse,
|
|
) -> Result<()> {
|
|
self.requests
|
|
.lock()
|
|
.await
|
|
.remove(&(server_name, id))
|
|
.ok_or_else(|| anyhow!("elicitation request not found"))?
|
|
.send(response)
|
|
.map_err(|e| anyhow!("failed to send elicitation response: {e:?}"))
|
|
}
|
|
|
|
pub(crate) fn make_sender(
|
|
&self,
|
|
server_name: String,
|
|
tx_event: Sender<Event>,
|
|
) -> SendElicitation {
|
|
let elicitation_requests = self.requests.clone();
|
|
let approval_policy = self.approval_policy.clone();
|
|
let permission_profile = self.permission_profile.clone();
|
|
let auto_deny = self.auto_deny.clone();
|
|
let reviewer = self.reviewer.clone();
|
|
Box::new(move |id, elicitation| {
|
|
let elicitation_requests = elicitation_requests.clone();
|
|
let tx_event = tx_event.clone();
|
|
let server_name = server_name.clone();
|
|
let approval_policy = approval_policy.clone();
|
|
let permission_profile = permission_profile.clone();
|
|
let auto_deny = auto_deny.clone();
|
|
let reviewer = reviewer.clone();
|
|
async move {
|
|
let auto_deny = auto_deny
|
|
.lock()
|
|
.map(|auto_deny| *auto_deny)
|
|
.unwrap_or(false);
|
|
if auto_deny {
|
|
return Ok(ElicitationResponse {
|
|
action: ElicitationAction::Decline,
|
|
content: None,
|
|
meta: None,
|
|
});
|
|
}
|
|
|
|
let approval_policy = approval_policy
|
|
.lock()
|
|
.map(|policy| *policy)
|
|
.unwrap_or(AskForApproval::Never);
|
|
let permission_profile = permission_profile
|
|
.lock()
|
|
.map(|profile| profile.clone())
|
|
.unwrap_or_default();
|
|
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.as_ref() {
|
|
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 request = match elicitation {
|
|
CreateElicitationRequestParams::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")?,
|
|
},
|
|
CreateElicitationRequestParams::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,
|
|
},
|
|
};
|
|
let (tx, rx) = oneshot::channel();
|
|
{
|
|
let mut lock = elicitation_requests.lock().await;
|
|
lock.insert((server_name.clone(), id.clone()), tx);
|
|
}
|
|
let _ = tx_event
|
|
.send(Event {
|
|
id: "mcp_elicitation_request".to_string(),
|
|
msg: EventMsg::ElicitationRequest(ElicitationRequestEvent {
|
|
turn_id: None,
|
|
server_name,
|
|
id: match id.clone() {
|
|
rmcp::model::NumberOrString::String(value) => {
|
|
ProtocolRequestId::String(value.to_string())
|
|
}
|
|
rmcp::model::NumberOrString::Number(value) => {
|
|
ProtocolRequestId::Integer(value)
|
|
}
|
|
},
|
|
request,
|
|
}),
|
|
})
|
|
.await;
|
|
rx.await
|
|
.context("elicitation request channel closed unexpectedly")
|
|
}
|
|
.boxed()
|
|
})
|
|
}
|
|
}
|
|
|
|
pub(crate) fn elicitation_is_rejected_by_policy(approval_policy: AskForApproval) -> bool {
|
|
match approval_policy {
|
|
AskForApproval::Never => true,
|
|
AskForApproval::OnFailure => false,
|
|
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: &CreateElicitationRequestParams) -> bool {
|
|
match elicitation {
|
|
CreateElicitationRequestParams::FormElicitationParams {
|
|
requested_schema, ..
|
|
} => {
|
|
// Auto-accept confirm/approval elicitations without schema requirements.
|
|
requested_schema.properties.is_empty()
|
|
}
|
|
CreateElicitationRequestParams::UrlElicitationParams { .. } => false,
|
|
}
|
|
}
|