mirror of
https://github.com/openai/codex.git
synced 2026-11-08 16:38:30 +00:00
Require synchronous review for sensitive MCP actions (#41094)
## What changed - Recognize `codex_sensitive_action: true` on MCP approval elicitations. - Route marked actions to the synchronous Guardian reviewer instead of extension fast approval. Absent or false values preserve the existing path. - Cover ordinary and strict auto-review configurations, including marked, unmarked, and explicitly false metadata. GitOrigin-RevId: dce13d1696568ca81c8d2b08a7ef0d5f13e155bf
This commit is contained in:
1 parent
2c4a95736b
commit
89650c66f2
10 files changed
+240
-28
No files matched your search
@@ -3,10 +3,12 @@ use anyhow::Result;
|
||||
use codex_core::TurnInputRequest;
|
||||
use codex_core::config::Constrained;
|
||||
use codex_protocol::config_types::ApprovalsReviewer;
|
||||
use codex_protocol::openai_models::AutoReviewMessages;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::GuardianAssessmentStatus;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::ThreadSettingsOverrides;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::responses;
|
||||
@@ -15,9 +17,13 @@ use core_test_support::skip_if_wine_exec;
|
||||
use core_test_support::test_codex::local_selections;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_mcp_server;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use test_case::test_case;
|
||||
use wiremock::matchers::body_partial_json;
|
||||
|
||||
// The tool itself needs no approval. Its server requests a separate Guardian
|
||||
// review after tools/call, exercising the ordinary MCP elicitation path.
|
||||
@@ -29,6 +35,7 @@ def send(message):
|
||||
print(json.dumps({"jsonrpc": "2.0", **message}), flush=True)
|
||||
|
||||
pending_call = None
|
||||
approval_meta = json.loads(sys.argv[1]) if len(sys.argv) > 1 else {}
|
||||
for line in sys.stdin:
|
||||
request = json.loads(line)
|
||||
method = request.get("method")
|
||||
@@ -46,7 +53,8 @@ for line in sys.stdin:
|
||||
"message": "Approve the server-side action?",
|
||||
"requestedSchema": {"type": "object", "properties": {}},
|
||||
"_meta": {"codex_request_type": "approval_request",
|
||||
"codex_approval_kind": "mcp_tool_call", "tool_name": "write_record"}}})
|
||||
"codex_approval_kind": "mcp_tool_call", "tool_name": "write_record",
|
||||
**approval_meta}}})
|
||||
continue
|
||||
elif method is None and request.get("id") == "server-approval":
|
||||
send({"id": pending_call, "result": {"content": [
|
||||
@@ -172,3 +180,164 @@ async fn interrupt_aborts_server_initiated_mcp_guardian_review() -> Result<()> {
|
||||
test.codex.shutdown_and_wait().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_case(None, false; "ordinary_absent")]
|
||||
#[test_case(Some(false), false; "ordinary_false")]
|
||||
#[test_case(Some(true), false; "ordinary_true")]
|
||||
#[test_case(None, true; "strict_absent")]
|
||||
#[test_case(Some(false), true; "strict_false")]
|
||||
#[test_case(Some(true), true; "strict_true")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn server_initiated_mcp_elicitation_can_require_synchronous_auto_review(
|
||||
sensitive_action: Option<bool>,
|
||||
strict_auto_review: bool,
|
||||
) -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
skip_if_wine_exec!(Ok(()), "the MCP fixture requires a host Python interpreter");
|
||||
|
||||
const RATIONALE: &str = "The server-side action is not authorized.";
|
||||
const REJECTION_INSTRUCTIONS: &str = "Do not retry the rejected action.";
|
||||
|
||||
struct AutoApprovingReviewContributor;
|
||||
|
||||
impl codex_extension_api::ApprovalReviewContributor for AutoApprovingReviewContributor {
|
||||
fn fast_decision<'a>(
|
||||
&'a self,
|
||||
_session_store: &'a codex_extension_api::ExtensionData,
|
||||
_thread_store: &'a codex_extension_api::ExtensionData,
|
||||
_prompt: &'a str,
|
||||
_extension_metrics: Option<Arc<dyn codex_extension_api::ExtensionMetrics>>,
|
||||
) -> codex_extension_api::ExtensionFuture<'a, Option<ReviewDecision>> {
|
||||
Box::pin(async { Some(ReviewDecision::Approved) })
|
||||
}
|
||||
}
|
||||
|
||||
let mut extensions = codex_extension_api::ExtensionRegistryBuilder::new();
|
||||
extensions.approval_review_contributor(Arc::new(AutoApprovingReviewContributor));
|
||||
let mut meta = serde_json::Map::new();
|
||||
if strict_auto_review {
|
||||
meta.insert("codex_strict_auto_review".to_string(), json!(true));
|
||||
}
|
||||
if let Some(sensitive_action) = sensitive_action {
|
||||
meta.insert(
|
||||
"codex_sensitive_action".to_string(),
|
||||
json!(sensitive_action),
|
||||
);
|
||||
}
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mcp_servers = serde_json::from_value(json!({
|
||||
"elicitation": {
|
||||
"command": if cfg!(windows) { "python" } else { "python3" },
|
||||
"args": ["-u", "-c", ELICITATION_SERVER, serde_json::to_string(&meta)?],
|
||||
"default_tools_approval_mode": "approve",
|
||||
}
|
||||
}))?;
|
||||
let mut builder = test_codex()
|
||||
.with_extensions(Arc::new(extensions.build()))
|
||||
.with_model_info_override("gpt-5.5", |model| {
|
||||
model
|
||||
.model_messages
|
||||
.as_mut()
|
||||
.expect("acting model messages")
|
||||
.auto_review = Some(AutoReviewMessages {
|
||||
policy: None,
|
||||
policy_template: None,
|
||||
rejection_instructions: Some(REJECTION_INSTRUCTIONS.to_string()),
|
||||
timeout_instructions: None,
|
||||
});
|
||||
})
|
||||
.with_config(move |config| {
|
||||
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
config.approvals_reviewer = if strict_auto_review {
|
||||
ApprovalsReviewer::User
|
||||
} else {
|
||||
ApprovalsReviewer::AutoReview
|
||||
};
|
||||
config
|
||||
.mcp_servers
|
||||
.set(mcp_servers)
|
||||
.expect("set MCP fixture");
|
||||
});
|
||||
let test = builder.build_with_auto_env(&server).await?;
|
||||
wait_for_mcp_server(&test.codex, "elicitation").await?;
|
||||
responses::mount_sse_once(
|
||||
&server,
|
||||
responses::sse(vec![
|
||||
responses::ev_function_call_with_namespace(
|
||||
"eliciting-tool",
|
||||
"mcp__elicitation",
|
||||
"request_approval",
|
||||
"{}",
|
||||
),
|
||||
responses::ev_completed("parent-tool"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let guardian = responses::mount_sse_once_match(
|
||||
&server,
|
||||
body_partial_json(json!({"client_metadata": {"x-openai-subagent": "guardian"}})),
|
||||
responses::sse(vec![
|
||||
responses::ev_assistant_message(
|
||||
"review-result",
|
||||
&json!({
|
||||
"risk_level": "high",
|
||||
"user_authorization": "low",
|
||||
"outcome": "deny",
|
||||
"rationale": RATIONALE,
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
responses::ev_completed("guardian-review"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let parent = responses::mount_sse_once(
|
||||
&server,
|
||||
responses::sse(vec![responses::ev_completed("parent-complete")]),
|
||||
)
|
||||
.await;
|
||||
|
||||
test.submit_text_turn("Run the tool that requests server-side approval.")
|
||||
.await?;
|
||||
|
||||
let output = parent
|
||||
.single_request()
|
||||
.function_call_output("eliciting-tool");
|
||||
let response: Value = serde_json::from_str(
|
||||
output["output"][1]["text"]
|
||||
.as_str()
|
||||
.expect("MCP fixture should echo the wire response"),
|
||||
)?;
|
||||
let requires_sync = sensitive_action == Some(true);
|
||||
let expected_response = if requires_sync {
|
||||
json!({
|
||||
"action": "decline",
|
||||
"_meta": {
|
||||
"approvals_reviewer": "auto_review",
|
||||
"message": format!(
|
||||
"This action was rejected due to unacceptable risk.\nReason: {RATIONALE}\n{REJECTION_INSTRUCTIONS}"
|
||||
),
|
||||
},
|
||||
})
|
||||
} else {
|
||||
json!({
|
||||
"action": "accept",
|
||||
"content": {},
|
||||
"_meta": {"approvals_reviewer": "auto_review"},
|
||||
})
|
||||
};
|
||||
assert_eq!(response, expected_response);
|
||||
|
||||
let guardian_requests = guardian
|
||||
.requests()
|
||||
.into_iter()
|
||||
.filter(|request| request.body_json()["client_metadata"]["x-openai-subagent"] == "guardian")
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(guardian_requests.len(), usize::from(requires_sync));
|
||||
if requires_sync {
|
||||
assert!(guardian_requests[0].body_contains_text("write_record"));
|
||||
}
|
||||
test.codex.shutdown_and_wait().await?;
|
||||
Ok(())
|
||||
}
|
||||
Reference in new issue
Block a user