diff --git a/codex-rs/app-server/tests/suite/v2/model_list.rs b/codex-rs/app-server/tests/suite/v2/model_list.rs index 4abbd333c2..c5bdb21d84 100644 --- a/codex-rs/app-server/tests/suite/v2/model_list.rs +++ b/codex-rs/app-server/tests/suite/v2/model_list.rs @@ -16,6 +16,7 @@ use codex_app_server_protocol::ModelUpgradeInfo; use codex_app_server_protocol::ReasoningEffortOption; use codex_app_server_protocol::RequestId; use codex_config::types::AuthCredentialsStoreMode; +use codex_protocol::openai_models::MODEL_SPECIALTY_CYBER; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelsResponse; @@ -157,7 +158,7 @@ async fn list_models_uses_chatgpt_remote_catalog_as_source_of_truth() -> Result< "slug": "chatgpt-remote-only", "display_name": "ChatGPT Remote Only", "description": "Remote-only model for app-server model/list coverage", - "model_specialty": "cyber", + "model_specialty": MODEL_SPECIALTY_CYBER, "default_reasoning_level": "max", "supported_reasoning_levels": [ {"effort": "max", "description": "Maximum"}, diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index b1e181cd30..acdd467683 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -47,7 +47,9 @@ pub(crate) use review_session::prompt_cache_key_override_for_review_session; pub(crate) const GUARDIAN_REVIEW_TIMEOUT: Duration = Duration::from_secs(90); pub(crate) const GUARDIAN_REVIEWER_NAME: &str = "guardian"; +pub(crate) const MAX_CONSECUTIVE_CYBER_GUARDIAN_DENIALS_PER_TURN: u32 = 1; pub(crate) const MAX_CONSECUTIVE_GUARDIAN_DENIALS_PER_TURN: u32 = 3; +pub(crate) const MAX_RECENT_CYBER_AUTO_REVIEW_DENIALS_PER_TURN: u32 = 1; pub(crate) const MAX_RECENT_AUTO_REVIEW_DENIALS_PER_TURN: u32 = 10; pub(crate) const AUTO_REVIEW_DENIAL_WINDOW_SIZE: usize = 50; pub(crate) const AUTO_REVIEW_DENIED_ACTION_APPROVAL_DEVELOPER_PREFIX: &str = @@ -81,6 +83,12 @@ struct GuardianRejectionCircuitBreakerTurn { interrupt_triggered: bool, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum GuardianRejectionCircuitBreakerPolicy { + Standard, + CyberModel, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum GuardianRejectionCircuitBreakerAction { Continue, @@ -95,14 +103,28 @@ impl GuardianRejectionCircuitBreaker { self.turns.remove(turn_id); } - pub(crate) fn record_denial(&mut self, turn_id: &str) -> GuardianRejectionCircuitBreakerAction { + pub(crate) fn record_denial( + &mut self, + turn_id: &str, + policy: GuardianRejectionCircuitBreakerPolicy, + ) -> GuardianRejectionCircuitBreakerAction { let turn = self.turns.entry(turn_id.to_string()).or_default(); turn.consecutive_denials = turn.consecutive_denials.saturating_add(1); Self::record_recent_review(turn, /*denied*/ true); let recent_denials = turn.recent_denials.iter().filter(|denied| **denied).count() as u32; + let (max_consecutive_denials, max_recent_denials) = match policy { + GuardianRejectionCircuitBreakerPolicy::Standard => ( + MAX_CONSECUTIVE_GUARDIAN_DENIALS_PER_TURN, + MAX_RECENT_AUTO_REVIEW_DENIALS_PER_TURN, + ), + GuardianRejectionCircuitBreakerPolicy::CyberModel => ( + MAX_CONSECUTIVE_CYBER_GUARDIAN_DENIALS_PER_TURN, + MAX_RECENT_CYBER_AUTO_REVIEW_DENIALS_PER_TURN, + ), + }; if !turn.interrupt_triggered - && (turn.consecutive_denials >= MAX_CONSECUTIVE_GUARDIAN_DENIALS_PER_TURN - || recent_denials >= MAX_RECENT_AUTO_REVIEW_DENIALS_PER_TURN) + && (turn.consecutive_denials >= max_consecutive_denials + || recent_denials >= max_recent_denials) { turn.interrupt_triggered = true; GuardianRejectionCircuitBreakerAction::InterruptTurn { diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 25adb738a0..41446bda71 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -7,6 +7,7 @@ use codex_analytics::GuardianReviewTrackContext; use codex_analytics::GuardianReviewedAction; use codex_core_plugins::PluginCommandAttribution; use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::openai_models::MODEL_SPECIALTY_CYBER; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::EventMsg; @@ -38,6 +39,7 @@ use super::GuardianApprovalRequest; use super::GuardianAssessment; use super::GuardianAssessmentOutcome; use super::GuardianRejectionCircuitBreakerAction; +use super::GuardianRejectionCircuitBreakerPolicy; use super::approval_request::guardian_assessment_action; use super::approval_request::guardian_request_target_item_id; use super::approval_request::guardian_request_turn_id; @@ -235,12 +237,17 @@ async fn record_guardian_non_denial(session: &Arc, turn_id: &str) { } async fn record_guardian_denial(session: &Arc, turn: &Arc, turn_id: &str) { + let policy = if turn.model_info.model_specialty.as_deref() == Some(MODEL_SPECIALTY_CYBER) { + GuardianRejectionCircuitBreakerPolicy::CyberModel + } else { + GuardianRejectionCircuitBreakerPolicy::Standard + }; let action = session .services .guardian_rejection_circuit_breaker .lock() .await - .record_denial(turn_id); + .record_denial(turn_id, policy); let GuardianRejectionCircuitBreakerAction::InterruptTurn { consecutive_denials, recent_denials, diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 2363a82fa0..e3a2b9526f 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -139,22 +139,38 @@ impl codex_extension_api::ContextContributor for GuardianMemoryContextProbe { fn guardian_rejection_circuit_breaker_interrupts_after_three_consecutive_denials() { let mut circuit_breaker = GuardianRejectionCircuitBreaker::default(); assert_eq!( - circuit_breaker.record_denial("turn-1"), + circuit_breaker.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::Standard), GuardianRejectionCircuitBreakerAction::Continue ); assert_eq!( - circuit_breaker.record_denial("turn-1"), + circuit_breaker.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::Standard), GuardianRejectionCircuitBreakerAction::Continue ); assert_eq!( - circuit_breaker.record_denial("turn-1"), + circuit_breaker.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::Standard), GuardianRejectionCircuitBreakerAction::InterruptTurn { consecutive_denials: 3, recent_denials: 3, } ); assert_eq!( - circuit_breaker.record_denial("turn-1"), + circuit_breaker.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::Standard), + GuardianRejectionCircuitBreakerAction::Continue + ); +} + +#[test] +fn guardian_rejection_circuit_breaker_interrupts_cyber_models_after_one_denial() { + let mut circuit_breaker = GuardianRejectionCircuitBreaker::default(); + assert_eq!( + circuit_breaker.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::CyberModel), + GuardianRejectionCircuitBreakerAction::InterruptTurn { + consecutive_denials: 1, + recent_denials: 1, + } + ); + assert_eq!( + circuit_breaker.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::CyberModel), GuardianRejectionCircuitBreakerAction::Continue ); } @@ -163,20 +179,20 @@ fn guardian_rejection_circuit_breaker_interrupts_after_three_consecutive_denials fn guardian_rejection_circuit_breaker_resets_consecutive_denials_on_non_denial() { let mut circuit_breaker = GuardianRejectionCircuitBreaker::default(); assert_eq!( - circuit_breaker.record_denial("turn-1"), + circuit_breaker.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::Standard), GuardianRejectionCircuitBreakerAction::Continue ); circuit_breaker.record_non_denial("turn-1"); assert_eq!( - circuit_breaker.record_denial("turn-1"), + circuit_breaker.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::Standard), GuardianRejectionCircuitBreakerAction::Continue ); assert_eq!( - circuit_breaker.record_denial("turn-1"), + circuit_breaker.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::Standard), GuardianRejectionCircuitBreakerAction::Continue ); assert_eq!( - circuit_breaker.record_denial("turn-1"), + circuit_breaker.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::Standard), GuardianRejectionCircuitBreakerAction::InterruptTurn { consecutive_denials: 3, recent_denials: 4, @@ -189,13 +205,14 @@ fn auto_review_rejection_circuit_breaker_interrupts_after_ten_recent_denials() { let mut circuit_breaker = GuardianRejectionCircuitBreaker::default(); for _ in 0..9 { assert_eq!( - circuit_breaker.record_denial("turn-1"), + circuit_breaker + .record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::Standard), GuardianRejectionCircuitBreakerAction::Continue ); circuit_breaker.record_non_denial("turn-1"); } assert_eq!( - circuit_breaker.record_denial("turn-1"), + circuit_breaker.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::Standard), GuardianRejectionCircuitBreakerAction::InterruptTurn { consecutive_denials: 1, recent_denials: 10, @@ -208,7 +225,8 @@ fn auto_review_rejection_circuit_breaker_forgets_denials_outside_recent_review_w let mut circuit_breaker = GuardianRejectionCircuitBreaker::default(); for _ in 0..9 { assert_eq!( - circuit_breaker.record_denial("turn-1"), + circuit_breaker + .record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::Standard), GuardianRejectionCircuitBreakerAction::Continue ); circuit_breaker.record_non_denial("turn-1"); @@ -217,7 +235,7 @@ fn auto_review_rejection_circuit_breaker_forgets_denials_outside_recent_review_w circuit_breaker.record_non_denial("turn-1"); } assert_eq!( - circuit_breaker.record_denial("turn-1"), + circuit_breaker.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::Standard), GuardianRejectionCircuitBreakerAction::Continue ); } diff --git a/codex-rs/core/tests/suite/guardian_review.rs b/codex-rs/core/tests/suite/guardian_review.rs index e5540dd8ec..af465ec376 100644 --- a/codex-rs/core/tests/suite/guardian_review.rs +++ b/codex-rs/core/tests/suite/guardian_review.rs @@ -16,6 +16,7 @@ use codex_features::Feature; use codex_login::CodexAuth; use codex_protocol::ThreadId; use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::openai_models::MODEL_SPECIALTY_CYBER; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; @@ -23,6 +24,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::TurnAbortReason; use codex_protocol::user_input::UserInput; use core_test_support::fs_wait; use core_test_support::responses::ev_assistant_message; @@ -748,6 +750,127 @@ async fn guardian_denial_rejects_tool_call_with_rationale() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn cyber_model_guardian_denial_interrupts_turn_immediately() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + skip_if_wine_exec!( + Ok(()), + "Guardian approval actions require host-native paths" + ); + + let server = start_mock_server().await; + let approval_policy = AskForApproval::OnRequest; + let sandbox_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + let sandbox_policy_for_config = sandbox_policy.clone(); + + let mut builder = test_codex() + .with_model_info_override("gpt-5.4", |model| { + model.model_specialty = Some(MODEL_SPECIALTY_CYBER.to_string()); + }) + .with_config(move |config| { + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config + .set_legacy_sandbox_policy(sandbox_policy_for_config) + .expect("set sandbox policy"); + }); + let test = builder.build_with_auto_env(&server).await?; + + let output_file = test.cwd.path().join("cyber-guardian-denied.txt"); + let command = format!("printf should-not-run > {}", output_file.display()); + let tool_args = json!({ + "cmd": command, + "yield_time_ms": 1_000_u64, + "sandbox_permissions": SandboxPermissions::RequireEscalated, + "justification": "Exercise immediate Guardian interruption for cyber models.", + }); + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-cyber-parent-tool-denied"), + ev_function_call( + "exec-cyber-call-denied", + "exec_command", + &serde_json::to_string(&tool_args)?, + ), + ev_completed("resp-cyber-parent-tool-denied"), + ]), + sse(vec![ + ev_response_created("resp-cyber-guardian-denied"), + ev_assistant_message( + "msg-cyber-guardian-denied", + &json!({ + "risk_level": "high", + "user_authorization": "low", + "outcome": "deny", + "rationale": "The requested command has unacceptable test risk.", + }) + .to_string(), + ), + ev_completed("resp-cyber-guardian-denied"), + ]), + ], + ) + .await; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "run a command that Guardian should deny for a cyber model".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + approval_policy: Some(approval_policy), + approvals_reviewer: Some(ApprovalsReviewer::AutoReview), + sandbox_policy: Some(sandbox_policy), + ..Default::default() + }, + }) + .await?; + + let warning = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::GuardianWarning(warning) + if warning.message.contains("too many approval requests") + ) + }) + .await; + let EventMsg::GuardianWarning(warning) = warning else { + unreachable!("wait_for_event returned a non-warning event") + }; + assert!( + warning + .message + .contains("1 consecutive, 1 in the last 50 reviews") + ); + + let aborted = wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnAborted(_)) + }) + .await; + let EventMsg::TurnAborted(aborted) = aborted else { + unreachable!("wait_for_event returned a non-abort event") + }; + assert_eq!(aborted.reason, TurnAbortReason::Interrupted); + assert_eq!(responses.requests().len(), 2); + assert!( + !output_file.exists(), + "Guardian-denied cyber-model command unexpectedly executed" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn guardian_review_session_does_not_inherit_legacy_notify() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index a915199ba2..e6c5db8d93 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -32,6 +32,8 @@ use crate::config_types::Verbosity; use crate::protocol::MultiAgentVersion; const PERSONALITY_PLACEHOLDER: &str = "{{ personality }}"; +/// Backend model-catalog specialty identifying cybersecurity-focused models. +pub const MODEL_SPECIALTY_CYBER: &str = "cyber"; pub const SPEED_TIER_FAST: &str = "fast"; /// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 2ef3387e32..4d2193b307 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -106,6 +106,7 @@ use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::NetworkPermissions; use codex_protocol::models::PermissionProfile; +use codex_protocol::openai_models::MODEL_SPECIALTY_CYBER; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::MAX_THREAD_GOAL_OBJECTIVE_CHARS; use codex_protocol::protocol::MultiAgentVersion; @@ -7248,7 +7249,7 @@ async fn selecting_cyber_model_defaults_active_thread_to_auto_review() { .into_iter() .find(|model| model.model == "gpt-5.4") .expect("gpt-5.4 model"); - model.model_specialty = Some("cyber".to_string()); + model.model_specialty = Some(MODEL_SPECIALTY_CYBER.to_string()); app.model_catalog = Arc::new(ModelCatalog::new(vec![model])); let mut app_server = @@ -7311,7 +7312,7 @@ async fn changing_cyber_model_reasoning_preserves_selected_permissions() { .into_iter() .find(|model| model.model == model_name) .expect("current model"); - model.model_specialty = Some("cyber".to_string()); + model.model_specialty = Some(MODEL_SPECIALTY_CYBER.to_string()); app.model_catalog = Arc::new(ModelCatalog::new(vec![model])); assert!( @@ -7414,7 +7415,7 @@ async fn selecting_cyber_model_falls_back_to_user_when_auto_review_is_unavailabl .into_iter() .find(|model| model.model == "gpt-5.4") .expect("gpt-5.4 model"); - model.model_specialty = Some("cyber".to_string()); + model.model_specialty = Some(MODEL_SPECIALTY_CYBER.to_string()); app.model_catalog = Arc::new(ModelCatalog::new(vec![model])); let _ = app.config.features.disable(Feature::GuardianApproval); app.chat_widget @@ -7469,7 +7470,7 @@ async fn selecting_cyber_model_respects_auto_review_requirements() { .into_iter() .find(|model| model.model == "gpt-5.4") .expect("gpt-5.4 model"); - model.model_specialty = Some("cyber".to_string()); + model.model_specialty = Some(MODEL_SPECIALTY_CYBER.to_string()); app.model_catalog = Arc::new(ModelCatalog::new(vec![model])); let mut app_server = diff --git a/codex-rs/tui/src/app/thread_settings.rs b/codex-rs/tui/src/app/thread_settings.rs index 8d3383880e..5f1e7e83ec 100644 --- a/codex-rs/tui/src/app/thread_settings.rs +++ b/codex-rs/tui/src/app/thread_settings.rs @@ -15,6 +15,7 @@ use codex_protocol::ThreadId; use codex_protocol::config_types::ModeKind; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::PermissionProfile; +use codex_protocol::openai_models::MODEL_SPECIALTY_CYBER; impl App { pub(super) async fn sync_active_thread_model_setting( @@ -50,7 +51,8 @@ impl App { let thread_id = self.active_thread_id?; let is_cyber_model = self.model_catalog.try_list_models().is_ok_and(|models| { models.iter().any(|preset| { - preset.model == model && preset.model_specialty.as_deref() == Some("cyber") + preset.model == model + && preset.model_specialty.as_deref() == Some(MODEL_SPECIALTY_CYBER) }) }); diff --git a/codex-rs/tui/src/chatwidget/permission_popups.rs b/codex-rs/tui/src/chatwidget/permission_popups.rs index 9bec7aa697..9342e2051c 100644 --- a/codex-rs/tui/src/chatwidget/permission_popups.rs +++ b/codex-rs/tui/src/chatwidget/permission_popups.rs @@ -5,6 +5,7 @@ //! `windows_sandbox_prompts`. use super::*; +use codex_protocol::openai_models::MODEL_SPECIALTY_CYBER; impl ChatWidget { /// Open the permissions popup. @@ -410,7 +411,7 @@ impl ChatWidget { let is_cyber_model = self.model_catalog.try_list_models().is_ok_and(|models| { models.iter().any(|model| { model.model == self.current_model() - && model.model_specialty.as_deref() == Some("cyber") + && model.model_specialty.as_deref() == Some(MODEL_SPECIALTY_CYBER) }) }); let title_line = Line::from("Enable full access?").bold();