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 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> { Box::pin(async move { if thread_store .get::() .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, Arc) { 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, Arc, async_channel::Receiver, ) { 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, Arc) { 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, turn: &Arc) { 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::>(); let entries = requests .iter() .zip(&bodies) .map(|((label, _), body)| SnapshotEntry::body(body).labeled(label)) .collect::>(); 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::() } 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::() } #[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("")); 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 { 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: "\n/tmp\n".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(" 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, > { self.0.cancel(); Box::pin(async { Some(codex_extension_api::ApprovalDecision::Allow) }) } } let mut extensions = codex_extension_api::ExtensionRegistryBuilder::::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::>(); 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::>(); 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::>() ); 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::>(); 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, 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::::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::>(); 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::>(); 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::(); 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> { 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::::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::(&requests[0])?; let second_request_body = serde_json::from_slice::(&requests[1])?; let failed_ephemeral_request_body = serde_json::from_slice::(&requests[2])?; let retried_ephemeral_request_body = serde_json::from_slice::(&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::( 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, context: impl Into, 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) }