Honor model requirements in Guardian computer-use scoring (#42422)

## Why

Computer-use-only Guardian scoring should follow the active model's REPL
auto-review requirement, including when the model changes within a live thread.

## What changed

- Run computer-use scoring and fast approval decisions only when the active
  model sets `node_repl_auto_review_required`.
- Invalidate prior or in-flight scores when a model switch skips scoring, so
  switching back to a reviewed model cannot revive a stale decision.

## Testing

- Cover model switches for both `node_repl` and `cua_repl` MCP servers.
- Verify skipped scoring and stale-score rejection across requirement changes.

GitOrigin-RevId: 46aec4d017bea8f135b435bcd769b87369b8ce95
This commit is contained in:
jif authored and copyberry committed 2026-09-03 02:27:43 +00:00
1 parent 1d741742c5
commit 38ba8cdceb
5 files changed
+209 -3

No files matched your search

@@ -280,6 +280,9 @@ impl ApprovalReviewContributor for GuardianV2Extension {
.get("server")
.and_then(serde_json::Value::as_str)
.is_some_and(is_node_repl_backed_server)
|| !thread_store
.get::<ModelInfo>()
.is_some_and(|model| model.node_repl_auto_review_required)
{
record_fast_decision(extension_metrics.as_deref(), "deferred", "out_of_scope");
return None;
@@ -498,16 +501,21 @@ impl GuardianV2Extension {
};
// Use the live reviewer, not the startup config or per-app reviewer overrides.
let snapshot = thread.config_snapshot().await;
let parent_model = input.thread_store.get::<ModelInfo>();
if snapshot.full_access
|| thread.approvals_reviewer_for_turn(input.turn_id).await == ApprovalsReviewer::User
|| (guardian_config.review_scope == GuardianV2ReviewScope::ComputerUseOnly
&& !parent_model
.as_ref()
.is_some_and(|model| model.node_repl_auto_review_required))
{
// A skipped call invalidates older scores, including ones still in flight.
// A skipped call invalidates older scores, including ones still in flight
// when switching to a model that does not require REPL review.
score_progress
.latest_failed_tool_call
.fetch_max(tool_call_index, Ordering::Release);
return;
}
let parent_model = input.thread_store.get::<ModelInfo>();
// Computer-use-only scores cannot approve other tools for required models.
if guardian_config.review_scope != GuardianV2ReviewScope::ComputerUseOnly
&& parent_model.as_ref().is_some_and(|model| {
@@ -47,6 +47,7 @@ use codex_protocol::models::MessagePhase;
use codex_protocol::models::ReasoningItemReasoningSummary;
use codex_protocol::openai_models::GuardianV2ModelConfig;
use codex_protocol::openai_models::GuardianV2TranscriptModelConfig;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
@@ -229,6 +230,13 @@ async fn installed_extension_reconnects_after_auth_refresh() -> Result<()> {
let progress = thread_store
.get::<GuardianV2ScoreProgress>()
.expect("Guardian v2 should initialize");
let mut model = test
.thread_manager
.get_models_manager()
.get_model_info("gpt-5.5", &config.to_models_manager_config())
.await;
model.node_repl_auto_review_required = true;
thread_store.insert(model);
let turn_store = ExtensionData::new("turn-1");
let tool_name = ToolName::namespaced("mcp__node_repl__", "js");
let payload = ToolPayload::Function {
@@ -550,6 +558,14 @@ async fn computer_use_only_scores_cannot_approve_other_actions() -> Result<()> {
let fixture = GuardianFailureFixture::new().await?;
let thread_store = fixture.test.codex.thread_extension_data();
let mut model = fixture
.test
.thread_manager
.get_models_manager()
.get_model_info("gpt-5.5", &fixture.test.config.to_models_manager_config())
.await;
model.node_repl_auto_review_required = true;
thread_store.insert(model);
let mut config = thread_store
.get::<crate::async_scorer::config::GuardianV2Config>()
.expect("Guardian v2 should have initialized")
@@ -687,6 +703,41 @@ async fn computer_use_only_scores_cannot_approve_other_actions() -> Result<()> {
]
);
let mut model = thread_store.get::<ModelInfo>().unwrap().as_ref().clone();
model.node_repl_auto_review_required = false;
thread_store.insert(model.clone());
fixture
.score_tool(ToolName::namespaced("mcp__node_repl__", "js"))
.await;
assert!(
progress.latest_failed_tool_call.load(Ordering::Acquire)
> progress.latest_scored_tool_call.load(Ordering::Acquire)
);
for required in [false, true] {
model.node_repl_auto_review_required = required;
thread_store.insert(model.clone());
// Also reject a low score published by an older, in-flight classifier.
thread_store.insert(SecurityRiskScore {
scores: BTreeMap::from([("action_risk".to_owned(), 0.0)]),
call_id: None,
action: None,
sampled_at: None,
});
assert_eq!(
fixture
.registry
.fast_approval_decision(
&fixture.session_store,
thread_store,
r#"{"tool":"mcp_tool_call","server":"node_repl","tool_name":"js"}"#,
/*extension_metrics*/ None,
)
.await,
None,
"switching back to a reviewed model must not revive a skipped score"
);
}
Ok(())
}