codex / codex-rs /core /src /guardian /tests.rs
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
52a9af3 verified
Raw History Blame Contribute Delete
161 kB
use super::*;
use crate::config::Config;
use crate::config::ConfigOverrides;
use crate::config::Constrained;
use crate::config::ManagedFeatures;
use crate::config::NetworkProxySpec;
use crate::config::PermissionProfileSnapshot;
use crate::config::test_config;
use crate::environment_selection::TurnEnvironmentState;
use crate::guardian::approval_request::guardian_request_target_item_id;
use crate::guardian::prompt::BUNDLED_GUARDIAN_POLICY;
use crate::guardian::prompt::BUNDLED_GUARDIAN_POLICY_TEMPLATE;
use crate::guardian::prompt::guardian_policy_prompt_with_config_and_template;
use crate::guardian::review::guardian_review_session_config;
use crate::session::session::Session;
use crate::session::tests::update_turn_settings_for_test;
use crate::session::turn_context::TurnContext;
use crate::test_support;
use codex_analytics::GuardianApprovalRequestSource;
use codex_config::ConfigLayerStack;
use codex_config::FeatureRequirementsToml;
use codex_config::NetworkConstraints;
use codex_config::NetworkDomainPermissionToml;
use codex_config::NetworkDomainPermissionsToml;
use codex_config::RequirementSource;
use codex_config::Sourced;
use codex_config::config_toml::ConfigToml;
use codex_config::types::McpServerConfig;
use codex_exec_server::LOCAL_FS;
use codex_features::Feature;
use codex_guardian_context::ConversationTranscriptEntry;
use codex_guardian_context::ConversationTranscriptEntryKind;
use codex_history::RolloutItem;
use codex_model_provider::create_model_provider;
use codex_model_provider_info::AMAZON_BEDROCK_GPT_5_4_MODEL_ID;
use codex_model_provider_info::AMAZON_BEDROCK_PROVIDER_ID;
use codex_model_provider_info::ModelProviderInfo;
use codex_model_provider_info::OPENAI_PROVIDER_ID;
use codex_models_manager::manager::StaticModelsManager;
use codex_network_proxy::NetworkProxyConfig;
use codex_protocol::ThreadId;
use codex_protocol::approvals::GuardianAssessmentAction;
use codex_protocol::approvals::NetworkApprovalProtocol;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::models::ContentItem;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::ResponseItem;
use codex_protocol::models::SandboxPermissions;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::GranularApprovalConfig;
use codex_protocol::protocol::GuardianAssessmentStatus;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::TurnCompleteEvent;
use codex_utils_path_uri::LegacyAppPathString;
use codex_utils_path_uri::PathUri;
use core_test_support::PathBufExt;
use core_test_support::TempDirExt;
use core_test_support::context_snapshot;
use core_test_support::context_snapshot::ContextSnapshotOptions;
use core_test_support::context_snapshot::SnapshotEntry;
use core_test_support::responses::ResponsesRequest;
use core_test_support::responses::assert_parent_turn;
use core_test_support::responses::assert_root_turn;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_response_sequence;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::sse_failed;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::streaming_sse::StreamingSseChunk;
use core_test_support::streaming_sse::start_streaming_sse_server;
use core_test_support::test_path_buf;
use insta::Settings;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tempfile::TempDir;
use tokio_util::sync::CancellationToken;
fn fixed_guardian_parent_session_id() -> ThreadId {
ThreadId::from_string("11111111-1111-4111-8111-111111111111")
.expect("fixed parent session id should be a valid UUID")
}
const GUARDIAN_MEMORY_CONTEXT_PROBE: &str = "guardian memory context probe";
const GUARDIAN_SKILL_NAME: &str = "guardian-context-probe";
const GUARDIAN_SKILL_BODY_PROBE: &str = "guardian skill body probe";
// The memories extension depends on codex-core, so this probe verifies the nested Guardian config
// at request assembly without introducing a circular test dependency.
struct GuardianMemoryContextEnabled(bool);
struct GuardianMemoryContextProbe;
impl codex_extension_api::ThreadLifecycleContributor<Config> for GuardianMemoryContextProbe {
fn on_thread_start<'a>(
&'a self,
input: codex_extension_api::ThreadStartInput<'a, Config>,
) -> codex_extension_api::ExtensionFuture<'a, ()> {
Box::pin(async move {
input.thread_store.insert(GuardianMemoryContextEnabled(
input.config.memories.use_memories,
));
})
}
}
impl codex_extension_api::ContextContributor for GuardianMemoryContextProbe {
fn contribute_thread_context<'a>(
&'a self,
_session_store: &'a codex_extension_api::ExtensionData,
thread_store: &'a codex_extension_api::ExtensionData,
) -> codex_extension_api::ExtensionFuture<'a, Vec<codex_extension_api::PromptFragment>> {
Box::pin(async move {
if thread_store
.get::<GuardianMemoryContextEnabled>()
.is_some_and(|enabled| enabled.0)
{
vec![codex_extension_api::PromptFragment::developer_policy(
GUARDIAN_MEMORY_CONTEXT_PROBE,
codex_extension_api::ContentItemKind(
"guardian.memory_context_probe".to_string(),
),
)]
} else {
Vec::new()
}
})
}
}
async fn guardian_test_session_and_turn(
server: &wiremock::MockServer,
) -> (Arc<Session>, Arc<TurnContext>) {
guardian_test_session_and_turn_with_base_url(server.uri().as_str()).await
}
async fn guardian_test_session_turn_and_rx(
server: &wiremock::MockServer,
) -> (
Arc<Session>,
Arc<TurnContext>,
async_channel::Receiver<Event>,
) {
let (mut session, mut turn, rx) =
crate::session::tests::make_session_and_context_with_rx().await;
Arc::get_mut(&mut session)
.expect("session should be uniquely owned")
.thread_id = fixed_guardian_parent_session_id();
let mut config = (*turn.config).clone();
config.model_provider.base_url = Some(format!("{}/v1", server.uri()));
let config = Arc::new(config);
let models_manager = test_support::models_manager_with_provider(
config.codex_home.to_path_buf(),
Arc::clone(&session.services.auth_manager),
config.model_provider.clone(),
);
Arc::get_mut(&mut session)
.expect("session should be uniquely owned")
.services
.models_manager = models_manager;
crate::guardian::test_host::install(&session, &config);
let turn_mut = Arc::get_mut(&mut turn).expect("turn should be uniquely owned");
turn_mut.config = Arc::clone(&config);
turn_mut.provider =
create_model_provider(config.model_provider.clone(), turn_mut.auth_manager.clone());
(session, turn, rx)
}
fn native_guardian_cwd(path: &str) -> LegacyAppPathString {
LegacyAppPathString::from_abs_path(&test_path_buf(path).abs())
}
fn guardian_exec_command_request(id: &str) -> GuardianApprovalRequest {
GuardianApprovalRequest::ExecCommand {
id: id.to_string(),
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the reviewed docs fix.".to_string()),
tty: false,
}
}
fn guardian_mcp_request(server: &str, tool_name: &str) -> GuardianApprovalRequest {
GuardianApprovalRequest::McpToolCall {
id: "mcp-1".to_string(),
server: server.to_string(),
tool_name: tool_name.to_string(),
arguments: Some(serde_json::json!({
"code": "await browser.open('https://example.com')",
})),
connector_id: Some("connector-1".to_string()),
connector_name: Some("Connected tools".to_string()),
connector_description: None,
connected_account_email: None,
tool_title: Some("Execute JavaScript".to_string()),
tool_description: None,
annotations: Some(GuardianMcpAnnotations {
destructive_hint: None,
open_world_hint: Some(true),
read_only_hint: None,
}),
}
}
async fn guardian_test_session_and_turn_with_base_url(
base_url: &str,
) -> (Arc<Session>, Arc<TurnContext>) {
let (mut session, mut turn) = crate::session::tests::make_session_and_context().await;
session.thread_id = fixed_guardian_parent_session_id();
let mut config = (*turn.config).clone();
config.model_provider.base_url = Some(format!("{base_url}/v1"));
let config = Arc::new(config);
let models_manager = test_support::models_manager_with_provider(
config.codex_home.to_path_buf(),
Arc::clone(&session.services.auth_manager),
config.model_provider.clone(),
);
session.services.models_manager = models_manager;
crate::guardian::test_host::install(&session, &config);
turn.config = Arc::clone(&config);
turn.provider = create_model_provider(config.model_provider.clone(), turn.auth_manager.clone());
(Arc::new(session), Arc::new(turn))
}
async fn seed_guardian_parent_history(session: &Arc<Session>, turn: &Arc<TurnContext>) {
session
.record_conversation_items(
turn.as_ref(),
turn.model_info(),
&[
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "Please check the repo visibility and push the docs fix if needed."
.to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
ResponseItem::FunctionCall {
id: None,
name: "gh_repo_view".to_string(),
namespace: None,
arguments: "{\"repo\":\"openai/codex\"}".to_string(),
call_id: "call-1".to_string(),
encrypted_function_args: None,
internal_chat_message_metadata_passthrough: None,
},
ResponseItem::FunctionCallOutput {
id: None,
call_id: Some("call-1".to_string()),
name: None,
namespace: None,
output: codex_protocol::models::FunctionCallOutputPayload::from_text(
"repo visibility: public".to_string(),
),
internal_chat_message_metadata_passthrough: None,
},
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "The repo is public; I now need approval to push the docs fix."
.to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
],
)
.await;
}
fn rollout_item_contains_message_text(item: &RolloutItem, needle: &str) -> bool {
let RolloutItem::ResponseItem(response_item) = item else {
return false;
};
response_item_contains_message_text(response_item, needle)
}
fn response_item_contains_message_text(item: &ResponseItem, needle: &str) -> bool {
let ResponseItem::Message { content, .. } = item else {
return false;
};
content.iter().any(|item| match item {
ContentItem::InputText { text } | ContentItem::OutputText { text } => text.contains(needle),
ContentItem::InputImage { .. } | ContentItem::InputAudio { .. } => false,
})
}
fn format_guardian_requests_snapshot(
scenario: &str,
requests: &[(&str, &ResponsesRequest)],
) -> String {
let bodies = requests
.iter()
.map(|(_, request)| {
let mut body = request.body_json();
// Project instructions depend on the checkout. Leave discovery enabled and omit
// only this content from these snapshots, as the previous renderer did.
for item in body["input"].as_array_mut().expect("request input") {
if item["type"] == "message" && item["role"] == "user" {
item["content"]
.as_array_mut()
.expect("message content")
.retain(|part| {
!part["text"]
.as_str()
.is_some_and(|text| text.starts_with("# AGENTS.md instructions"))
});
}
}
body
})
.collect::<Vec<_>>();
let entries = requests
.iter()
.zip(&bodies)
.map(|((label, _), body)| SnapshotEntry::body(body).labeled(label))
.collect::<Vec<_>>();
context_snapshot::format_context_snapshot(
scenario,
&entries,
&ContextSnapshotOptions::default().rewrite_known_segments(),
)
}
fn normalize_guardian_snapshot_paths(text: String) -> String {
let mut text = text;
for canonical_path in ["/repo/codex-rs/core", "/repo"] {
let platform_path = test_path_buf(canonical_path).display().to_string();
if platform_path == canonical_path {
continue;
}
let escaped_platform_path = serde_json::to_string(&platform_path)
.expect("test path should serialize")
.trim_matches('"')
.to_string();
// Function-call JSON strings are escaped once more by the one-line item renderer.
let rendered_platform_path = escaped_platform_path.replace('\\', "\\\\");
text = text
.replace(&rendered_platform_path, canonical_path)
.replace(&escaped_platform_path, canonical_path)
.replace(&platform_path, canonical_path);
}
text
}
fn guardian_prompt_text(items: &[codex_protocol::user_input::UserInput]) -> String {
items
.iter()
.map(|item| match item {
codex_protocol::user_input::UserInput::Text { text, .. } => text.as_str(),
_ => "",
})
.collect::<String>()
}
fn last_user_message_text_from_body(body: &serde_json::Value) -> String {
body["input"]
.as_array()
.expect("request input array")
.iter()
.filter(|item| item.get("role").and_then(serde_json::Value::as_str) == Some("user"))
.filter_map(|item| item.get("content").and_then(serde_json::Value::as_array))
.next_back()
.expect("user message content")
.iter()
.filter(|span| span.get("type").and_then(serde_json::Value::as_str) == Some("input_text"))
.filter_map(|span| span.get("text").and_then(serde_json::Value::as_str))
.collect::<String>()
}
#[tokio::test(flavor = "current_thread")]
async fn build_guardian_prompt_full_mode_preserves_initial_review_format() -> anyhow::Result<()> {
let (session, turn) = guardian_test_session_and_turn_with_base_url("http://localhost").await;
seed_guardian_parent_history(&session, &turn).await;
let prompt = build_guardian_prompt_items(
session.as_ref(),
Some("Sandbox denied outbound git push to github.com.".to_string()),
GuardianApprovalRequest::ExecCommand {
id: "shell-1".to_string(),
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the reviewed docs fix.".to_string()),
tty: false,
},
GuardianPromptMode::Full,
)
.await?;
let text = guardian_prompt_text(&prompt.context.into_user_inputs()?);
assert!(text.contains("whose request action you are assessing"));
assert!(text.contains(">>> TRANSCRIPT START\n"));
assert!(text.contains(">>> TRANSCRIPT END\n"));
assert!(text.contains("The Codex agent has requested the following action:\n"));
assert!(!text.contains("TRANSCRIPT DELTA"));
assert_eq!(prompt.transcript_cursor.transcript_entry_count, 4);
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn build_guardian_prompt_prefers_retry_reason_over_approval_reason() -> anyhow::Result<()> {
let (session, turn) = guardian_test_session_and_turn_with_base_url("http://localhost").await;
seed_guardian_parent_history(&session, &turn).await;
let context = GuardianReviewContext::from(&turn);
let prompt = build_guardian_prompt_items_with_parent_turn(
session.as_ref(),
session.conversation_history_snapshot().await.as_ref(),
Some(&context),
ApprovalRequestReasons {
approval: Some("A policy rule requires approval.".to_string()),
retry: Some("The sandbox blocked the initial command.".to_string()),
},
GuardianApprovalRequest::ExecCommand {
id: "shell-1".to_string(),
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: None,
tty: false,
},
GuardianPromptMode::Full,
/*reviewed_node_repl_evidence_sequence*/ 0,
)
.await?;
let text = guardian_prompt_text(&prompt.context.into_user_inputs()?);
assert!(text.contains("Retry reason:\nThe sandbox blocked the initial command.\n\n"));
assert!(!text.contains("A policy rule requires approval."));
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn build_guardian_prompt_truncates_oversized_approval_reason() -> anyhow::Result<()> {
let (session, turn) = guardian_test_session_and_turn_with_base_url("http://localhost").await;
seed_guardian_parent_history(&session, &turn).await;
let context = GuardianReviewContext::from(&turn);
let approval_reason = format!("policy-start {} policy-end", "x".repeat(10_000));
let expected_reason = codex_utils_output_truncation::truncate_text(
&approval_reason,
codex_utils_output_truncation::TruncationPolicy::Tokens(/*tokens*/ 512),
);
let prompt = build_guardian_prompt_items_with_parent_turn(
session.as_ref(),
session.conversation_history_snapshot().await.as_ref(),
Some(&context),
ApprovalRequestReasons {
approval: Some(approval_reason),
retry: None,
},
GuardianApprovalRequest::ExecCommand {
id: "shell-1".to_string(),
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: None,
tty: false,
},
GuardianPromptMode::Full,
/*reviewed_node_repl_evidence_sequence*/ 0,
)
.await?;
let items = prompt.context.into_user_inputs()?;
let reason_item = items
.iter()
.find_map(|item| match item {
codex_protocol::user_input::UserInput::Text { text, .. }
if text.contains("tokens truncated") =>
{
Some(text)
}
_ => None,
})
.expect("oversized approval reason should include a truncation marker");
assert!(reason_item.starts_with("policy-start"));
assert!(reason_item.ends_with("policy-end\n\n"));
assert_eq!(reason_item, &format!("{expected_reason}\n\n"));
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn build_guardian_prompt_includes_parent_turn_denied_reads() -> anyhow::Result<()> {
let (mut session, mut turn) = crate::session::tests::make_session_and_context().await;
session.thread_id = fixed_guardian_parent_session_id();
let workspace_root = test_path_buf("/repo").abs();
let second_workspace_root = test_path_buf("/another-repo").abs();
let denied_root = workspace_root.join("private");
let second_denied_root = second_workspace_root.join("private");
let denied_glob = test_path_buf("/repo/private/**").display().to_string();
let environment_permission_profile = PermissionProfile::from_runtime_permissions(
&FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: codex_protocol::permissions::FileSystemSpecialPath::Root,
},
access: FileSystemAccessMode::Read,
missing_path_behavior: None,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: codex_protocol::permissions::FileSystemSpecialPath::project_roots(Some(
"private".to_string(),
)),
},
access: FileSystemAccessMode::Deny,
missing_path_behavior: None,
},
FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: denied_glob.clone(),
},
access: FileSystemAccessMode::Deny,
missing_path_behavior: None,
},
]),
NetworkSandboxPolicy::Restricted,
);
let TurnEnvironmentState::Ready(environment) = &mut turn.environments.environments[0] else {
panic!("parent environment should be ready");
};
environment.config_mut().permission_profile =
PermissionProfileSnapshot::legacy(environment_permission_profile);
environment.config_mut().workspace_roots = vec![
PathUri::from_abs_path(&workspace_root),
PathUri::from_abs_path(&second_workspace_root),
];
let session = Arc::new(session);
let turn = Arc::new(turn);
seed_guardian_parent_history(&session, &turn).await;
let context = GuardianReviewContext::from(&turn);
let prompt = build_guardian_prompt_items_with_parent_turn(
session.as_ref(),
session.conversation_history_snapshot().await.as_ref(),
Some(&context),
ApprovalRequestReasons {
approval: None,
retry: Some("Sandbox denied reading /repo/private/secret.txt.".to_string()),
},
GuardianApprovalRequest::ExecCommand {
id: "shell-1".to_string(),
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
command: vec!["cat".to_string(), "/repo/private/secret.txt".to_string()],
cwd: test_path_buf("/repo").abs().into(),
guardian_cwd: native_guardian_cwd("/repo"),
sandbox_permissions: crate::sandboxing::SandboxPermissions::RequireEscalated,
additional_permissions: None,
justification: Some("Need to inspect the secret file.".to_string()),
tty: false,
},
GuardianPromptMode::Full,
/*reviewed_node_repl_evidence_sequence*/ 0,
)
.await?;
let text = guardian_prompt_text(&prompt.context.into_user_inputs()?);
assert!(text.contains("PARENT TURN PERMISSION CONTEXT START"));
assert!(text.contains("do not approve escalation whose purpose is to read them"));
assert!(text.contains(denied_root.to_string_lossy().as_ref()));
assert!(text.contains(second_denied_root.to_string_lossy().as_ref()));
assert!(text.contains(&format!("glob `{denied_glob}`")));
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn build_guardian_prompt_delta_mode_preserves_original_numbering() -> anyhow::Result<()> {
let (session, turn) = guardian_test_session_and_turn_with_base_url("http://localhost").await;
seed_guardian_parent_history(&session, &turn).await;
session
.record_conversation_items(
turn.as_ref(),
turn.model_info(),
&[
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "Please also push the second docs fix.".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "I need approval for the second push.".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
],
)
.await;
let prompt = build_guardian_prompt_items(
session.as_ref(),
/*retry_reason*/ None,
GuardianApprovalRequest::ExecCommand {
id: "shell-2".to_string(),
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the second docs fix.".to_string()),
tty: false,
},
GuardianPromptMode::Delta {
cursor: GuardianTranscriptCursor {
parent_history_version: 0,
transcript_entry_count: 4,
},
},
)
.await?;
let text = guardian_prompt_text(&prompt.context.into_user_inputs()?);
assert!(text.contains("added since your last approval assessment"));
assert!(text.contains(">>> TRANSCRIPT DELTA START\n"));
assert!(text.contains("[5] user: Please also push the second docs fix."));
assert!(text.contains("[6] assistant: I need approval for the second push."));
assert!(text.contains(">>> TRANSCRIPT DELTA END\n"));
assert!(text.contains("The Codex agent has requested the following next action:\n"));
assert!(!text.contains("[1] user: Please check the repo visibility"));
assert_eq!(prompt.transcript_cursor.transcript_entry_count, 6);
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn build_guardian_prompt_delta_mode_handles_empty_delta() -> anyhow::Result<()> {
let (session, turn) = guardian_test_session_and_turn_with_base_url("http://localhost").await;
seed_guardian_parent_history(&session, &turn).await;
let prompt = build_guardian_prompt_items(
session.as_ref(),
/*retry_reason*/ None,
GuardianApprovalRequest::ExecCommand {
id: "shell-2".to_string(),
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the second docs fix.".to_string()),
tty: false,
},
GuardianPromptMode::Delta {
cursor: GuardianTranscriptCursor {
parent_history_version: 0,
transcript_entry_count: 4,
},
},
)
.await?;
let text = guardian_prompt_text(&prompt.context.into_user_inputs()?);
assert!(text.contains(">>> TRANSCRIPT DELTA START\n"));
assert!(text.contains("<no retained transcript delta entries>"));
assert!(text.contains(">>> TRANSCRIPT DELTA END\n"));
assert_eq!(prompt.transcript_cursor.transcript_entry_count, 4);
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn build_guardian_prompt_stale_delta_cursor_falls_back_to_full_prompt() -> anyhow::Result<()>
{
let (session, turn) = guardian_test_session_and_turn_with_base_url("http://localhost").await;
seed_guardian_parent_history(&session, &turn).await;
let prompt = build_guardian_prompt_items(
session.as_ref(),
/*retry_reason*/ None,
GuardianApprovalRequest::ExecCommand {
id: "shell-3".to_string(),
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the docs fix.".to_string()),
tty: false,
},
GuardianPromptMode::Delta {
cursor: GuardianTranscriptCursor {
parent_history_version: 0,
transcript_entry_count: 99,
},
},
)
.await?;
let text = guardian_prompt_text(&prompt.context.into_user_inputs()?);
assert!(text.contains("whose request action you are assessing"));
assert!(text.contains(">>> TRANSCRIPT START\n"));
assert!(!text.contains("TRANSCRIPT DELTA"));
assert_eq!(prompt.transcript_cursor.transcript_entry_count, 4);
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn build_guardian_prompt_stale_delta_version_falls_back_to_full_prompt() -> anyhow::Result<()>
{
let (session, turn) = guardian_test_session_and_turn_with_base_url("http://localhost").await;
seed_guardian_parent_history(&session, &turn).await;
session
.replace_history(
vec![
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "Compacted retained user request.".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "Compacted summary of earlier guardian context.".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
],
/*reference_context_item*/ None,
)
.await;
session
.record_conversation_items(
turn.as_ref(),
turn.model_info(),
&[
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "Please push after the compaction.".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "I need approval for the post-compaction push.".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
],
)
.await;
let prompt = build_guardian_prompt_items(
session.as_ref(),
/*retry_reason*/ None,
GuardianApprovalRequest::ExecCommand {
id: "shell-4".to_string(),
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push after the compaction.".to_string()),
tty: false,
},
GuardianPromptMode::Delta {
cursor: GuardianTranscriptCursor {
parent_history_version: 0,
transcript_entry_count: 4,
},
},
)
.await?;
let text = guardian_prompt_text(&prompt.context.into_user_inputs()?);
assert!(text.contains("whose request action you are assessing"));
assert!(text.contains(">>> TRANSCRIPT START\n"));
assert!(!text.contains("TRANSCRIPT DELTA"));
assert!(text.contains("[3] user: Please push after the compaction."));
assert!(text.contains("[4] assistant: I need approval for the post-compaction push."));
assert_eq!(prompt.transcript_cursor.parent_history_version, 1);
assert_eq!(prompt.transcript_cursor.transcript_entry_count, 4);
Ok(())
}
fn collect_guardian_transcript_entries(
history: &dyn codex_guardian_context::SectionHistory,
node_repl_result_token_limit: usize,
) -> Vec<ConversationTranscriptEntry> {
prompt::collect_guardian_context(
history,
node_repl_result_token_limit,
&[],
&[],
/*planned_action*/ None,
/*permissions*/ None,
/*node_repl*/ None,
)
.expect("collect Guardian context")
.transcript_entries()
.to_vec()
}
#[test]
fn collect_guardian_transcript_entries_skips_contextual_user_messages() {
let items = vec![
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "<environment_context>\n<cwd>/tmp</cwd>\n</environment_context>".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "hello".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
];
let entries = collect_guardian_transcript_entries(&items, GUARDIAN_MAX_TOOL_ENTRY_TOKENS);
assert_eq!(entries.len(), 1);
assert_eq!(
entries[0],
ConversationTranscriptEntry {
kind: ConversationTranscriptEntryKind::ProtectedAssistant,
text: "hello".to_string(),
original_bytes: "hello".len(),
}
);
}
#[test]
fn collect_guardian_transcript_entries_keeps_manual_approval_developer_message() {
let approval_text =
format!("{AUTO_REVIEW_DENIED_ACTION_APPROVAL_DEVELOPER_PREFIX}\n\nApproved action:\n{{}}");
let items = vec![
ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: "ordinary developer context".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
ResponseItem::Message {
id: None,
role: "developer".to_string(),
content: vec![ContentItem::InputText {
text: approval_text.clone(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
];
let entries = collect_guardian_transcript_entries(&items, GUARDIAN_MAX_TOOL_ENTRY_TOKENS);
assert_eq!(
entries,
vec![ConversationTranscriptEntry {
kind: ConversationTranscriptEntryKind::Developer,
original_bytes: approval_text.len(),
text: approval_text,
}]
);
}
#[test]
fn collect_guardian_transcript_entries_includes_recent_tool_calls_and_output() {
let mut items = vec![
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "check the repo".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
ResponseItem::FunctionCall {
id: None,
name: "read_file".to_string(),
namespace: None,
arguments: "{\"path\":\"README.md\"}".to_string(),
call_id: "call-1".to_string(),
encrypted_function_args: None,
internal_chat_message_metadata_passthrough: None,
},
ResponseItem::FunctionCallOutput {
id: None,
call_id: Some("call-1".to_string()),
name: None,
namespace: None,
output: codex_protocol::models::FunctionCallOutputPayload::from_text(
"repo is public".to_string(),
),
internal_chat_message_metadata_passthrough: None,
},
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "I need to push a fix".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
];
let entries = collect_guardian_transcript_entries(&items, GUARDIAN_MAX_TOOL_ENTRY_TOKENS);
assert_eq!(entries.len(), 4);
assert_eq!(
entries[1],
ConversationTranscriptEntry {
kind: ConversationTranscriptEntryKind::ToolCall("tool read_file call".to_string()),
text: "{\"path\":\"README.md\"}".to_string(),
original_bytes: "{\"path\":\"README.md\"}".len(),
}
);
assert_eq!(
entries[2],
ConversationTranscriptEntry {
kind: ConversationTranscriptEntryKind::ToolOutput("tool read_file result".to_string()),
text: "repo is public".to_string(),
original_bytes: "repo is public".len(),
}
);
if let ResponseItem::FunctionCall { namespace, .. } = &mut items[1] {
*namespace = Some("mcp__node_repl__".to_string());
}
assert!(matches!(
collect_guardian_transcript_entries(&items, GUARDIAN_MAX_TOOL_ENTRY_TOKENS)[2].kind,
ConversationTranscriptEntryKind::NodeReplToolOutput(_)
));
let oversized_result = "é🙂".repeat(/*n*/ 10_000);
if let ResponseItem::FunctionCallOutput { output, .. } = &mut items[2] {
*output =
codex_protocol::models::FunctionCallOutputPayload::from_text(oversized_result.clone());
}
for token_cap in [
GUARDIAN_MAX_TOOL_ENTRY_TOKENS,
GUARDIAN_MAX_NODE_REPL_TOOL_RESULT_TOKENS,
] {
let entries = collect_guardian_transcript_entries(&items, token_cap);
assert_eq!(
entries[2],
ConversationTranscriptEntry {
kind: ConversationTranscriptEntryKind::NodeReplToolOutput(
"tool read_file result".to_string()
),
text: guardian_truncate_text(&oversized_result, token_cap).0,
original_bytes: oversized_result.len(),
}
);
assert_eq!(entries.len(), 4);
assert_eq!(
render_guardian_transcript_entries(&entries),
(
vec![
"[1] user: check the repo".to_string(),
"[2] tool read_file call: {\"path\":\"README.md\"}".to_string(),
format!("[3] tool read_file result: {}", entries[2].text),
"[4] assistant: I need to push a fix".to_string(),
],
None,
)
);
}
}
#[test]
fn guardian_truncate_text_keeps_prefix_suffix_and_xml_marker() {
let content = "prefix ".repeat(200) + &" suffix".repeat(200);
let (truncated, was_truncated) = guardian_truncate_text(&content, /*token_cap*/ 20);
assert!(truncated.starts_with("prefix"));
assert!(truncated.contains("<truncated omitted_approx_tokens=\""));
assert!(truncated.ends_with("suffix"));
assert!(was_truncated);
}
#[test]
fn guardian_approval_request_to_json_renders_mcp_tool_call_shape() -> serde_json::Result<()> {
let action = GuardianApprovalRequest::McpToolCall {
id: "call-1".to_string(),
server: "mcp_server".to_string(),
tool_name: "browser_navigate".to_string(),
arguments: Some(serde_json::json!({
"url": "https://example.com",
})),
connector_id: None,
connector_name: Some("Playwright".to_string()),
connector_description: None,
connected_account_email: Some("owner@example.com".to_string()),
tool_title: Some("Navigate".to_string()),
tool_description: None,
annotations: Some(GuardianMcpAnnotations {
destructive_hint: Some(true),
open_world_hint: None,
read_only_hint: Some(false),
}),
};
assert_eq!(
guardian_approval_request_to_json(&action)?,
serde_json::json!({
"tool": "mcp_tool_call",
"server": "mcp_server",
"tool_name": "browser_navigate",
"arguments": {
"url": "https://example.com",
},
"connector_name": "Playwright",
"connected_account_email": "owner@example.com",
"tool_title": "Navigate",
"annotations": {
"destructive_hint": true,
"read_only_hint": false,
},
})
);
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn build_guardian_prompt_items_keeps_required_node_repl_reviews_generic() -> anyhow::Result<()>
{
let (session, mut turn) =
guardian_test_session_and_turn_with_base_url("http://localhost").await;
update_turn_settings_for_test(
Arc::get_mut(&mut turn).expect("turn should be uniquely owned"),
|settings| Arc::make_mut(&mut settings.model_info).node_repl_auto_review_required = true,
);
seed_guardian_parent_history(&session, &turn).await;
let context = GuardianReviewContext::from(&turn);
let prompt = build_guardian_prompt_items_with_parent_turn(
session.as_ref(),
session.conversation_history_snapshot().await.as_ref(),
Some(&context),
ApprovalRequestReasons {
approval: None,
retry: Some("Retry the authorized browser inspection.".to_string()),
},
guardian_mcp_request("node_repl", "js"),
GuardianPromptMode::Full,
/*reviewed_node_repl_evidence_sequence*/ 0,
)
.await?;
let text = guardian_prompt_text(&prompt.context.into_user_inputs()?);
assert!(text.contains("Assess the exact planned action below."));
assert!(text.contains("Retry reason:\nRetry the authorized browser inspection."));
assert!(text.contains("Planned action JSON:"));
assert!(text.contains("\"tool\": \"mcp_tool_call\""));
assert!(text.contains("\"server\": \"node_repl\""));
assert!(text.contains("\"tool_name\": \"js\""));
assert!(text.contains("await browser.open('https://example.com')"));
assert!(!text.contains("# Computer and Browser Use"));
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn build_guardian_prompt_items_keeps_other_requests_generic() -> anyhow::Result<()> {
let (session, turn) = guardian_test_session_and_turn_with_base_url("http://localhost").await;
seed_guardian_parent_history(&session, &turn).await;
let context = GuardianReviewContext::from(&turn);
for request in [
guardian_mcp_request("node_repl", "js"),
guardian_mcp_request("node_repl", "inspect"),
guardian_mcp_request("another_server", "js"),
guardian_exec_command_request("shell-1"),
] {
let prompt = build_guardian_prompt_items_with_parent_turn(
session.as_ref(),
session.conversation_history_snapshot().await.as_ref(),
Some(&context),
ApprovalRequestReasons::default(),
request,
GuardianPromptMode::Full,
/*reviewed_node_repl_evidence_sequence*/ 0,
)
.await?;
let text = guardian_prompt_text(&prompt.context.into_user_inputs()?);
assert!(text.contains("Assess the exact planned action below."));
assert!(text.contains("Planned action JSON:"));
assert!(!text.contains("Node REPL action JSON:"));
assert!(!text.contains("Distinguish preparation"));
}
Ok(())
}
#[test]
fn guardian_approval_request_to_json_renders_network_access_trigger() -> serde_json::Result<()> {
let cwd = PathUri::parse("file:///C:/repo").expect("valid Windows path URI");
let action = GuardianApprovalRequest::NetworkAccess {
id: "network-1".to_string(),
turn_id: "turn-1".to_string(),
target: "https://example.com:443".to_string(),
host: "example.com".to_string(),
protocol: NetworkApprovalProtocol::Https,
port: 443,
trigger: Some(GuardianNetworkAccessTrigger {
call_id: "call-1".to_string(),
tool_name: "shell".to_string(),
command: vec!["curl".to_string(), "https://example.com".to_string()],
cwd,
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Fetch the release metadata.".to_string()),
tty: None,
}),
};
assert_eq!(
guardian_approval_request_to_json(&action)?,
serde_json::json!({
"tool": "network_access",
"target": "https://example.com:443",
"host": "example.com",
"protocol": "https",
"port": 443,
"trigger": {
"callId": "call-1",
"toolName": "shell",
"command": ["curl", "https://example.com"],
"cwd": "C:\\repo",
"sandboxPermissions": "use_default",
"justification": "Fetch the release metadata.",
},
})
);
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn build_guardian_prompt_items_explains_network_access_review_scope() -> anyhow::Result<()> {
let (session, turn) = guardian_test_session_and_turn_with_base_url("http://localhost").await;
seed_guardian_parent_history(&session, &turn).await;
let cwd = PathUri::from_abs_path(&test_path_buf("/repo").abs());
let prompt = build_guardian_prompt_items(
session.as_ref(),
Some("Network access to \"example.com\" is blocked by policy.".to_string()),
GuardianApprovalRequest::NetworkAccess {
id: "network-1".to_string(),
turn_id: "turn-1".to_string(),
target: "https://example.com:443".to_string(),
host: "example.com".to_string(),
protocol: NetworkApprovalProtocol::Https,
port: 443,
trigger: Some(GuardianNetworkAccessTrigger {
call_id: "call-1".to_string(),
tool_name: "shell".to_string(),
command: vec!["curl".to_string(), "https://example.com".to_string()],
cwd,
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Fetch the release metadata.".to_string()),
tty: None,
}),
},
GuardianPromptMode::Full,
)
.await?;
let text = guardian_prompt_text(&prompt.context.into_user_inputs()?);
assert!(text.contains("Below is a proposed network access request under review."));
assert!(!text.contains("Network approval context:"));
assert!(
!text.contains(
"This approval request is about network access to the target in the network access JSON below"
)
);
assert!(
text.contains(
"When assessing this request, focus primarily on whether the triggering command is authorised by the user and whether it is within the rules."
)
);
assert!(
text.contains(
"The user does not need to have explicitly authorised this exact network connection, as long as the network access is a reasonable consequence of the triggering command."
)
);
assert!(text.contains("\"trigger\""));
assert!(text.contains("Network access JSON:"));
assert!(!text.contains("The Codex agent has requested the following action:"));
assert!(!text.contains("Planned action JSON:"));
assert!(!text.contains("Retry reason:"));
assert!(!text.contains("Network access to \"example.com\" is blocked by policy."));
let mut settings = Settings::clone_current();
settings.set_snapshot_path("snapshots");
settings.set_prepend_module_to_snapshot(false);
settings.bind(|| {
assert_snapshot!(
"codex_core__guardian__tests__network_access_guardian_prompt_layout",
normalize_guardian_snapshot_paths(text)
);
});
Ok(())
}
#[test_case::test_case(SandboxPermissions::UseDefault)]
#[test_case::test_case(SandboxPermissions::RequireEscalated)]
#[test_case::test_case(SandboxPermissions::WithAdditionalPermissions)]
fn guardian_write_stdin_preserves_input_and_foreign_cwd(
sandbox_permissions: SandboxPermissions,
) -> serde_json::Result<()> {
let cwd = PathUri::parse("file:///C:/workspace").expect("valid executor cwd");
let input = "confirm\n";
let additional_permissions =
if sandbox_permissions == SandboxPermissions::WithAdditionalPermissions {
Some(serde_json::from_value(
serde_json::json!({"network":{"enabled":true}}),
)?)
} else {
None
};
let action = GuardianApprovalRequest::WriteStdin {
id: "terminal-open".to_string(),
approval_id: "terminal-write".to_string(),
environment_id: "windows-executor".to_string(),
process_id: 1000,
input: input.to_string(),
cwd: cwd.clone(),
tty: true,
sandbox_permissions,
additional_permissions: additional_permissions.clone(),
};
let mut expected = serde_json::json!({
"tool": "write_stdin",
"environment_id": "windows-executor",
"session_id": 1000,
"chars": input,
"cwd": r"C:\workspace",
"sandbox_permissions": sandbox_permissions,
"tty": true,
});
if let Some(permissions) = additional_permissions {
expected["additional_permissions"] = serde_json::to_value(permissions)?;
}
assert_eq!(guardian_approval_request_to_json(&action)?, expected);
assert_eq!(
guardian_assessment_action(&action),
GuardianAssessmentAction::WriteStdin {
approval_id: "terminal-write".to_string(),
process_id: "1000".to_string(),
stdin: input.to_string(),
cwd,
},
);
assert_eq!(
(
guardian_request_target_item_id(&action),
guardian_request_turn_id(&action, "current-turn"),
),
(Some("terminal-open"), "current-turn"),
);
Ok(())
}
#[test_case::test_case(codex_utils_path_uri::PathConvention::Windows, r"C:\workspace"; "windows")]
#[test_case::test_case(codex_utils_path_uri::PathConvention::Posix, "/C:/workspace"; "posix")]
fn guardian_exec_command_uses_executor_cwd_convention(
cwd_convention: codex_utils_path_uri::PathConvention,
expected_cwd: &str,
) -> serde_json::Result<()> {
let cwd = PathUri::parse("file:///C:/workspace").expect("valid executor cwd");
let action = crate::tools::sandboxing::ApprovalAction::ExecCommand {
id: "command-1".to_string(),
environment_id: codex_exec_server::REMOTE_ENVIRONMENT_ID.to_string(),
command: vec!["git".to_string(), "status".to_string()],
hook_command: "git status".to_string(),
cwd,
sandbox_permissions: SandboxPermissions::UseDefault,
additional_permissions: None,
justification: None,
tty: false,
proposed_execpolicy_amendment: None,
}
.into_guardian_request(Some(cwd_convention))
.expect("Guardian request should render executor cwd");
assert_eq!(
guardian_approval_request_to_json(&action)?,
serde_json::json!({
"tool": "exec_command",
"command": ["git", "status"],
"cwd": expected_cwd,
"sandbox_permissions": "use_default",
"tty": false,
}),
);
assert_eq!(
guardian_assessment_action(&action),
GuardianAssessmentAction::Command {
source: codex_protocol::approvals::GuardianCommandSource::UnifiedExec,
command: "git status".to_string(),
cwd: LegacyAppPathString::from_string(expected_cwd),
},
);
Ok(())
}
#[test]
fn guardian_apply_patch_preserves_foreign_paths_and_redacts_patch_text() -> serde_json::Result<()> {
let cwd = PathUri::parse("file:///C:/workspace").expect("valid executor cwd");
let file =
PathUri::parse("file:///C:/workspace/guardian.txt").expect("valid executor file path");
let patch = "*** Begin Patch\n*** Update File: guardian.txt\n@@\n+secret\n*** End Patch";
let action = GuardianApprovalRequest::ApplyPatch {
id: "patch-1".to_string(),
cwd,
files: vec![file],
patch: patch.to_string(),
};
assert_eq!(
guardian_approval_request_to_json(&action)?,
serde_json::json!({
"tool": "apply_patch",
"cwd": r"C:\workspace",
"files": [r"C:\workspace\guardian.txt"],
"patch": patch,
}),
);
assert_eq!(
serde_json::to_value(guardian_assessment_action(&action))?,
serde_json::json!({
"type": "apply_patch",
"cwd": r"C:\workspace",
"files": [r"C:\workspace\guardian.txt"],
}),
);
Ok(())
}
#[test]
fn guardian_request_turn_id_prefers_network_access_owner_turn() {
let network_access = GuardianApprovalRequest::NetworkAccess {
id: "network-1".to_string(),
turn_id: "owner-turn".to_string(),
target: "https://example.com:443".to_string(),
host: "example.com".to_string(),
protocol: NetworkApprovalProtocol::Https,
port: 443,
trigger: None,
};
let apply_patch = GuardianApprovalRequest::ApplyPatch {
id: "patch-1".to_string(),
cwd: test_path_buf("/tmp").abs().into(),
files: vec![test_path_buf("/tmp/guardian.txt").abs().into()],
patch: "*** Begin Patch\n*** Update File: guardian.txt\n@@\n+hello\n*** End Patch"
.to_string(),
};
assert_eq!(
guardian_request_turn_id(&network_access, "fallback-turn"),
"owner-turn"
);
assert_eq!(
guardian_request_turn_id(&apply_patch, "fallback-turn"),
"fallback-turn"
);
}
#[test]
fn guardian_request_target_item_id_omits_network_access_trigger_call_id() {
let network_access = GuardianApprovalRequest::NetworkAccess {
id: "network-1".to_string(),
turn_id: "owner-turn".to_string(),
target: "https://example.com:443".to_string(),
host: "example.com".to_string(),
protocol: NetworkApprovalProtocol::Https,
port: 443,
trigger: Some(GuardianNetworkAccessTrigger {
call_id: "call-1".to_string(),
tool_name: "shell".to_string(),
command: vec!["curl".to_string(), "https://example.com".to_string()],
cwd: PathUri::from_abs_path(&test_path_buf("/repo").abs()),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: None,
tty: None,
}),
};
assert_eq!(guardian_request_target_item_id(&network_access), None);
}
#[derive(Clone, Copy)]
enum ApprovalCancellation {
BeforeRouting,
BeforeCachedResult,
}
#[test_case::test_case(ApprovalCancellation::BeforeRouting; "before_routing")]
#[test_case::test_case(ApprovalCancellation::BeforeCachedResult; "before_cached_result")]
#[tokio::test]
async fn cancelled_guardian_review_emits_terminal_abort_without_warning(
moment: ApprovalCancellation,
) {
let (mut session, turn, rx) = crate::session::tests::make_session_and_context_with_rx().await;
let cancel_token = CancellationToken::new();
match moment {
ApprovalCancellation::BeforeRouting => cancel_token.cancel(),
ApprovalCancellation::BeforeCachedResult => {
struct CancelWhileApproving(CancellationToken);
impl codex_extension_api::ApprovalReviewContributor for CancelWhileApproving {
fn decide<'a>(
&'a self,
_input: &'a codex_extension_api::ApprovalDecisionInput<'_>,
) -> codex_extension_api::ExtensionFuture<
'a,
Option<codex_extension_api::ApprovalDecision>,
> {
self.0.cancel();
Box::pin(async { Some(codex_extension_api::ApprovalDecision::Allow) })
}
}
let mut extensions = codex_extension_api::ExtensionRegistryBuilder::<Config>::new();
extensions
.approval_review_contributor(Arc::new(CancelWhileApproving(cancel_token.clone())));
Arc::get_mut(&mut session)
.expect("unique test session")
.services
.extensions = Arc::new(extensions.build());
}
}
let decision = super::decide_approval(
Arc::clone(&session),
&turn,
"review-cancelled-guardian".to_string(),
GuardianApprovalRequest::ApplyPatch {
id: "patch-1".to_string(),
cwd: test_path_buf("/tmp").abs().into(),
files: vec![test_path_buf("/tmp/guardian.txt").abs().into()],
patch: "*** Begin Patch\n*** Update File: guardian.txt\n@@\n+hello\n*** End Patch"
.to_string(),
},
ApprovalRequestReasons {
approval: None,
retry: None,
},
GuardianReviewOptions {
require_guardian: true,
plugin_attribution_override: None,
approval_request_source: GuardianApprovalRequestSource::MainTurn,
external_cancel: Some(cancel_token),
require_synchronous_review: false,
},
)
.await;
assert_eq!(decision, Some(ReviewDecision::Abort));
let mut guardian_statuses = Vec::new();
let mut warnings = Vec::new();
while let Ok(event) = rx.try_recv() {
match event.msg {
EventMsg::GuardianAssessment(event) => guardian_statuses.push(event.status),
EventMsg::GuardianWarning(event) => warnings.push(event.message),
_ => {}
}
}
assert_eq!(
guardian_statuses,
match moment {
ApprovalCancellation::BeforeRouting => vec![
GuardianAssessmentStatus::InProgress,
GuardianAssessmentStatus::Aborted
],
ApprovalCancellation::BeforeCachedResult => vec![],
}
);
assert!(warnings.is_empty());
}
#[test]
fn guardian_timeout_message_distinguishes_timeout_from_policy_denial() {
let mut model = codex_models_manager::model_info::model_info_from_slug("acting-model");
model.model_messages = None;
let message = guardian_timeout_message(&model);
assert!(message.contains("did not finish before its deadline"));
assert!(message.contains("retry once"));
assert!(!message.contains("unacceptable risk"));
for timeout_instructions in [None, Some("Catalog timeout instructions."), Some("")] {
model.model_messages = Some(
serde_json::from_value(serde_json::json!({
"auto_review": {
"policy": "review policy",
"timeout_instructions": timeout_instructions,
},
}))
.expect("model messages should deserialize"),
);
assert_eq!(
guardian_timeout_message(&model),
timeout_instructions.unwrap_or(&message),
);
}
}
#[tokio::test]
async fn routes_approval_to_guardian_requires_guardian_reviewer() {
let (_session, mut turn) = crate::session::tests::make_session_and_context().await;
let mut config = (*turn.config).clone();
config.approvals_reviewer = ApprovalsReviewer::User;
turn.config = Arc::new(config.clone());
assert!(!routes_approval_to_guardian(&turn));
config.approvals_reviewer = ApprovalsReviewer::AutoReview;
turn.config = Arc::new(config);
assert!(routes_approval_to_guardian(&turn));
}
#[tokio::test]
async fn routes_approval_to_guardian_can_use_app_reviewer_override() {
let (_session, turn) = crate::session::tests::make_session_and_context().await;
assert!(!routes_approval_to_guardian_with_reviewer(
&turn,
ApprovalsReviewer::User
));
assert!(routes_approval_to_guardian_with_reviewer(
&turn,
ApprovalsReviewer::AutoReview
));
}
#[tokio::test]
async fn routes_approval_to_guardian_allows_granular_review_policy() {
let (_session, mut turn) = crate::session::tests::make_session_and_context().await;
let mut config = (*turn.config).clone();
config.approvals_reviewer = ApprovalsReviewer::AutoReview;
turn.config = Arc::new(config);
Arc::make_mut(&mut turn.config)
.permissions
.approval_policy
.set(AskForApproval::Granular(GranularApprovalConfig {
sandbox_approval: true,
rules: true,
skill_approval: true,
request_permissions: true,
mcp_elicitations: true,
}))
.expect("test setup should allow updating approval policy");
assert!(routes_approval_to_guardian(&turn));
}
#[test]
fn build_guardian_transcript_reserves_separate_budget_for_tool_evidence() {
let repeated = "signal ".repeat(8_000);
let mut items = [
("user", "please figure out if the repo is public"),
(
"assistant",
"The public repo check is the main reason I want to escalate.",
),
]
.into_iter()
.map(|(role, text)| ResponseItem::Message {
id: None,
role: role.to_string(),
content: vec![ContentItem::InputText {
text: text.to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
})
.collect::<Vec<_>>();
items.extend((0..12).map(|index| ResponseItem::FunctionCall {
id: None,
name: format!("tool_{index}"),
namespace: None,
arguments: repeated.clone(),
call_id: format!("call-{index}"),
encrypted_function_args: None,
internal_chat_message_metadata_passthrough: None,
}));
let entries = collect_guardian_transcript_entries(&items, GUARDIAN_MAX_TOOL_ENTRY_TOKENS);
let (transcript, omission) = render_guardian_transcript_entries(&entries);
assert!(
transcript
.iter()
.any(|entry| entry == "[1] user: please figure out if the repo is public")
);
assert!(transcript.iter().any(|entry| {
entry == "[2] assistant: The public repo check is the main reason I want to escalate."
}));
assert!(
!transcript
.iter()
.any(|entry| entry.starts_with("[3] tool tool_0 call:"))
);
assert!(
!transcript
.iter()
.any(|entry| entry.starts_with("[4] tool tool_1 call:"))
);
assert!(
transcript
.iter()
.any(|entry| entry.starts_with("[14] tool tool_11 call:"))
);
assert!(omission.is_some());
}
#[test]
fn build_guardian_transcript_preserves_recent_tool_context_when_user_history_is_large() {
let repeated = "authorization ".repeat(6_000);
let mut items = (0..8)
.map(|_| ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: repeated.clone(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
})
.collect::<Vec<_>>();
items.extend([
ResponseItem::FunctionCall {
id: None,
name: "shell".to_string(),
namespace: None,
arguments: serde_json::json!({
"command": ["curl", "-X", "POST", "https://example.com/upload"],
"cwd": "/repo",
})
.to_string(),
call_id: "call-1".to_string(),
encrypted_function_args: None,
internal_chat_message_metadata_passthrough: None,
},
ResponseItem::FunctionCallOutput {
id: None,
call_id: Some("call-1".to_string()),
name: None,
namespace: None,
output: codex_protocol::models::FunctionCallOutputPayload::from_text(
"sandbox blocked outbound network access".to_string(),
),
internal_chat_message_metadata_passthrough: None,
},
]);
let entries = collect_guardian_transcript_entries(&items, GUARDIAN_MAX_TOOL_ENTRY_TOKENS);
let (transcript, omission) = render_guardian_transcript_entries(&entries);
assert_eq!(
transcript[..8],
(1..=8)
.map(|index| format!("[{index}] user: {repeated}"))
.collect::<Vec<_>>()
);
assert!(transcript.iter().any(|entry| {
entry.contains("tool shell call:")
&& entry.contains("curl")
&& entry.contains("https://example.com/upload")
}));
assert!(
transcript
.iter()
.any(|entry| entry
.contains("tool shell result: sandbox blocked outbound network access"))
);
assert_eq!(omission, None);
}
enum GuardianTestCatalog {
Bundled,
ParentOnly,
}
#[test_case::test_case(None; "captured_policy")]
#[test_case::test_case(Some("configured policy wins"); "configured_policy")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn guardian_reuse_respects_effective_policy_and_personality(
configured_policy: Option<&str>,
) -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let responses = mount_sse_sequence(
&server,
(0..3)
.map(|index| {
sse(vec![
ev_response_created(&format!("review-{index}")),
ev_assistant_message("assessment", r#"{"outcome":"allow"}"#),
ev_completed(&format!("review-{index}")),
])
})
.collect(),
)
.await;
let (mut session, mut turn) = guardian_test_session_and_turn(&server).await;
Arc::make_mut(&mut Arc::get_mut(&mut turn).expect("unshared turn").config)
.guardian_policy_config = configured_policy.map(str::to_owned);
let mut captured = GuardianReviewContext::from(&turn);
let parent = Arc::make_mut(&mut captured.model_info);
parent.slug = "captured-parent".to_string();
parent.auto_review_model_override = None;
parent.model_messages = Some(serde_json::from_value(serde_json::json!({
"auto_review": {
"policy": "captured policy",
"policy_template": "captured template: {{ tenant_policy_config }}",
},
}))?);
captured.personality = Some(codex_protocol::config_types::Personality::Friendly);
Arc::get_mut(&mut session)
.expect("unshared session")
.services
.models_manager = Arc::new(StaticModelsManager::new(
/*auth_manager*/ None,
ModelsResponse { models: vec![] },
));
seed_guardian_parent_history(&session, &turn).await;
let mut changed_policy = captured.clone();
Arc::make_mut(&mut changed_policy.model_info)
.model_messages
.as_mut()
.unwrap()
.auto_review
.as_mut()
.unwrap()
.policy = Some("changed action policy".to_string());
let mut different_personality = changed_policy.clone();
different_personality.personality = Some(codex_protocol::config_types::Personality::Pragmatic);
for (index, context) in [captured, changed_policy, different_personality]
.into_iter()
.enumerate()
{
let (outcome, _) = run_guardian_review_session_for_test(
Arc::clone(&session),
context,
guardian_exec_command_request(&format!("action-{index}")),
ApprovalRequestReasons::default(),
guardian_output_schema(),
/*external_cancel*/ None,
/*max_attempts*/ 1,
)
.await;
assert!(matches!(outcome, GuardianReviewOutcome::Completed(_)));
}
let requests = responses.requests();
assert_eq!(requests.len(), 3);
let initial_policy = configured_policy.unwrap_or("captured policy");
assert_eq!(
requests[0].instructions_text(),
guardian_policy_prompt_with_config_and_template(
initial_policy,
"captured template: {{ tenant_policy_config }}"
)
);
let changed_policy = configured_policy.unwrap_or("changed action policy");
assert_eq!(
requests[1].instructions_text(),
guardian_policy_prompt_with_config_and_template(
changed_policy,
"captured template: {{ tenant_policy_config }}"
)
);
let thread_ids = requests
.iter()
.map(|request| request.body_json()["client_metadata"]["thread_id"].clone())
.collect::<Vec<_>>();
assert_eq!(thread_ids[0] == thread_ids[1], configured_policy.is_some());
assert_ne!(thread_ids[1], thread_ids[2]);
Ok(())
}
async fn guardian_request_model_for_auto_review(
auto_review_model_override: Option<String>,
catalog: GuardianTestCatalog,
) -> anyhow::Result<(
String,
String,
String,
codex_analytics::GuardianReviewAnalyticsResult,
)> {
let server = start_mock_server().await;
let guardian_assessment = serde_json::json!({
"outcome": "allow",
})
.to_string();
let request_log = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-guardian"),
ev_assistant_message("msg-guardian", &guardian_assessment),
ev_completed("resp-guardian"),
]),
)
.await;
let (mut session, mut turn) = guardian_test_session_and_turn(&server).await;
match catalog {
GuardianTestCatalog::Bundled => {}
GuardianTestCatalog::ParentOnly => {
let parent_model = turn.model_info().as_ref().clone();
let auth_manager = Arc::clone(&session.services.auth_manager);
let models_manager = StaticModelsManager::new(
Some(auth_manager),
ModelsResponse {
models: vec![parent_model],
},
);
Arc::get_mut(&mut session)
.expect("session should be unique")
.services
.models_manager = Arc::new(models_manager);
crate::guardian::test_host::install(&session, &turn.config);
}
}
update_turn_settings_for_test(
Arc::get_mut(&mut turn).expect("turn should be unique"),
|settings| {
Arc::make_mut(&mut settings.model_info).auto_review_model_override =
auto_review_model_override
},
);
let parent_model = turn.model_info().slug.clone();
let preferred_model = turn.provider.approval_review_preferred_model().to_string();
let parent_turn_id = turn.sub_id.clone();
seed_guardian_parent_history(&session, &turn).await;
let (outcome, analytics_result) = run_guardian_review_session_for_test(
Arc::clone(&session),
turn,
GuardianApprovalRequest::ExecCommand {
id: "shell-1".to_string(),
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: None,
tty: false,
},
ApprovalRequestReasons {
approval: None,
retry: Some("Sandbox denied outbound git push to github.com.".to_string()),
},
guardian_output_schema(),
/*external_cancel*/ None,
/*max_attempts*/ 1,
)
.await;
let GuardianReviewOutcome::Completed(_) = outcome else {
panic!("expected guardian assessment");
};
let request = request_log.single_request();
let request_body = request.body_json();
core_test_support::responses::assert_parent_turn(&request_body, Some(parent_turn_id.as_str()))?;
let request_model = request_body
.get("model")
.and_then(|value| value.as_str())
.expect("guardian request should include a model")
.to_string();
Ok((
request_model,
parent_model,
preferred_model,
analytics_result,
))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn guardian_review_uses_model_catalog_override_when_preferred_review_model_exists()
-> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let override_model = "guardian-review-model-override".to_string();
let (request_model, parent_model, preferred_model, analytics_result) =
guardian_request_model_for_auto_review(
Some(override_model.clone()),
GuardianTestCatalog::Bundled,
)
.await?;
assert_eq!(request_model, override_model);
assert_ne!(request_model, parent_model);
assert_ne!(request_model, preferred_model);
assert_eq!(
analytics_result.guardian_catalog_contains_auto_review,
Some(true)
);
assert_eq!(
analytics_result.guardian_default_review_model_id.as_deref(),
Some(preferred_model.as_str())
);
assert_eq!(
analytics_result.guardian_review_model_overridden,
Some(true)
);
assert_eq!(
analytics_result.guardian_review_model_override.as_deref(),
Some(override_model.as_str())
);
assert_eq!(
analytics_result.guardian_model_provider_id.as_deref(),
Some(OPENAI_PROVIDER_ID)
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn guardian_review_uses_preferred_review_model_without_model_catalog_override()
-> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let (request_model, parent_model, preferred_model, analytics_result) =
guardian_request_model_for_auto_review(
/*auto_review_model_override*/ None,
GuardianTestCatalog::Bundled,
)
.await?;
assert_eq!(request_model, preferred_model);
assert_ne!(request_model, parent_model);
assert_eq!(
analytics_result.guardian_catalog_contains_auto_review,
Some(true)
);
assert_eq!(
analytics_result.guardian_default_review_model_id.as_deref(),
Some(preferred_model.as_str())
);
assert_eq!(
analytics_result.guardian_review_model_overridden,
Some(false)
);
assert_eq!(
analytics_result.guardian_review_model_override.as_deref(),
None
);
assert_eq!(
analytics_result.guardian_model_provider_id.as_deref(),
Some(OPENAI_PROVIDER_ID)
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn guardian_review_records_missing_auto_review_model_in_analytics_metadata()
-> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let (request_model, parent_model, preferred_model, analytics_result) =
guardian_request_model_for_auto_review(
/*auto_review_model_override*/ None,
GuardianTestCatalog::ParentOnly,
)
.await?;
assert_eq!(request_model, parent_model);
assert_ne!(request_model, preferred_model);
assert_eq!(
analytics_result.guardian_catalog_contains_auto_review,
Some(false)
);
assert_eq!(
analytics_result.guardian_default_review_model_id.as_deref(),
Some(preferred_model.as_str())
);
assert_eq!(
analytics_result.guardian_review_model_overridden,
Some(false)
);
assert_eq!(
analytics_result.guardian_review_model_override.as_deref(),
None
);
assert_eq!(
analytics_result.guardian_model_provider_id.as_deref(),
Some(OPENAI_PROVIDER_ID)
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn guardian_review_request_layout_matches_model_visible_request_snapshot()
-> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let guardian_assessment = serde_json::json!({
"risk_level": "medium",
"user_authorization": "high",
"outcome": "allow",
"rationale": "The user explicitly requested pushing the reviewed branch to the known remote.",
})
.to_string();
let request_log = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-guardian"),
ev_assistant_message("msg-guardian", &guardian_assessment),
ev_completed("resp-guardian"),
]),
)
.await;
let (mut session, mut turn) = crate::session::tests::make_session_and_context().await;
session.thread_id = fixed_guardian_parent_session_id();
let temp_cwd = TempDir::new()?;
let mut config = (*turn.config).clone();
config.cwd = temp_cwd.abs();
config.model_provider.base_url = Some(format!("{}/v1", server.uri()));
config.memories.use_memories = true;
config
.features
.enable(Feature::MemoryTool)
.expect("memory tool feature is configurable");
let config = Arc::new(config);
let models_manager = test_support::models_manager_with_provider(
config.codex_home.to_path_buf(),
Arc::clone(&session.services.auth_manager),
config.model_provider.clone(),
);
session.services.models_manager = models_manager;
crate::guardian::test_host::install(&session, &config);
let memory_extension = Arc::new(GuardianMemoryContextProbe);
let mut extensions = codex_extension_api::ExtensionRegistryBuilder::<Config>::new();
extensions.thread_lifecycle_contributor(memory_extension.clone());
extensions.prompt_contributor(memory_extension);
session.services.extensions = Arc::new(extensions.build());
let skill_dir = config
.codex_home
.to_path_buf()
.join("skills")
.join(GUARDIAN_SKILL_NAME);
std::fs::create_dir_all(&skill_dir)?;
std::fs::write(
skill_dir.join("SKILL.md"),
format!(
"---\nname: {GUARDIAN_SKILL_NAME}\ndescription: Guardian skill injection probe.\n---\n\n{GUARDIAN_SKILL_BODY_PROBE}\n"
),
)?;
session.services.skills_service.clear_cache();
turn.config = Arc::clone(&config);
turn.provider = create_model_provider(config.model_provider.clone(), turn.auth_manager.clone());
update_turn_settings_for_test(&mut turn, |settings| {
Arc::make_mut(&mut settings.model_info).auto_review_model_override =
Some("codex-auto-review".to_string());
});
let session = Arc::new(session);
let turn = Arc::new(turn);
seed_guardian_parent_history(&session, &turn).await;
session
.record_conversation_items(
turn.as_ref(),
turn.model_info(),
&[ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: format!(
"Use ${GUARDIAN_SKILL_NAME} before deciding whether the push is safe."
),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
}],
)
.await;
let request = GuardianApprovalRequest::ExecCommand {
id: "shell-1".to_string(),
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
command: vec![
"git".to_string(),
"push".to_string(),
"origin".to_string(),
"guardian-approval-mvp".to_string(),
],
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the reviewed docs fix to the repo remote.".to_string()),
tty: false,
};
let outcome = run_guardian_review_session_for_test(
Arc::clone(&session),
Arc::clone(&turn),
request,
ApprovalRequestReasons {
approval: None,
retry: Some("Sandbox denied outbound git push to github.com.".to_string()),
},
guardian_output_schema(),
/*external_cancel*/ None,
/*max_attempts*/ 1,
)
.await;
let (GuardianReviewOutcome::Completed(assessment), metadata) = outcome else {
panic!("expected guardian assessment");
};
let guardian_thread_id = metadata
.guardian_thread_id
.as_deref()
.expect("guardian thread id");
assert_eq!(assessment.outcome, GuardianAssessmentOutcome::Allow);
assert_ne!(guardian_thread_id, session.thread_id.to_string());
ThreadId::from_string(guardian_thread_id).expect("guardian thread id should be a valid UUID");
assert!(matches!(
metadata.guardian_session_kind,
Some(codex_analytics::GuardianReviewSessionKind::TrunkNew)
));
let request = request_log.single_request();
let turn_metadata: serde_json::Value = serde_json::from_str(
&request
.header("x-codex-turn-metadata")
.expect("guardian turn metadata"),
)?;
assert_eq!(turn_metadata["turn_trigger"], "guardian_review");
let request_body = request.body_json();
assert!(
request_body.get("tools").is_none(),
"guardian request should use Responses Lite tool input"
);
let guardian_tools = request_body["input"]
.as_array()
.and_then(|input| input.first())
.filter(|item| item["type"] == "additional_tools")
.and_then(|item| item["tools"].as_array())
.and_then(|tools| {
tools
.iter()
.find(|tool| tool["type"] == "namespace" && tool["name"] == "functions")
})
.and_then(|namespace| namespace["tools"].as_array())
.expect("guardian request functions namespace");
let mut guardian_tool_names = guardian_tools
.iter()
.map(|tool| tool["name"].as_str().expect("guardian code-mode tool name"))
.collect::<Vec<_>>();
guardian_tool_names.sort_unstable();
assert_eq!(guardian_tool_names, vec!["exec", "wait"]);
let guardian_exec_description = guardian_tools
.iter()
.find(|tool| tool["name"] == "exec")
.and_then(|tool| tool["description"].as_str())
.expect("guardian code-mode exec description");
let mut guardian_nested_tool_names = guardian_exec_description
.lines()
.filter_map(|line| line.strip_prefix("### `"))
.filter_map(|line| line.strip_suffix('`'))
.collect::<Vec<_>>();
guardian_nested_tool_names.sort_unstable();
assert_eq!(
guardian_nested_tool_names,
vec!["exec_command", "view_image", "write_stdin"]
);
let guardian_user_text = request.message_input_texts("user").join("\n");
assert!(guardian_user_text.contains(&format!(
"Reviewed Codex session id: {}",
fixed_guardian_parent_session_id()
)));
assert!(
guardian_user_text.contains(&format!("${GUARDIAN_SKILL_NAME}")),
"guardian request should contain the untrusted skill mention from the parent transcript"
);
assert!(
!request.body_contains_text(GUARDIAN_SKILL_BODY_PROBE),
"guardian request should not inject a skill body from its generated review prompt"
);
assert!(
!request.body_contains_text(GUARDIAN_MEMORY_CONTEXT_PROBE),
"guardian request should not include memory context"
);
assert_eq!(
request_body.pointer("/text/format/strict"),
Some(&serde_json::json!(false))
);
assert_eq!(
request_body.pointer("/text/format/schema"),
Some(&serde_json::json!({
"type": "object",
"additionalProperties": false,
"properties": {
"risk_level": {
"type": "string",
"enum": ["low", "medium", "high", "critical"]
},
"user_authorization": {
"type": "string",
"enum": ["unknown", "low", "medium", "high"]
},
"outcome": {
"type": "string",
"enum": ["allow", "deny"]
},
"rationale": {
"type": "string"
}
},
"required": ["outcome"]
}))
);
let request_model = request_body
.get("model")
.and_then(|value| value.as_str())
.expect("guardian request should include a model");
let request_reasoning_effort = request_body
.get("reasoning")
.and_then(|reasoning| reasoning.get("effort"))
.and_then(|value| value.as_str());
assert_eq!(metadata.guardian_model.as_deref(), Some(request_model));
assert_eq!(
metadata.guardian_reasoning_effort.as_deref(),
request_reasoning_effort
);
assert_eq!(metadata.had_prior_review_context, Some(false));
assert!(
metadata.time_to_first_token_ms.is_some(),
"guardian review metadata should capture TTFT when the nested turn completes"
);
let mut settings = Settings::clone_current();
settings.set_snapshot_path("snapshots");
settings.set_prepend_module_to_snapshot(false);
settings.bind(|| {
assert_snapshot!(
"codex_core__guardian__tests__guardian_review_request_layout",
normalize_guardian_snapshot_paths(format_guardian_requests_snapshot(
"Guardian review request layout",
&[("Guardian Review Request", &request)],
))
);
});
Ok(())
}
#[tokio::test]
async fn build_guardian_prompt_items_includes_parent_session_id() -> anyhow::Result<()> {
let (session, _) = crate::session::tests::make_session_and_context().await;
let prompt = build_guardian_prompt_items(
&session,
/*retry_reason*/ None,
GuardianApprovalRequest::ExecCommand {
id: "shell-1".to_string(),
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
command: vec!["git".to_string(), "status".to_string()],
cwd: test_path_buf("/repo").abs().into(),
guardian_cwd: native_guardian_cwd("/repo"),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: None,
tty: false,
},
GuardianPromptMode::Full,
)
.await?;
let prompt_text = prompt
.context
.into_user_inputs()?
.into_iter()
.map(|item| match item {
codex_protocol::user_input::UserInput::Text { text, .. } => text,
codex_protocol::user_input::UserInput::Image { .. } => String::new(),
_ => String::new(),
})
.collect::<String>();
assert!(
prompt_text.contains(&format!(
">>> TRANSCRIPT END\nReviewed Codex session id: {}\n",
session.thread_id
)),
"guardian prompt should expose the parent session id immediately after the transcript end"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let first_rationale = "first guardian rationale from the prior review";
let request_log = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-guardian-1"),
ev_assistant_message(
"msg-guardian-1",
&format!(
"{{\"risk_level\":\"low\",\"user_authorization\":\"high\",\"outcome\":\"allow\",\"rationale\":\"{first_rationale}\"}}"
),
),
ev_completed("resp-guardian-1"),
]),
sse(vec![
ev_response_created("resp-guardian-2"),
ev_assistant_message(
"msg-guardian-2",
"{\"risk_level\":\"low\",\"user_authorization\":\"high\",\"outcome\":\"allow\",\"rationale\":\"second guardian rationale\"}",
),
ev_completed("resp-guardian-2"),
]),
sse(vec![
ev_response_created("resp-guardian-3"),
ev_assistant_message(
"msg-guardian-3",
"{\"risk_level\":\"low\",\"user_authorization\":\"high\",\"outcome\":\"allow\",\"rationale\":\"third guardian rationale\"}",
),
ev_completed("resp-guardian-3"),
]),
sse(vec![
ev_response_created("resp-guardian-4"),
ev_assistant_message(
"msg-guardian-4",
"{\"risk_level\":\"low\",\"user_authorization\":\"high\",\"outcome\":\"allow\",\"rationale\":\"fourth guardian rationale\"}",
),
ev_completed("resp-guardian-4"),
]),
],
)
.await;
let (session, mut turn) = guardian_test_session_and_turn(&server).await;
let mut config = (*turn.config).clone();
config
.features
.enable(Feature::GuardianReuseParentCompaction)
.expect("Guardian parent-compaction reuse should be configurable");
let turn_mut = Arc::get_mut(&mut turn).expect("turn should be unique");
update_turn_settings_for_test(turn_mut, |settings| {
Arc::make_mut(&mut settings.model_info).auto_review_model_override =
Some("codex-auto-review".to_string());
});
turn_mut.config = Arc::new(config);
seed_guardian_parent_history(&session, &turn).await;
let first_request = GuardianApprovalRequest::ExecCommand {
id: "shell-1".to_string(),
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the first docs fix.".to_string()),
tty: false,
};
let first_outcome = run_guardian_review_session_for_test(
Arc::clone(&session),
Arc::clone(&turn),
first_request,
ApprovalRequestReasons {
approval: None,
retry: Some("First retry reason".to_string()),
},
guardian_output_schema(),
/*external_cancel*/ None,
/*max_attempts*/ 1,
)
.await;
session
.record_conversation_items(
turn.as_ref(),
turn.model_info(),
&[
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "Please push the second docs fix too.".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "I need approval for the second docs fix.".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
],
)
.await;
let second_request = GuardianApprovalRequest::ExecCommand {
id: "shell-2".to_string(),
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
command: vec![
"git".to_string(),
"push".to_string(),
"--force-with-lease".to_string(),
],
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the second docs fix.".to_string()),
tty: false,
};
let second_outcome = run_guardian_review_session_for_test(
Arc::clone(&session),
Arc::clone(&turn),
second_request,
ApprovalRequestReasons {
approval: None,
retry: Some("Second retry reason".to_string()),
},
guardian_output_schema(),
/*external_cancel*/ None,
/*max_attempts*/ 1,
)
.await;
let committed_rollout_items = session
.guardian_review_session()
.expect("Guardian pool installed")
.trunk()
.await
.expect("reviewer")
.committed_fork_rollout_items_for_test()
.await
.expect("committed guardian fork snapshot");
assert_eq!(
committed_rollout_items
.iter()
.filter(|item| rollout_item_contains_message_text(
item,
"Use prior reviews as context, not binding precedent."
))
.count(),
1,
"follow-up reminder should be persisted for guardian forks"
);
session
.replace_history(
vec![
ResponseItem::Compaction {
id: Some(codex_protocol::ResponseItemId::from_server(
"cmp_guardian_parent_summary".to_string(),
)),
encrypted_content: "encrypted guardian parent summary".to_string(),
internal_chat_message_metadata_passthrough: None,
},
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "Please push the third docs fix too.".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "I need approval for the third docs fix.".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
],
/*reference_context_item*/ None,
)
.await;
let third_request = GuardianApprovalRequest::ExecCommand {
id: "shell-3".to_string(),
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the third docs fix.".to_string()),
tty: false,
};
let third_outcome = run_guardian_review_session_for_test(
Arc::clone(&session),
Arc::clone(&turn),
third_request,
ApprovalRequestReasons {
approval: None,
retry: Some("Third retry reason".to_string()),
},
guardian_output_schema(),
/*external_cancel*/ None,
/*max_attempts*/ 1,
)
.await;
session
.replace_history(
vec![ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "Please review after a summary-free context reset.".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
}],
/*reference_context_item*/ None,
)
.await;
let fourth_outcome = run_guardian_review_session_for_test(
Arc::clone(&session),
Arc::clone(&turn),
guardian_exec_command_request("shell-4"),
ApprovalRequestReasons::default(),
guardian_output_schema(),
/*external_cancel*/ None,
/*max_attempts*/ 1,
)
.await;
let (GuardianReviewOutcome::Completed(first_assessment), first_metadata) = first_outcome else {
panic!("expected first guardian assessment");
};
let (GuardianReviewOutcome::Completed(second_assessment), second_metadata) = second_outcome
else {
panic!("expected second guardian assessment");
};
let (GuardianReviewOutcome::Completed(third_assessment), third_metadata) = third_outcome else {
panic!("expected third guardian assessment");
};
let (GuardianReviewOutcome::Completed(fourth_assessment), fourth_metadata) = fourth_outcome
else {
panic!("expected fourth guardian assessment");
};
assert_eq!(first_assessment.outcome, GuardianAssessmentOutcome::Allow);
assert_eq!(second_assessment.outcome, GuardianAssessmentOutcome::Allow);
assert_eq!(third_assessment.outcome, GuardianAssessmentOutcome::Allow);
assert_eq!(fourth_assessment.outcome, GuardianAssessmentOutcome::Allow);
assert!(matches!(
first_metadata.guardian_session_kind,
Some(codex_analytics::GuardianReviewSessionKind::TrunkNew)
));
assert!(matches!(
second_metadata.guardian_session_kind,
Some(codex_analytics::GuardianReviewSessionKind::TrunkReused)
));
assert!(matches!(
third_metadata.guardian_session_kind,
Some(codex_analytics::GuardianReviewSessionKind::TrunkNew)
));
assert!(matches!(
fourth_metadata.guardian_session_kind,
Some(codex_analytics::GuardianReviewSessionKind::TrunkNew)
));
ThreadId::from_string(
first_metadata
.guardian_thread_id
.as_deref()
.expect("first guardian thread id"),
)
.expect("first guardian thread id should be a valid UUID");
ThreadId::from_string(
second_metadata
.guardian_thread_id
.as_deref()
.expect("second guardian thread id"),
)
.expect("second guardian thread id should be a valid UUID");
ThreadId::from_string(
third_metadata
.guardian_thread_id
.as_deref()
.expect("third guardian thread id"),
)
.expect("third guardian thread id should be a valid UUID");
assert_eq!(first_metadata.had_prior_review_context, Some(false));
assert_eq!(second_metadata.had_prior_review_context, Some(true));
assert_eq!(third_metadata.had_prior_review_context, Some(false));
assert_eq!(fourth_metadata.had_prior_review_context, Some(false));
assert_eq!(
first_metadata.guardian_thread_id,
second_metadata.guardian_thread_id
);
assert_ne!(
second_metadata.guardian_thread_id,
third_metadata.guardian_thread_id
);
assert_ne!(
third_metadata.guardian_thread_id,
fourth_metadata.guardian_thread_id
);
let requests = request_log.requests();
assert_eq!(requests.len(), 4);
for request in &requests[..2] {
assert!(
request
.message_input_texts("user")
.join("\n")
.contains(&format!(
"Reviewed Codex session id: {}",
fixed_guardian_parent_session_id()
))
);
}
let first_body = requests[0].body_json();
let second_body = requests[1].body_json();
let third_body = requests[2].body_json();
let fourth_body = requests[3].body_json();
let third_input = third_body["input"]
.as_array()
.expect("guardian review should include input items");
assert!(third_input.iter().any(|item| {
item["type"] == "compaction"
&& item["id"] == "cmp_guardian_parent_summary"
&& item["encrypted_content"] == "encrypted guardian parent summary"
}));
assert_eq!(
first_body["prompt_cache_key"],
second_body["prompt_cache_key"]
);
assert_eq!(
second_body["prompt_cache_key"],
third_body["prompt_cache_key"]
);
assert_eq!(
third_body["prompt_cache_key"],
fourth_body["prompt_cache_key"]
);
assert!(!fourth_body.to_string().contains("third guardian rationale"));
assert!(
second_body.to_string().contains(concat!(
"Use prior reviews as context, not binding precedent. ",
"Follow the Workspace Policy. ",
"If the user explicitly approves a previously rejected action after being ",
"informed of the concrete risks, set outcome to \\\"allow\\\" unless the policy ",
"explicitly disallows user overwrites in such cases."
)),
"follow-up guardian request should include the follow-up reminder"
);
assert!(
second_body.to_string().contains(first_rationale),
"guardian session should append earlier reviews into the follow-up request"
);
assert_eq!(
third_body
.to_string()
.matches("Use prior reviews as context, not binding precedent.")
.count(),
0,
"a fresh guardian session should not inherit the follow-up reminder"
);
let third_user_message = requests[2]
.message_input_text_groups("user")
.last()
.expect("fresh guardian user message")
.join("");
assert!(third_user_message.contains(">>> TRANSCRIPT START\n"));
assert!(third_user_message.contains("Please push the third docs fix too."));
assert!(!third_body.to_string().contains(first_rationale));
let second_user_message = requests[1]
.message_input_text_groups("user")
.last()
.expect("follow-up guardian user message")
.join("");
assert!(second_user_message.contains(">>> TRANSCRIPT DELTA START\n"));
assert!(second_user_message.contains("[5] user: Please push the second docs fix too."));
assert!(
second_user_message.contains("[6] assistant: I need approval for the second docs fix.")
);
assert!(!second_user_message.contains("[1] user: Please check the repo visibility"));
let mut settings = Settings::clone_current();
settings.set_snapshot_path("snapshots");
settings.set_prepend_module_to_snapshot(false);
settings.bind(|| {
assert_snapshot!(
"codex_core__guardian__tests__guardian_followup_review_request_layout",
format!(
"{}\n\nshared_prompt_cache_key: {}\nfollowup_contains_first_rationale: {}",
normalize_guardian_snapshot_paths(format_guardian_requests_snapshot(
"Guardian follow-up review request layout",
&[
("Initial Guardian Review Request", &requests[0]),
("Follow-up Guardian Review Request", &requests[1]),
],
)),
first_body["prompt_cache_key"] == second_body["prompt_cache_key"],
second_body.to_string().contains(first_rationale),
)
);
});
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn guardian_reused_trunk_ignores_stale_prior_turn_completion() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let request_log = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-guardian-1"),
ev_assistant_message(
"msg-guardian-1",
"{\"risk_level\":\"low\",\"user_authorization\":\"high\",\"outcome\":\"allow\",\"rationale\":\"first guardian rationale\"}",
),
ev_completed("resp-guardian-1"),
]),
sse(vec![
ev_response_created("resp-guardian-2"),
ev_assistant_message(
"msg-guardian-2",
"{\"risk_level\":\"low\",\"user_authorization\":\"high\",\"outcome\":\"allow\",\"rationale\":\"second guardian rationale\"}",
),
ev_completed("resp-guardian-2"),
]),
],
)
.await;
let (session, turn) = guardian_test_session_and_turn(&server).await;
let first_outcome = run_guardian_review_session_for_test(
Arc::clone(&session),
Arc::clone(&turn),
GuardianApprovalRequest::ExecCommand {
id: "shell-1".to_string(),
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the first docs fix.".to_string()),
tty: false,
},
ApprovalRequestReasons::default(),
guardian_output_schema(),
/*external_cancel*/ None,
/*max_attempts*/ 1,
)
.await;
let (GuardianReviewOutcome::Completed(first_assessment), first_metadata) = first_outcome else {
panic!("expected first guardian assessment");
};
assert_eq!(first_assessment.rationale, "first guardian rationale");
assert!(matches!(
first_metadata.guardian_session_kind,
Some(codex_analytics::GuardianReviewSessionKind::TrunkNew)
));
session
.guardian_review_session()
.expect("Guardian pool installed")
.trunk()
.await
.expect("reviewer")
.send_trunk_event_raw_for_test(Event {
id: "stale-turn".to_string(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "stale-turn".to_string(),
started_at: None,
last_agent_message: Some(
"{\"risk_level\":\"high\",\"user_authorization\":\"low\",\"outcome\":\"deny\",\"rationale\":\"stale guardian rationale\"}"
.to_string(),
),
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: Some(1),
}),
})
.await;
let second_outcome = run_guardian_review_session_for_test(
Arc::clone(&session),
Arc::clone(&turn),
GuardianApprovalRequest::ExecCommand {
id: "shell-2".to_string(),
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the second docs fix.".to_string()),
tty: false,
},
ApprovalRequestReasons::default(),
guardian_output_schema(),
/*external_cancel*/ None,
/*max_attempts*/ 1,
)
.await;
let (GuardianReviewOutcome::Completed(second_assessment), second_metadata) = second_outcome
else {
panic!("expected second guardian assessment");
};
assert_eq!(second_assessment.outcome, GuardianAssessmentOutcome::Allow);
assert_eq!(second_assessment.rationale, "second guardian rationale");
assert!(matches!(
second_metadata.guardian_session_kind,
Some(codex_analytics::GuardianReviewSessionKind::TrunkReused)
));
assert_eq!(
request_log.requests().len(),
2,
"the reused trunk should wait for the real follow-up review"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let error_message =
"Item 'rs_test' of type 'reasoning' was provided without its required following item.";
let request_log = mount_response_sequence(
&server,
vec![
wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
"error": {
"message": error_message,
"type": "invalid_request_error",
"param": "input"
}
})),
],
)
.await;
let (mut session, mut turn, rx) =
crate::session::tests::make_session_and_context_with_rx().await;
let mut config = (*turn.config).clone();
config.model_provider.base_url = Some(format!("{}/v1", server.uri()));
let config = Arc::new(config);
let models_manager = test_support::models_manager_with_provider(
config.codex_home.to_path_buf(),
Arc::clone(&session.services.auth_manager),
config.model_provider.clone(),
);
Arc::get_mut(&mut session)
.expect("session should be uniquely owned")
.services
.models_manager = models_manager;
crate::guardian::test_host::install(&session, &config);
let turn_mut = Arc::get_mut(&mut turn).expect("turn should be uniquely owned");
turn_mut.config = Arc::clone(&config);
turn_mut.provider =
create_model_provider(config.model_provider.clone(), turn_mut.auth_manager.clone());
seed_guardian_parent_history(&session, &turn).await;
let decision = review_approval_request(
&session,
&turn,
"review-shell-guardian-error".to_string(),
GuardianApprovalRequest::ExecCommand {
id: "shell-guardian-error".to_string(),
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Need to push the reviewed docs fix.".to_string()),
tty: false,
},
ApprovalRequestReasons::default(),
)
.await;
let ReviewDecision::Denied { rejection } = decision else {
panic!("guardian error should deny the approval");
};
assert_eq!(request_log.requests().len(), 1);
let mut warnings = Vec::new();
let mut denial_rationales = Vec::new();
while let Ok(event) = rx.try_recv() {
match event.msg {
EventMsg::GuardianWarning(event) => warnings.push(event.message),
EventMsg::GuardianAssessment(event)
if event.status == GuardianAssessmentStatus::Denied =>
{
denial_rationales.push(event.rationale)
}
_ => {}
}
}
assert!(
warnings
.iter()
.any(|message| message.contains(error_message)),
"warning should include the underlying responses api error"
);
assert!(
denial_rationales
.iter()
.flatten()
.any(|message| message.contains(error_message)),
"denial rationale should include the underlying responses api error"
);
assert!(
denial_rationales.iter().flatten().all(|message| {
!message.contains("guardian review completed without an assessment payload")
}),
"denial rationale should not fall back to the generic missing payload error"
);
assert!(
rejection.starts_with("Automatic approval review failed:")
&& rejection.contains(error_message),
"rejection message should include guardian rationale: {rejection}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn guardian_review_retries_transient_session_failure_then_approves() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let approval = serde_json::json!({
"risk_level": "low",
"user_authorization": "high",
"outcome": "allow",
"rationale": "retry succeeded",
})
.to_string();
let request_log = mount_sse_sequence(
&server,
vec![
sse_failed(
"resp-session-failure",
"server_is_overloaded",
"temporary reviewer overload",
),
sse(vec![
ev_response_created("resp-approved"),
ev_assistant_message("msg-approved", &approval),
ev_completed("resp-approved"),
]),
],
)
.await;
let (session, turn) = guardian_test_session_and_turn(&server).await;
seed_guardian_parent_history(&session, &turn).await;
let (outcome, metadata) = run_guardian_review_session_for_test(
Arc::clone(&session),
Arc::clone(&turn),
guardian_exec_command_request("shell-session-retry"),
ApprovalRequestReasons::default(),
guardian_output_schema(),
/*external_cancel*/ None,
/*max_attempts*/ 3,
)
.await;
let GuardianReviewOutcome::Completed(assessment) = outcome else {
panic!("expected guardian assessment");
};
assert_eq!(assessment.outcome, GuardianAssessmentOutcome::Allow);
assert_eq!(assessment.rationale, "retry succeeded");
assert_eq!(metadata.attempt_count, 2);
assert!(matches!(
metadata.guardian_session_kind,
Some(codex_analytics::GuardianReviewSessionKind::TrunkReused)
));
assert_eq!(request_log.requests().len(), 2);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn guardian_review_does_not_retry_missing_assessment_payload() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let request_log = mount_sse_sequence(
&server,
vec![sse(vec![
ev_response_created("resp-missing-assessment"),
ev_completed("resp-missing-assessment"),
])],
)
.await;
let (session, turn) = guardian_test_session_and_turn(&server).await;
seed_guardian_parent_history(&session, &turn).await;
let decision = review_approval_request(
&session,
&turn,
"review-missing-assessment".to_string(),
guardian_exec_command_request("shell-missing-assessment"),
ApprovalRequestReasons::default(),
)
.await;
assert!(matches!(decision, ReviewDecision::Denied { .. }));
assert_eq!(request_log.requests().len(), 1);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn guardian_review_retries_two_parse_failures_then_approves() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let approval = serde_json::json!({
"risk_level": "low",
"user_authorization": "high",
"outcome": "allow",
"rationale": "retry succeeded",
})
.to_string();
let request_log = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-parse-failure-1"),
ev_assistant_message("msg-parse-failure-1", "not valid guardian json"),
ev_completed("resp-parse-failure-1"),
]),
sse(vec![
ev_response_created("resp-parse-failure-2"),
ev_assistant_message("msg-parse-failure-2", "still not valid guardian json"),
ev_completed("resp-parse-failure-2"),
]),
sse(vec![
ev_response_created("resp-approved"),
ev_assistant_message("msg-approved", &approval),
ev_completed("resp-approved"),
]),
],
)
.await;
let (session, turn) = guardian_test_session_and_turn(&server).await;
seed_guardian_parent_history(&session, &turn).await;
let (outcome, metadata) = run_guardian_review_session_for_test(
Arc::clone(&session),
Arc::clone(&turn),
guardian_exec_command_request("shell-parse-retry"),
ApprovalRequestReasons::default(),
guardian_output_schema(),
/*external_cancel*/ None,
/*max_attempts*/ 3,
)
.await;
let GuardianReviewOutcome::Completed(assessment) = outcome else {
panic!("expected guardian assessment");
};
assert_eq!(assessment.outcome, GuardianAssessmentOutcome::Allow);
assert_eq!(assessment.rationale, "retry succeeded");
assert_eq!(metadata.attempt_count, 3);
assert!(matches!(
metadata.guardian_session_kind,
Some(codex_analytics::GuardianReviewSessionKind::TrunkReused)
));
assert_eq!(request_log.requests().len(), 3);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn guardian_review_exhausts_three_failures_with_one_terminal_event() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let request_log = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-parse-failure-1"),
ev_assistant_message("msg-parse-failure-1", "invalid one"),
ev_completed("resp-parse-failure-1"),
]),
sse(vec![
ev_response_created("resp-parse-failure-2"),
ev_assistant_message("msg-parse-failure-2", "invalid two"),
ev_completed("resp-parse-failure-2"),
]),
sse(vec![
ev_response_created("resp-parse-failure-3"),
ev_assistant_message("msg-parse-failure-3", "invalid three"),
ev_completed("resp-parse-failure-3"),
]),
],
)
.await;
let (session, turn, rx) = guardian_test_session_turn_and_rx(&server).await;
seed_guardian_parent_history(&session, &turn).await;
let decision = review_approval_request(
&session,
&turn,
"review-exhausted-retry".to_string(),
guardian_exec_command_request("shell-exhausted-retry"),
ApprovalRequestReasons::default(),
)
.await;
assert!(matches!(decision, ReviewDecision::Denied { .. }));
assert_eq!(request_log.requests().len(), 3);
let mut statuses = Vec::new();
while let Ok(event) = rx.try_recv() {
if let EventMsg::GuardianAssessment(event) = event.msg {
statuses.push(event.status);
}
}
assert_eq!(
statuses,
vec![
GuardianAssessmentStatus::InProgress,
GuardianAssessmentStatus::Denied,
]
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn guardian_review_does_not_retry_valid_denial() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let denial = serde_json::json!({
"risk_level": "high",
"user_authorization": "unknown",
"outcome": "deny",
"rationale": "unsafe",
})
.to_string();
let request_log = mount_sse_sequence(
&server,
vec![sse(vec![
ev_response_created("resp-denied"),
ev_assistant_message("msg-denied", &denial),
ev_completed("resp-denied"),
])],
)
.await;
let (session, turn) = guardian_test_session_and_turn(&server).await;
seed_guardian_parent_history(&session, &turn).await;
let decision = review_approval_request(
&session,
&turn,
"review-valid-denial".to_string(),
guardian_exec_command_request("shell-valid-denial"),
ApprovalRequestReasons::default(),
)
.await;
assert!(matches!(decision, ReviewDecision::Denied { .. }));
assert_eq!(request_log.requests().len(), 1);
Ok(())
}
enum RequiredGuardianReview {
SandboxRetry,
EscalatedPermission,
LiveManagedModel,
LiveManagedModelWithGuardianV2,
}
#[test_case::test_case(RequiredGuardianReview::SandboxRetry; "sandbox_retry")]
#[test_case::test_case(RequiredGuardianReview::EscalatedPermission; "escalated_permission")]
#[test_case::test_case(RequiredGuardianReview::LiveManagedModel; "live_managed_action_model")]
#[test_case::test_case(RequiredGuardianReview::LiveManagedModelWithGuardianV2; "live_managed_action_model_with_guardian_v2")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn guardian_review_routes_required_actions(
required_review: RequiredGuardianReview,
) -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
struct AutoApprovingReviewContributor;
impl codex_extension_api::ApprovalReviewContributor for AutoApprovingReviewContributor {
fn decide<'a>(
&'a self,
_input: &'a codex_extension_api::ApprovalDecisionInput<'_>,
) -> codex_extension_api::ExtensionFuture<'a, Option<codex_extension_api::ApprovalDecision>>
{
Box::pin(async { Some(codex_extension_api::ApprovalDecision::Allow) })
}
}
let server = start_mock_server().await;
let denial = serde_json::json!({
"risk_level": "high",
"user_authorization": "unknown",
"outcome": "deny",
"rationale": "The original attempt was blocked by the sandbox.",
})
.to_string();
let request_log = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-escalated-retry"),
ev_assistant_message("msg-escalated-retry", &denial),
ev_completed("resp-escalated-retry"),
]),
)
.await;
let (mut session, mut turn) = guardian_test_session_and_turn(&server).await;
let mut extensions = codex_extension_api::ExtensionRegistryBuilder::<Config>::new();
extensions.approval_review_contributor(Arc::new(AutoApprovingReviewContributor));
Arc::get_mut(&mut session)
.expect("session should be uniquely owned")
.services
.extensions = Arc::new(extensions.build());
let guardian_v2_enabled = matches!(
required_review,
RequiredGuardianReview::LiveManagedModelWithGuardianV2
);
let turn_mut = Arc::get_mut(&mut turn).expect("turn should be uniquely owned");
Arc::make_mut(&mut turn_mut.config)
.features
.set_enabled(Feature::GuardianV2, guardian_v2_enabled)?;
seed_guardian_parent_history(&session, &turn).await;
let retry_reason = "The sandbox blocked the original command.";
let mut context = GuardianReviewContext::from(&turn);
let (request, reasons) = match required_review {
RequiredGuardianReview::SandboxRetry => (
guardian_exec_command_request("shell-sandbox-retry"),
ApprovalRequestReasons {
approval: None,
retry: Some(retry_reason.to_string()),
},
),
RequiredGuardianReview::EscalatedPermission => (
GuardianApprovalRequest::ExecCommand {
id: "shell-escalated-permission".to_string(),
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
sandbox_permissions: crate::sandboxing::SandboxPermissions::RequireEscalated,
additional_permissions: None,
justification: Some("Need to push the reviewed docs fix.".to_string()),
tty: false,
},
ApprovalRequestReasons::default(),
),
RequiredGuardianReview::LiveManagedModel
| RequiredGuardianReview::LiveManagedModelWithGuardianV2 => {
Arc::make_mut(&mut context.model_info).slug = "required-action-model".to_string();
// The admitted turn has neither this model nor the new requirement.
let mut config = session.get_config().await.as_ref().clone();
let requirements = codex_config::ConfigRequirements {
auto_review_required_models: Some(Sourced::new(
std::collections::BTreeSet::from([context.model_info.slug.clone()]),
RequirementSource::Unknown,
)),
..Default::default()
};
config.config_layer_stack = ConfigLayerStack::new(
config
.config_layer_stack
.all_layers_low_to_high()
.cloned()
.collect(),
requirements,
config.config_layer_stack.requirements_toml().clone(),
)?;
session.refresh_mcp_config(config).await;
(
guardian_exec_command_request("shell-live-managed-model"),
ApprovalRequestReasons::default(),
)
}
};
let is_retry = reasons.retry.is_some();
let decision = review_approval_request(
&session,
context,
"review-required-guardian".to_string(),
request,
reasons,
)
.await;
if guardian_v2_enabled {
assert_eq!(decision, ReviewDecision::Approved);
assert!(request_log.requests().is_empty());
} else {
assert!(matches!(decision, ReviewDecision::Denied { .. }));
assert_eq!(
request_log
.single_request()
.body_contains_text(retry_reason),
is_retry,
);
}
Ok(())
}
#[tokio::test]
async fn guardian_ephemeral_retry_preserves_parallel_trunk_and_fork_history() -> anyhow::Result<()>
{
const TEST_STACK_SIZE_BYTES: usize = 4 * 1024 * 1024;
let handle = std::thread::Builder::new()
.name("guardian_ephemeral_retry_preserves_parallel_trunk_and_fork_history".to_string())
.stack_size(TEST_STACK_SIZE_BYTES)
.spawn(|| -> anyhow::Result<()> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
runtime.block_on(Box::pin(async {
let first_assessment = serde_json::json!({
"risk_level": "low",
"user_authorization": "high",
"outcome": "allow",
"rationale": "first guardian rationale",
})
.to_string();
let second_assessment = serde_json::json!({
"risk_level": "low",
"user_authorization": "high",
"outcome": "allow",
"rationale": "second guardian rationale",
})
.to_string();
let third_assessment = serde_json::json!({
"risk_level": "low",
"user_authorization": "high",
"outcome": "allow",
"rationale": "third guardian rationale",
})
.to_string();
let (gate_tx, gate_rx) = tokio::sync::oneshot::channel();
let (server, _) = start_streaming_sse_server(vec![
vec![StreamingSseChunk {
gate: None,
body: sse(vec![
ev_response_created("resp-guardian-1"),
ev_assistant_message("msg-guardian-1", &first_assessment),
ev_completed("resp-guardian-1"),
]),
}],
vec![
StreamingSseChunk {
gate: None,
body: sse(vec![ev_response_created("resp-guardian-2")]),
},
StreamingSseChunk {
gate: Some(gate_rx),
body: sse(vec![
ev_assistant_message("msg-guardian-2", &second_assessment),
ev_completed("resp-guardian-2"),
]),
},
],
vec![StreamingSseChunk {
gate: None,
body: sse(vec![
ev_response_created("resp-guardian-3"),
ev_assistant_message("msg-guardian-3", "not valid guardian json"),
ev_completed("resp-guardian-3"),
]),
}],
vec![StreamingSseChunk {
gate: None,
body: sse(vec![
ev_response_created("resp-guardian-4"),
ev_assistant_message("msg-guardian-4", &third_assessment),
ev_completed("resp-guardian-4"),
]),
}],
])
.await;
let (mut session, turn) = guardian_test_session_and_turn_with_base_url(server.uri()).await;
// Isolate feedback from other tests using the fixed parent session ID.
Arc::get_mut(&mut session)
.expect("session should be uniquely owned")
.thread_id = ThreadId::new();
turn.turn_metadata_state
.set_parent_turn_id("upstream-parent-turn".to_string());
turn.turn_metadata_state
.set_root_turn_id("causal-root-turn".to_string());
seed_guardian_parent_history(&session, &turn).await;
let initial_request = GuardianApprovalRequest::ExecCommand {
id: "shell-guardian-1".to_string(),
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
command: vec!["git".to_string(), "status".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Inspect repo state before proceeding.".to_string()),
tty: false,
};
assert_eq!(
review_approval_request(
&session,
&turn,
"review-shell-guardian-1".to_string(),
initial_request,
ApprovalRequestReasons::default()
)
.await,
ReviewDecision::Approved
);
session
.record_conversation_items(
turn.as_ref(), turn.model_info(),
&[
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "Please inspect pending changes before pushing.".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,},
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "I need approval to run git diff.".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,},
],
)
.await;
let second_request = GuardianApprovalRequest::ExecCommand {
id: "shell-guardian-2".to_string(),
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
command: vec!["git".to_string(), "diff".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Inspect pending changes before proceeding.".to_string()),
tty: false,
};
let third_request = GuardianApprovalRequest::ExecCommand {
id: "shell-guardian-3".to_string(),
environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(),
command: vec!["git".to_string(), "push".to_string()],
cwd: test_path_buf("/repo/codex-rs/core").abs().into(),
guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"),
sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault,
additional_permissions: None,
justification: Some("Inspect whether pushing is safe before proceeding.".to_string()),
tty: false,
};
let session_for_second = Arc::clone(&session);
let turn_for_second = Arc::clone(&turn);
let mut second_review = tokio::spawn(async move {
review_approval_request(
&session_for_second,
&turn_for_second,
"review-shell-guardian-2".to_string(),
second_request,
ApprovalRequestReasons {
approval: None,
retry: Some("trunk follow-up".to_string()),
},
)
.await
});
let second_request_observed = tokio::time::timeout(Duration::from_secs(5), async {
loop {
if server.requests().await.len() >= 2 {
break;
}
tokio::task::yield_now().await;
}
})
.await;
assert!(
second_request_observed.is_ok(),
"second guardian request was not observed"
);
session
.record_conversation_items(
turn.as_ref(), turn.model_info(),
&[
ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "Now inspect whether pushing is safe.".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,},
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "I need approval to push after the diff check.".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,},
],
)
.await;
let third_decision = review_approval_request(
&session,
&turn,
"review-shell-guardian-3".to_string(),
third_request,
ApprovalRequestReasons {
approval: None,
retry: Some("parallel follow-up".to_string()),
},
)
.await;
assert_eq!(third_decision, ReviewDecision::Approved);
let requests = server.requests().await;
assert_eq!(requests.len(), 4);
let first_request_body = serde_json::from_slice::<serde_json::Value>(&requests[0])?;
let second_request_body = serde_json::from_slice::<serde_json::Value>(&requests[1])?;
let failed_ephemeral_request_body =
serde_json::from_slice::<serde_json::Value>(&requests[2])?;
let retried_ephemeral_request_body =
serde_json::from_slice::<serde_json::Value>(&requests[3])?;
let mut reviewer_turn_ids = std::collections::BTreeSet::new();
for (body, expected_root) in [
(&first_request_body, Some("causal-root-turn")),
(&second_request_body, Some("causal-root-turn")),
(&failed_ephemeral_request_body, Some("causal-root-turn")),
(&retried_ephemeral_request_body, Some("causal-root-turn")),
] {
assert_parent_turn(body, Some(turn.sub_id.as_str()))?;
assert_root_turn(body, expected_root)?;
assert_ne!(body["client_metadata"]["turn_id"], turn.sub_id);
reviewer_turn_ids.insert(
body["client_metadata"]["turn_id"].as_str().expect("reviewer turn id")
);
}
assert_eq!(reviewer_turn_ids.len(), 4);
assert_eq!(
second_request_body["prompt_cache_key"],
failed_ephemeral_request_body["prompt_cache_key"],
"forked guardian review should reuse the trunk guardian prompt cache key"
);
assert_eq!(
failed_ephemeral_request_body["prompt_cache_key"],
retried_ephemeral_request_body["prompt_cache_key"],
"retried ephemeral review should preserve the guardian prompt cache key"
);
let third_request_body_text = retried_ephemeral_request_body.to_string();
assert!(
third_request_body_text.contains("first guardian rationale"),
"forked guardian review should include the last committed trunk assessment"
);
let third_user_message = last_user_message_text_from_body(&retried_ephemeral_request_body);
assert!(third_user_message.contains(">>> TRANSCRIPT DELTA START\n"));
assert!(
third_user_message.contains("[5] user: Please inspect pending changes before pushing.")
);
assert!(third_user_message.contains("[7] user: Now inspect whether pushing is safe."));
assert!(!third_user_message.contains("[1] user: Please check the repo visibility"));
assert!(
!third_request_body_text.contains("second guardian rationale"),
"forked guardian review should not include the still in-flight trunk assessment"
);
assert!(
tokio::time::timeout(Duration::from_millis(100), &mut second_review)
.await
.is_err(),
"the trunk guardian review should still be blocked on its gated response"
);
gate_tx
.send(())
.expect("second guardian review gate should still be open");
assert_eq!(second_review.await?, ReviewDecision::Approved);
let feedback = codex_feedback::guardian_review_failures(&[session.thread_id()])
.attachment
.expect("failed ephemeral review survives cleanup and subsequent allowed reviews");
let record: serde_json::Value = serde_json::from_slice(&feedback.buffer)?;
assert_eq!(
serde_json::json!({
"reviewed_thread_id": record["reviewed_thread_id"],
"reviewed_turn_id": record["reviewed_turn_id"],
"target_item_id": record["target_item_id"],
"reviewer_thread_id": record["reviewer_thread_id"],
"status": record["status"],
"decision": record["decision"],
"command": serde_json::from_str::<serde_json::Value>(
record["action"].as_str().expect("reviewed action")
)?["command"],
}),
serde_json::json!({
"reviewed_thread_id": session.thread_id(),
"reviewed_turn_id": turn.sub_id,
"target_item_id": "shell-guardian-3",
"reviewer_thread_id": failed_ephemeral_request_body["client_metadata"]["thread_id"],
"status": "invalid_decision",
"decision": "not valid guardian json",
"command": ["git", "push"],
})
);
server.shutdown().await;
Ok(())
}))
})?;
match handle.join() {
Ok(result) => result,
Err(_) => Err(anyhow::anyhow!(
"guardian_ephemeral_retry_preserves_parallel_trunk_and_fork_history thread panicked"
)),
}
}
#[tokio::test]
async fn guardian_review_session_config_preserves_parent_network_proxy() {
let mut parent_config = test_config().await;
let network = NetworkProxySpec::from_config_and_constraints(
NetworkProxyConfig::default(),
Some(NetworkConstraints {
enabled: Some(true),
domains: Some(NetworkDomainPermissionsToml {
entries: std::collections::BTreeMap::from([(
"github.com".to_string(),
NetworkDomainPermissionToml::Allow,
)]),
}),
..Default::default()
}),
parent_config.permissions.permission_profile(),
)
.expect("network proxy spec");
parent_config.permissions.network = Some(network.clone());
let guardian_config = build_guardian_review_session_config_for_test(
crate::guardian::test_host::build_reviewer_config(&parent_config).expect("reviewer config"),
/*live_network_config*/ None,
"parent-active-model",
Some(codex_protocol::openai_models::ReasoningEffort::Low),
ReasoningSummary::default(),
/*personality*/ None,
/*model_messages*/ None,
)
.expect("guardian config");
assert_eq!(guardian_config.permissions.network, Some(network));
assert_eq!(
guardian_config.model,
Some("parent-active-model".to_string())
);
assert_eq!(
guardian_config.model_reasoning_effort,
Some(codex_protocol::openai_models::ReasoningEffort::Low)
);
assert_eq!(
guardian_config.permissions.approval_policy,
Constrained::allow_only(AskForApproval::Never)
);
assert_eq!(
guardian_config.permissions.permission_profile(),
&PermissionProfile::read_only()
);
}
#[tokio::test]
async fn guardian_review_session_config_clears_context_overrides_for_distinct_effective_model() {
let server = start_mock_server().await;
let (session, mut turn) = guardian_test_session_and_turn(&server).await;
let mut config = (*turn.config).clone();
config.model = Some("codex-auto-review".to_string());
config.model_context_window = Some(900_000);
config.model_auto_compact_token_limit = Some(600_000);
Arc::get_mut(&mut turn)
.expect("turn should be unique")
.config = Arc::new(config);
let guardian_config =
guardian_review_session_config(session.as_ref(), &GuardianReviewContext::from(&turn))
.await
.expect("guardian config")
.spawn_config;
assert_eq!(
(
guardian_config.model_context_window,
guardian_config.model_auto_compact_token_limit,
),
(None, None)
);
}
#[tokio::test]
async fn guardian_review_session_config_preserves_context_overrides_for_same_effective_model() {
let server = start_mock_server().await;
let (mut session, mut turn) = guardian_test_session_and_turn(&server).await;
let parent_model = turn.model_info().as_ref().clone();
let auth_manager = Arc::clone(&session.services.auth_manager);
Arc::get_mut(&mut session)
.expect("session should be unique")
.services
.models_manager = Arc::new(StaticModelsManager::new(
Some(auth_manager),
ModelsResponse {
models: vec![parent_model],
},
));
let mut config = (*turn.config).clone();
config.model = Some("stale-parent-model".to_string());
config.model_context_window = Some(128_000);
config.model_auto_compact_token_limit = Some(100_000);
Arc::get_mut(&mut turn)
.expect("turn should be unique")
.config = Arc::new(config);
let guardian_config =
guardian_review_session_config(session.as_ref(), &GuardianReviewContext::from(&turn))
.await
.expect("guardian config")
.spawn_config;
assert_eq!(
(
guardian_config.model_context_window,
guardian_config.model_auto_compact_token_limit,
),
(Some(128_000), Some(100_000))
);
}
#[tokio::test]
async fn guardian_review_session_config_clears_parent_developer_instructions() {
let mut parent_config = test_config().await;
parent_config.developer_instructions =
Some("parent or managed config should not replace guardian policy".to_string());
let guardian_config = build_guardian_review_session_config_for_test(
crate::guardian::test_host::build_reviewer_config(&parent_config).expect("reviewer config"),
/*live_network_config*/ None,
"active-model",
/*reasoning_effort*/ None,
ReasoningSummary::default(),
/*personality*/ None,
/*model_messages*/ None,
)
.expect("guardian config");
assert_eq!(guardian_config.developer_instructions, None);
assert_eq!(
guardian_config.base_instructions,
Some(guardian_policy_prompt_with_config_and_template(
BUNDLED_GUARDIAN_POLICY,
BUNDLED_GUARDIAN_POLICY_TEMPLATE,
))
);
}
#[tokio::test]
async fn guardian_review_session_config_clears_legacy_notify() {
let mut parent_config = test_config().await;
parent_config.notify = Some(vec![
"/path/to/notify".to_string(),
"turn-ended".to_string(),
]);
let guardian_config = build_guardian_review_session_config_for_test(
crate::guardian::test_host::build_reviewer_config(&parent_config).expect("reviewer config"),
/*live_network_config*/ None,
"active-model",
/*reasoning_effort*/ None,
ReasoningSummary::default(),
/*personality*/ None,
/*model_messages*/ None,
)
.expect("guardian config");
assert_eq!(guardian_config.notify, None);
}
#[tokio::test]
async fn guardian_review_session_config_uses_live_network_proxy_state() {
let mut parent_config = test_config().await;
let mut parent_network = NetworkProxyConfig {
enabled: true,
..Default::default()
};
parent_network.set_allowed_domains(vec!["parent.example".to_string()]);
parent_config.permissions.network = Some(
NetworkProxySpec::from_config_and_constraints(
parent_network,
/*requirements*/ None,
parent_config.permissions.permission_profile(),
)
.expect("parent network proxy spec"),
);
let mut live_network = NetworkProxyConfig {
enabled: true,
..Default::default()
};
live_network.set_allowed_domains(vec!["github.com".to_string()]);
let guardian_config = build_guardian_review_session_config_for_test(
crate::guardian::test_host::build_reviewer_config(&parent_config).expect("reviewer config"),
Some(live_network.clone()),
"active-model",
/*reasoning_effort*/ None,
ReasoningSummary::default(),
/*personality*/ None,
/*model_messages*/ None,
)
.expect("guardian config");
assert_eq!(
guardian_config.permissions.network,
Some(
NetworkProxySpec::from_config_and_constraints(
live_network,
/*requirements*/ None,
&PermissionProfile::read_only(),
)
.expect("live network proxy spec")
)
);
}
#[tokio::test]
async fn guardian_review_session_config_disables_mcp_apps_plugins_memories_and_guardian_v2() {
let mut parent_config = test_config().await;
let server: McpServerConfig =
toml::from_str("command = \"docs-server\"").expect("deserialize MCP server");
parent_config
.mcp_servers
.set(HashMap::from([("docs".to_string(), server)]))
.expect("parent MCP servers are configurable");
parent_config
.features
.enable(Feature::Apps)
.expect("apps feature is configurable");
parent_config
.features
.enable(Feature::Plugins)
.expect("plugins feature is configurable");
parent_config
.features
.enable(Feature::GuardianV2)
.expect("guardian v2 feature is configurable");
parent_config.include_apps_instructions = true;
parent_config.memories.use_memories = true;
parent_config.memories.dedicated_tools = true;
let guardian_config = build_guardian_review_session_config_for_test(
crate::guardian::test_host::build_reviewer_config(&parent_config).expect("reviewer config"),
/*live_network_config*/ None,
"active-model",
/*reasoning_effort*/ None,
ReasoningSummary::default(),
/*personality*/ None,
/*model_messages*/ None,
)
.expect("guardian config");
assert!(guardian_config.mcp_servers.get().is_empty());
assert!(!guardian_config.features.enabled(Feature::Apps));
assert!(!guardian_config.features.enabled(Feature::Plugins));
assert!(!guardian_config.features.enabled(Feature::GuardianV2));
assert!(!guardian_config.include_apps_instructions);
assert!(!guardian_config.memories.use_memories);
assert!(!guardian_config.memories.dedicated_tools);
}
#[tokio::test]
async fn guardian_review_session_config_allows_pinned_disabled_feature() {
let mut parent_config = test_config().await;
parent_config.features = ManagedFeatures::from_configured(
parent_config.features.get().clone(),
Some(Sourced {
value: FeatureRequirementsToml {
entries: BTreeMap::from([("multi_agent".to_string(), true)]),
},
source: RequirementSource::Unknown,
}),
)
.expect("managed features");
let guardian_config = build_guardian_review_session_config_for_test(
crate::guardian::test_host::build_reviewer_config(&parent_config).expect("reviewer config"),
/*live_network_config*/ None,
"active-model",
/*reasoning_effort*/ None,
ReasoningSummary::default(),
/*personality*/ None,
/*model_messages*/ None,
)
.expect("guardian config should continue when a disabled feature is pinned on");
assert!(guardian_config.features.enabled(Feature::Collab));
assert!(guardian_config.mcp_servers.get().is_empty());
assert!(!guardian_config.include_apps_instructions);
}
#[tokio::test]
async fn guardian_review_session_config_uses_parent_active_model_instead_of_hardcoded_slug() {
let mut parent_config = test_config().await;
parent_config.model = Some("configured-model".to_string());
let guardian_config = build_guardian_review_session_config_for_test(
crate::guardian::test_host::build_reviewer_config(&parent_config).expect("reviewer config"),
/*live_network_config*/ None,
"active-model",
/*reasoning_effort*/ None,
ReasoningSummary::default(),
/*personality*/ None,
/*model_messages*/ None,
)
.expect("guardian config");
assert_eq!(guardian_config.model, Some("active-model".to_string()));
}
#[tokio::test]
async fn guardian_review_session_config_keeps_bedrock_provider_for_bedrock_gpt_5_4() {
let mut parent_config = test_config().await;
parent_config.model_provider_id = AMAZON_BEDROCK_PROVIDER_ID.to_string();
parent_config.model_provider =
ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None);
let guardian_config = build_guardian_review_session_config_for_test(
crate::guardian::test_host::build_reviewer_config(&parent_config).expect("reviewer config"),
/*live_network_config*/ None,
AMAZON_BEDROCK_GPT_5_4_MODEL_ID,
Some(ReasoningEffort::Low),
ReasoningSummary::default(),
/*personality*/ None,
/*model_messages*/ None,
)
.expect("guardian config");
let mut expected_model_provider =
ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None);
expected_model_provider.request_max_retries = Some(1);
expected_model_provider.stream_max_retries = Some(1);
assert_eq!(
(
guardian_config.model,
guardian_config.model_provider_id,
guardian_config.model_provider,
),
(
Some(AMAZON_BEDROCK_GPT_5_4_MODEL_ID.to_string()),
AMAZON_BEDROCK_PROVIDER_ID.to_string(),
expected_model_provider,
)
);
}
#[tokio::test]
async fn guardian_review_session_config_uses_requirements_guardian_policy_config() {
let codex_home = tempfile::tempdir().expect("create temp dir");
let workspace = tempfile::tempdir().expect("create temp dir");
let config_layer_stack = ConfigLayerStack::new(
Vec::new(),
Default::default(),
codex_config::ConfigRequirementsToml {
guardian_policy_config: Some(
" Use the workspace-managed guardian policy. ".to_string(),
),
..Default::default()
},
)
.expect("config layer stack");
let parent_config = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
ConfigToml::default(),
ConfigOverrides {
cwd: Some(workspace.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
config_layer_stack,
)
.await
.expect("load config");
let guardian_config = build_guardian_review_session_config_for_test(
crate::guardian::test_host::build_reviewer_config(&parent_config).expect("reviewer config"),
/*live_network_config*/ None,
"active-model",
/*reasoning_effort*/ None,
ReasoningSummary::default(),
/*personality*/ None,
/*model_messages*/ None,
)
.expect("guardian config");
assert_eq!(guardian_config.developer_instructions, None);
assert_eq!(
guardian_config.base_instructions,
Some(guardian_policy_prompt_with_config_and_template(
"Use the workspace-managed guardian policy.",
BUNDLED_GUARDIAN_POLICY_TEMPLATE,
))
);
}
#[tokio::test]
async fn guardian_review_session_config_uses_default_guardian_policy_without_requirements_override()
{
let codex_home = tempfile::tempdir().expect("create temp dir");
let workspace = tempfile::tempdir().expect("create temp dir");
let config_layer_stack =
ConfigLayerStack::new(Vec::new(), Default::default(), Default::default())
.expect("config layer stack");
let parent_config = Config::load_config_with_layer_stack(
LOCAL_FS.as_ref(),
ConfigToml::default(),
ConfigOverrides {
cwd: Some(workspace.path().to_path_buf()),
..Default::default()
},
codex_home.abs(),
config_layer_stack,
)
.await
.expect("load config");
let guardian_config = build_guardian_review_session_config_for_test(
crate::guardian::test_host::build_reviewer_config(&parent_config).expect("reviewer config"),
/*live_network_config*/ None,
"active-model",
/*reasoning_effort*/ None,
ReasoningSummary::default(),
/*personality*/ None,
/*model_messages*/ None,
)
.expect("guardian config");
assert_eq!(guardian_config.developer_instructions, None);
assert_eq!(
guardian_config.base_instructions,
Some(guardian_policy_prompt_with_config_and_template(
BUNDLED_GUARDIAN_POLICY,
BUNDLED_GUARDIAN_POLICY_TEMPLATE,
))
);
}
// Keep the existing reviewer tests on the production decision path.
async fn review_approval_request(
session: &Arc<Session>,
context: impl Into<GuardianReviewContext>,
review_id: String,
request: GuardianApprovalRequest,
reasons: ApprovalRequestReasons,
) -> ReviewDecision {
super::decide_approval(
Arc::clone(session),
context,
review_id,
request,
reasons,
GuardianReviewOptions {
require_guardian: true,
plugin_attribution_override: None,
approval_request_source: GuardianApprovalRequestSource::MainTurn,
external_cancel: None,
require_synchronous_review: false,
},
)
.await
.expect("Guardian should handle the request")
}
/// Whether this turn should route allowed approval prompts through the guardian
/// reviewer instead of surfacing them to the user. ARC may still block actions
/// earlier in the flow.
fn routes_approval_to_guardian(turn: &crate::session::turn_context::TurnContext) -> bool {
routes_approval_to_guardian_with_reviewer(turn, turn.config.approvals_reviewer)
}
/// Whether an approval with its own reviewer selection should be routed through guardian.
fn routes_approval_to_guardian_with_reviewer(
turn: &crate::session::turn_context::TurnContext,
approvals_reviewer: ApprovalsReviewer,
) -> bool {
routes_approval_policy_to_guardian(turn.approval_policy(), approvals_reviewer)
}