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 authored and copyberry committed 2026-08-06 00:37:46 +00:00
1 parent 92b83e226d
commit f141dc77f0
9 files changed
+200 -23

No files matched your search

@@ -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(()));