Interrupt cyber model turns after one Guardian denial (#37190)

## What changed

- Add a Guardian circuit-breaker policy for models whose catalog specialty is
  `cyber`.
- Interrupt those models after the first Guardian denial while retaining the
  existing denial thresholds for other models.
- Centralize the `cyber` model-specialty identifier for use across core,
  protocol, app-server, and TUI code.

## Testing

- Cover the cyber-specific threshold in the circuit-breaker unit tests.
- Verify end to end that a denied command is not executed and the turn is
  interrupted after one denial.

GitOrigin-RevId: fad24c9e7aa280432580db1f0e39d13535779710
This commit is contained in:
andrewgu-oai
2026-08-06 00:37:46 +00:00
committed by copyberry
parent 92b83e226d
commit f141dc77f0
9 changed files with 200 additions and 23 deletions
@@ -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"},
+25 -3
View File
@@ -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 {
+8 -1
View File
@@ -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<Session>, turn_id: &str) {
}
async fn record_guardian_denial(session: &Arc<Session>, turn: &Arc<TurnContext>, 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,
+30 -12
View File
@@ -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
);
}
@@ -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(()));
+2
View File
@@ -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
+5 -4
View File
@@ -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 =
+3 -1
View File
@@ -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)
})
});
@@ -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();