Use captured action settings for Guardian reviews (#44574)

## Why

Model changes during a turn and catalog refreshes can leave Guardian reviews using settings or metadata that do not match the action being reviewed.

## What changed

- Use the action's captured model, reasoning effort, reasoning summary, and personality when configuring reviews.
- Preserve captured model metadata when falling back to the parent model, while resolving a separate reviewer against the current catalog on each attempt.
- Evaluate live managed review requirements against the action's model and use its metadata for computer-use review behavior.
- Include personality in the review session reuse key so personality changes invalidate cached sessions.

## Testing

Add regression coverage for delayed and new actions across model changes and catalog refreshes, required-review routing, and session reuse when policy or personality changes. Extend integration tests to cover reviewer reuse across parent model changes and verify that interrupted reviews cannot execute commands after a delayed approval arrives.

GitOrigin-RevId: 5b434568e113e44bf7177780f2ae13192b55e942
This commit is contained in:
Abhinav authored and copyberry committed 2026-09-10 16:17:25 +00:00
1 parent 6baa076eb6
commit 6bb5be869f
10 files changed
+667 -161

No files matched your search

+376 -108
View File
@@ -23,6 +23,7 @@ use codex_features::CurrentTimeSource;
use codex_features::Feature;
use codex_history::RolloutItem;
use codex_login::CodexAuth;
use codex_models_manager::manager::RefreshStrategy;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::WindowsSandboxLevel;
@@ -33,6 +34,7 @@ use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::AutoReviewMessages;
use codex_protocol::openai_models::MODEL_SPECIALTY_CYBER;
use codex_protocol::openai_models::ModelTokenBudgetConfig;
use codex_protocol::openai_models::ModelVisibility;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
@@ -59,19 +61,20 @@ use core_test_support::responses::ev_completed_with_tokens;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::ev_function_call_with_namespace;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_response_once_match;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::mount_models_once;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::sse_response;
use core_test_support::responses::start_mock_server;
use core_test_support::responses::start_websocket_server;
use core_test_support::skip_if_no_network;
use core_test_support::skip_if_sandbox;
use core_test_support::skip_if_wine_exec;
use core_test_support::streaming_sse::StreamingSseChunk;
use core_test_support::streaming_sse::start_streaming_sse_server;
use core_test_support::test_codex::local_selections;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_match;
use core_test_support::wait_for_mcp_server;
use pretty_assertions::assert_eq;
use serde_json::Value;
@@ -948,6 +951,219 @@ async fn guardian_node_repl_policy_follows_production_approval_path(
Ok(())
}
fn guardian_parent_catalog() -> ModelsResponse {
let template = codex_models_manager::bundled_models_response()
.expect("bundled model catalog")
.models
.into_iter()
.find(|model| model.slug == "gpt-5.4")
.expect("gpt-5.4 in bundled catalog");
// Keep safety settings compatible so active publication can switch A to B.
ModelsResponse {
models: ["guardian-parent-a", "guardian-parent-b"]
.into_iter()
.map(|slug| {
let mut model = template.clone();
model.slug = slug.to_string();
model.visibility = ModelVisibility::List;
model.auto_review_model_override = None;
model.supported_reasoning_levels.retain(|level| {
level.effort != codex_protocol::openai_models::ReasoningEffort::Low
});
model
.model_messages
.as_mut()
.expect("model messages")
.auto_review = Some(AutoReviewMessages {
policy: Some("captured policy".to_string()),
policy_template: Some(
"captured template: {{ tenant_policy_config }}".to_string(),
),
node_repl_policy: None,
rejection_instructions: None,
timeout_instructions: None,
});
model
})
.collect(),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn guardian_reviews_delayed_and_new_actions_after_catalog_refresh() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
skip_if_wine_exec!(
Ok(()),
"Guardian approval actions require host-native paths"
);
#[derive(Default)]
struct PauseFirstAction {
started: tokio::sync::Notify,
resume: tokio::sync::Notify,
}
impl ToolLifecycleContributor for PauseFirstAction {
fn on_tool_start<'a>(&'a self, input: ToolStartInput<'a>) -> ToolLifecycleFuture<'a> {
Box::pin(async move {
if input.call_id == "captured-action" {
self.started.notify_one();
self.resume.notified().await;
}
})
}
}
let server = wiremock::MockServer::start().await;
let mut catalog = guardian_parent_catalog();
let mut preferred = catalog.models[0].clone();
preferred.slug = "codex-auto-review".to_string();
catalog.models.push(preferred);
mount_models_once(&server, catalog.clone()).await;
let pause = Arc::new(PauseFirstAction::default());
let mut extensions = ExtensionRegistryBuilder::<Config>::new();
extensions.tool_lifecycle_contributor(pause.clone());
let test = test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_model("guardian-parent-a")
.with_extensions(Arc::new(extensions.build()))
.with_config(|config| {
config.features.enable(Feature::StepModelSwitching).unwrap();
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
config.approvals_reviewer = ApprovalsReviewer::AutoReview;
config.model_reasoning_effort =
Some(codex_protocol::openai_models::ReasoningEffort::High);
config.model_reasoning_summary =
Some(codex_protocol::config_types::ReasoningSummary::Detailed);
})
.build_with_auto_env(&server)
.await?;
let models_manager = test.thread_manager.get_models_manager();
assert_eq!(models_manager.get_remote_models().await, catalog.models);
let mut events = Vec::new();
for (call_id, marker) in [("captured-action", "action-a"), ("new-action", "action-b")] {
events.push(sse(vec![
ev_response_created(call_id),
ev_function_call(
call_id,
"exec_command",
&json!({
"cmd": format!("printf {marker}"),
"sandbox_permissions": SandboxPermissions::RequireEscalated,
"justification": "Verify action-scoped Guardian fallback.",
})
.to_string(),
),
ev_completed(call_id),
]));
events.push(sse(vec![
ev_response_created("guardian"),
ev_assistant_message("assessment", r#"{"outcome":"allow"}"#),
ev_completed("guardian"),
]));
}
events.push(sse(vec![ev_response_created("done"), ev_completed("done")]));
let responses = mount_sse_sequence(&server, events).await;
test.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "run both protected commands".to_string(),
text_elements: Vec::new(),
}]))
.await?;
let turn_id = wait_for_event_match(&test.codex, |event| match event {
EventMsg::TurnStarted(event) => Some(event.turn_id.clone()),
_ => None,
})
.await;
tokio::time::timeout(Duration::from_secs(10), pause.started.notified()).await?;
let (reply, outcome) = tokio::sync::oneshot::channel();
test.codex
.submit(Op::TurnSettings {
turn_id,
update: codex_protocol::protocol::TurnSettingsUpdate {
model: Some("guardian-parent-b".to_string()),
effort: Some(Some(codex_protocol::openai_models::ReasoningEffort::Medium)),
summary: Some(codex_protocol::config_types::ReasoningSummary::Concise),
..Default::default()
},
reply,
})
.await?;
assert_eq!(
tokio::time::timeout(Duration::from_secs(10), outcome).await??,
codex_protocol::protocol::TurnSettingsUpdateOutcome::Applied
);
// A remains paused after the B settings request. Remove the preferred reviewer
// and replace A's metadata before review so fallback must use A's captured copy.
catalog
.models
.retain(|model| model.slug != "codex-auto-review");
catalog.models[0]
.model_messages
.as_mut()
.unwrap()
.auto_review = Some(AutoReviewMessages {
policy: Some("refreshed policy".to_string()),
policy_template: Some("refreshed template: {{ tenant_policy_config }}".to_string()),
node_repl_policy: None,
rejection_instructions: None,
timeout_instructions: None,
});
mount_models_once(&server, catalog.clone()).await;
assert_eq!(
models_manager
.raw_model_catalog(
RefreshStrategy::Online,
codex_core::test_support::default_http_client_factory(),
)
.await,
catalog
);
pause.resume.notify_one();
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let requests = responses.requests();
let reviews = requests
.iter()
.filter(|request| request.body_json()["client_metadata"]["x-openai-subagent"] == "guardian")
.map(|request| {
let body = request.body_json();
assert!(
request
.instructions_text()
.starts_with("captured template: captured policy\n")
);
json!([
body["model"],
body["reasoning"]["effort"],
body["reasoning"]["summary"]
])
})
.collect::<Vec<_>>();
assert_eq!(
reviews,
vec![
json!(["guardian-parent-a", "high", "detailed"]),
json!(["guardian-parent-b", "medium", "concise"]),
]
);
assert!(
requests[2]
.function_call_output("captured-action")
.to_string()
.contains("action-a")
);
assert!(
requests[4]
.function_call_output("new-action")
.to_string()
.contains("action-b")
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn guardian_session_is_reused_for_consecutive_tool_reviews_without_prewarm() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -964,6 +1180,7 @@ async fn guardian_session_is_reused_for_consecutive_tool_reviews_without_prewarm
let mut extensions = ExtensionRegistryBuilder::<Config>::new();
extensions.tool_lifecycle_contributor(lifecycle_recorder.clone());
let mut builder = test_codex()
.with_model("gpt-5.4")
.with_extensions(Arc::new(extensions.build()))
.with_config(move |config| {
let secret_file = config.cwd.join("guardian-secret.txt");
@@ -1141,9 +1358,25 @@ async fn guardian_session_is_reused_for_consecutive_tool_reviews_without_prewarm
matches!(event, EventMsg::TurnComplete(_))
})
.await;
test.submit_text_turn("run the second command that requires Guardian review")
test.codex
.start_or_steer_turn(
TurnInputRequest::user_input(vec![UserInput::Text {
text: "run the second command that requires Guardian review".into(),
text_elements: Vec::new(),
}])
.with_thread_settings(ThreadSettingsOverrides {
model: Some("gpt-5.5".to_string()),
..Default::default()
}),
)
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let requests = responses.requests();
assert_eq!(requests[0].body_json()["model"], "gpt-5.4");
assert_eq!(requests[4].body_json()["model"], "gpt-5.5");
assert!(
requests
.iter()
@@ -1236,7 +1469,8 @@ async fn guardian_session_is_reused_for_consecutive_tool_reviews_without_prewarm
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn interrupted_guardian_tool_review_aborts_without_executing_the_command() -> Result<()> {
async fn interrupted_guardian_review_across_model_change_does_not_execute_the_command() -> Result<()>
{
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
skip_if_wine_exec!(
@@ -1252,137 +1486,171 @@ async fn interrupted_guardian_tool_review_aborts_without_executing_the_command()
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
};
let sandbox_policy_for_config = sandbox_policy.clone();
let mut builder = test_codex().with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
config.approvals_reviewer = ApprovalsReviewer::AutoReview;
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("guardian-interrupted.txt");
let command = format!("printf should-not-run > {}", output_file.display());
let tool_args = json!({
"cmd": command,
"cmd": "printf should-not-run > guardian-interrupted.txt",
"yield_time_ms": 1_000_u64,
"sandbox_permissions": SandboxPermissions::RequireEscalated,
"justification": "Exercise interrupted Guardian approval.",
});
mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-parent-interrupted-tool"),
ev_function_call(
"exec-call-interrupted",
"exec_command",
&serde_json::to_string(&tool_args)?,
),
ev_completed("resp-parent-interrupted-tool"),
]),
)
let (release_review, review_gate) = tokio::sync::oneshot::channel();
let (streaming, mut completions) = start_streaming_sse_server(vec![
vec![StreamingSseChunk {
gate: None,
body: sse(vec![
ev_response_created("resp-parent-interrupted-tool"),
ev_function_call(
"exec-call-interrupted",
"exec_command",
&tool_args.to_string(),
),
ev_completed("resp-parent-interrupted-tool"),
]),
}],
vec![StreamingSseChunk {
gate: Some(review_gate),
body: sse(vec![
ev_response_created("resp-guardian-interrupted-review"),
ev_assistant_message("assessment", r#"{"outcome":"allow"}"#),
ev_completed("resp-guardian-interrupted-review"),
]),
}],
vec![StreamingSseChunk {
gate: None,
body: sse(vec![
ev_response_created("resp-parent-after-interrupted-review"),
ev_assistant_message("msg-parent-after-interrupted-review", "next turn completed"),
ev_completed("resp-parent-after-interrupted-review"),
]),
}],
])
.await;
let pending_guardian = mount_response_once_match(
&server,
|request: &wiremock::Request| {
serde_json::from_slice::<Value>(&request.body)
.ok()
.and_then(|body| {
body.pointer("/client_metadata/x-openai-subagent")
.and_then(Value::as_str)
.map(str::to_owned)
})
.as_deref()
== Some("guardian")
},
sse_response(sse(vec![
ev_response_created("resp-guardian-interrupted-review"),
ev_assistant_message(
"msg-guardian-interrupted-review",
&json!({
"risk_level": "low",
"user_authorization": "high",
"outcome": "allow",
"rationale": "This review should be interrupted before it completes.",
})
.to_string(),
),
ev_completed("resp-guardian-interrupted-review"),
]))
.set_delay(Duration::from_millis(200)),
)
.await;
test.codex
.start_or_steer_turn(
TurnInputRequest::user_input(vec![UserInput::Text {
text: "interrupt a Guardian-reviewed command".into(),
text_elements: Vec::new(),
}])
.with_thread_settings(ThreadSettingsOverrides {
environments: Some(local_selections(test.config.cwd.clone())),
approval_policy: Some(approval_policy),
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
sandbox_policy: Some(sandbox_policy),
..Default::default()
}),
)
let base_url = format!("{}/v1", streaming.uri());
let test = test_codex()
.with_model("guardian-parent-a")
.with_config(move |config| {
config.model_provider.base_url = Some(base_url);
config.model_catalog = Some(guardian_parent_catalog());
config.features.enable(Feature::StepModelSwitching).unwrap();
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
config.approvals_reviewer = ApprovalsReviewer::AutoReview;
config
.set_legacy_sandbox_policy(sandbox_policy)
.expect("set sandbox policy");
})
.build_with_auto_env(&server)
.await?;
tokio::time::timeout(Duration::from_secs(5), async {
while pending_guardian.requests().is_empty() {
tokio::task::yield_now().await;
}
test.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "interrupt a Guardian-reviewed command".into(),
text_elements: Vec::new(),
}]))
.await?;
let turn_id = wait_for_event_match(&test.codex, |event| match event {
EventMsg::TurnStarted(event) => Some(event.turn_id.clone()),
_ => None,
})
.await;
tokio::time::timeout(
Duration::from_secs(5),
streaming.wait_for_request_count(/*count*/ 2),
)
.await
.context("timed out waiting for Guardian review request")?;
let (reply, outcome) = tokio::sync::oneshot::channel();
test.codex
.submit(Op::TurnSettings {
turn_id,
update: codex_protocol::protocol::TurnSettingsUpdate {
model: Some("guardian-parent-b".to_string()),
..Default::default()
},
reply,
})
.await?;
assert_eq!(
tokio::time::timeout(Duration::from_secs(10), outcome).await??,
codex_protocol::protocol::TurnSettingsUpdateOutcome::Applied
);
test.codex.submit(Op::Interrupt).await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnAborted(_))
})
.await;
tokio::time::sleep(Duration::from_millis(350)).await;
assert!(
!output_file.exists(),
"the interrupted Guardian-reviewed command executed after its delayed approval response"
);
let follow_up = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-parent-after-interrupted-review"),
ev_assistant_message("msg-parent-after-interrupted-review", "next turn completed"),
ev_completed("resp-parent-after-interrupted-review"),
]),
)
.await;
release_review
.send(())
.expect("release interrupted review response");
// The cancelled connection may close before the server finishes writing.
let _ = tokio::time::timeout(Duration::from_secs(5), completions.remove(1)).await?;
test.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "verify Guardian cancellation left the next turn clean".into(),
text_elements: Vec::new(),
}]))
.start_or_steer_turn(
TurnInputRequest::user_input(vec![UserInput::Text {
text: "verify Guardian cancellation left the next turn clean".into(),
text_elements: Vec::new(),
}])
.with_thread_settings(ThreadSettingsOverrides {
model: Some("guardian-parent-b".to_string()),
..Default::default()
}),
)
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let follow_up_request = follow_up.single_request();
assert!(
follow_up_request
.body_contains_text("verify Guardian cancellation left the next turn clean")
let requests = streaming
.requests()
.await
.into_iter()
.map(|body| serde_json::from_slice::<Value>(&body))
.collect::<serde_json::Result<Vec<_>>>()?;
assert_eq!(
requests
.iter()
.map(|body| body["model"].clone())
.collect::<Vec<_>>(),
vec![
json!("guardian-parent-a"),
json!("guardian-parent-a"),
json!("guardian-parent-b")
]
);
assert!(follow_up_request.has_function_call("exec-call-interrupted"));
let interrupted_output = follow_up_request
.function_call_output_text("exec-call-interrupted")
assert_eq!(
requests[1]["client_metadata"]["x-openai-subagent"],
"guardian"
);
let follow_up_input = requests[2]["input"].as_array().expect("follow-up input");
assert!(follow_up_input.iter().any(|item| {
item["type"] == "function_call" && item["call_id"] == "exec-call-interrupted"
}));
let interrupted_output = follow_up_input
.iter()
.find(|item| {
item["type"] == "function_call_output" && item["call_id"] == "exec-call-interrupted"
})
.expect("next turn should contain the interrupted command's tool output");
assert!(
interrupted_output.contains("aborted"),
interrupted_output["output"]
.as_str()
.expect("tool output text")
.contains("aborted"),
"unexpected interrupted tool output: {interrupted_output}"
);
assert!(
matches!(
test.fs()
.get_metadata(
&test.workspace_path_uri("guardian-interrupted.txt")?,
Default::default(),
/*sandbox*/ None,
)
.await,
Err(error) if error.kind() == std::io::ErrorKind::NotFound
),
"the interrupted Guardian-reviewed command executed after its delayed approval response"
);
streaming.shutdown().await;
Ok(())
}