diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 385fe89ceb..a3c37b650a 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -317,6 +317,7 @@ async fn run_guardian_review( .config_layer_stack .requirements() .auto_review_required_for_model(&turn.model_info.slug) + && reasons.retry.is_none() && options .external_cancel .as_ref() diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index a2e7e267cc..c421c94dff 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -2981,6 +2981,72 @@ async fn guardian_review_does_not_retry_valid_denial() -> anyhow::Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn escalated_retry_bypasses_extension_approval_and_runs_guardian() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + struct AutoApprovingReviewContributor; + + impl codex_extension_api::ApprovalReviewContributor for AutoApprovingReviewContributor { + fn contribute<'a>( + &'a self, + _session_store: &'a codex_extension_api::ExtensionData, + _thread_store: &'a codex_extension_api::ExtensionData, + _prompt: &'a str, + ) -> codex_extension_api::ExtensionFuture<'a, Option> { + Box::pin(async move { Some(ReviewDecision::Approved) }) + } + } + + let server = start_mock_server().await; + let denial = serde_json::json!({ + "risk_level": "high", + "user_authorization": "unknown", + "outcome": "deny", + "rationale": "The original attempt was blocked by the sandbox.", + }) + .to_string(); + let request_log = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-escalated-retry"), + ev_assistant_message("msg-escalated-retry", &denial), + ev_completed("resp-escalated-retry"), + ]), + ) + .await; + + let (mut session, turn) = guardian_test_session_and_turn(&server).await; + let mut extensions = codex_extension_api::ExtensionRegistryBuilder::::new(); + extensions.approval_review_contributor(Arc::new(AutoApprovingReviewContributor)); + Arc::get_mut(&mut session) + .expect("session should be uniquely owned") + .services + .extensions = Arc::new(extensions.build()); + seed_guardian_parent_history(&session, &turn).await; + + let retry_reason = "The sandbox blocked the original command."; + let decision = review_approval_request( + &session, + &turn, + "review-escalated-retry".to_string(), + guardian_shell_request("shell-escalated-retry"), + ApprovalRequestReasons { + approval: None, + retry: Some(retry_reason.to_string()), + }, + ) + .await; + + assert!(matches!(decision, ReviewDecision::Denied { .. })); + assert!( + request_log + .single_request() + .body_contains_text(retry_reason) + ); + Ok(()) +} + #[tokio::test] async fn guardian_ephemeral_retry_preserves_parallel_trunk_and_fork_history() -> anyhow::Result<()> {