Download codex-rs/core/src/guardian/tests.rs from SaylorTwift/codex: direct link, hf CLI and curl.
- Browser
- Download file 161 kB
-
https://huggingface.co/SaylorTwift/codex/resolve/main/codex-rs/core/src/guardian/tests.rs
- Command line
-
hf download hf://SaylorTwift/codex/codex-rs/core/src/guardian/tests.rs
-
curl -L -o tests.rs https://huggingface.co/SaylorTwift/codex/resolve/main/codex-rs/core/src/guardian/tests.rs
161 kB
| use super::*; | |
| use crate::config::Config; | |
| use crate::config::ConfigOverrides; | |
| use crate::config::Constrained; | |
| use crate::config::ManagedFeatures; | |
| use crate::config::NetworkProxySpec; | |
| use crate::config::PermissionProfileSnapshot; | |
| use crate::config::test_config; | |
| use crate::environment_selection::TurnEnvironmentState; | |
| use crate::guardian::approval_request::guardian_request_target_item_id; | |
| use crate::guardian::prompt::BUNDLED_GUARDIAN_POLICY; | |
| use crate::guardian::prompt::BUNDLED_GUARDIAN_POLICY_TEMPLATE; | |
| use crate::guardian::prompt::guardian_policy_prompt_with_config_and_template; | |
| use crate::guardian::review::guardian_review_session_config; | |
| use crate::session::session::Session; | |
| use crate::session::tests::update_turn_settings_for_test; | |
| use crate::session::turn_context::TurnContext; | |
| use crate::test_support; | |
| use codex_analytics::GuardianApprovalRequestSource; | |
| use codex_config::ConfigLayerStack; | |
| use codex_config::FeatureRequirementsToml; | |
| use codex_config::NetworkConstraints; | |
| use codex_config::NetworkDomainPermissionToml; | |
| use codex_config::NetworkDomainPermissionsToml; | |
| use codex_config::RequirementSource; | |
| use codex_config::Sourced; | |
| use codex_config::config_toml::ConfigToml; | |
| use codex_config::types::McpServerConfig; | |
| use codex_exec_server::LOCAL_FS; | |
| use codex_features::Feature; | |
| use codex_guardian_context::ConversationTranscriptEntry; | |
| use codex_guardian_context::ConversationTranscriptEntryKind; | |
| use codex_history::RolloutItem; | |
| use codex_model_provider::create_model_provider; | |
| use codex_model_provider_info::AMAZON_BEDROCK_GPT_5_4_MODEL_ID; | |
| use codex_model_provider_info::AMAZON_BEDROCK_PROVIDER_ID; | |
| use codex_model_provider_info::ModelProviderInfo; | |
| use codex_model_provider_info::OPENAI_PROVIDER_ID; | |
| use codex_models_manager::manager::StaticModelsManager; | |
| use codex_network_proxy::NetworkProxyConfig; | |
| use codex_protocol::ThreadId; | |
| use codex_protocol::approvals::GuardianAssessmentAction; | |
| use codex_protocol::approvals::NetworkApprovalProtocol; | |
| use codex_protocol::config_types::ApprovalsReviewer; | |
| use codex_protocol::config_types::ReasoningSummary; | |
| use codex_protocol::models::ContentItem; | |
| use codex_protocol::models::PermissionProfile; | |
| use codex_protocol::models::ResponseItem; | |
| use codex_protocol::models::SandboxPermissions; | |
| use codex_protocol::openai_models::ModelsResponse; | |
| use codex_protocol::openai_models::ReasoningEffort; | |
| use codex_protocol::permissions::FileSystemAccessMode; | |
| use codex_protocol::permissions::FileSystemPath; | |
| use codex_protocol::permissions::FileSystemSandboxEntry; | |
| use codex_protocol::permissions::FileSystemSandboxPolicy; | |
| use codex_protocol::permissions::NetworkSandboxPolicy; | |
| use codex_protocol::protocol::AskForApproval; | |
| use codex_protocol::protocol::Event; | |
| use codex_protocol::protocol::EventMsg; | |
| use codex_protocol::protocol::GranularApprovalConfig; | |
| use codex_protocol::protocol::GuardianAssessmentStatus; | |
| use codex_protocol::protocol::ReviewDecision; | |
| use codex_protocol::protocol::TurnCompleteEvent; | |
| use codex_utils_path_uri::LegacyAppPathString; | |
| use codex_utils_path_uri::PathUri; | |
| use core_test_support::PathBufExt; | |
| use core_test_support::TempDirExt; | |
| use core_test_support::context_snapshot; | |
| use core_test_support::context_snapshot::ContextSnapshotOptions; | |
| use core_test_support::context_snapshot::SnapshotEntry; | |
| use core_test_support::responses::ResponsesRequest; | |
| use core_test_support::responses::assert_parent_turn; | |
| use core_test_support::responses::assert_root_turn; | |
| use core_test_support::responses::ev_assistant_message; | |
| use core_test_support::responses::ev_completed; | |
| use core_test_support::responses::ev_response_created; | |
| use core_test_support::responses::mount_response_sequence; | |
| use core_test_support::responses::mount_sse_once; | |
| use core_test_support::responses::mount_sse_sequence; | |
| use core_test_support::responses::sse; | |
| use core_test_support::responses::sse_failed; | |
| use core_test_support::responses::start_mock_server; | |
| use core_test_support::skip_if_no_network; | |
| use core_test_support::streaming_sse::StreamingSseChunk; | |
| use core_test_support::streaming_sse::start_streaming_sse_server; | |
| use core_test_support::test_path_buf; | |
| use insta::Settings; | |
| use insta::assert_snapshot; | |
| use pretty_assertions::assert_eq; | |
| use std::collections::BTreeMap; | |
| use std::collections::HashMap; | |
| use std::sync::Arc; | |
| use std::time::Duration; | |
| use tempfile::TempDir; | |
| use tokio_util::sync::CancellationToken; | |
| fn fixed_guardian_parent_session_id() -> ThreadId { | |
| ThreadId::from_string("11111111-1111-4111-8111-111111111111") | |
| .expect("fixed parent session id should be a valid UUID") | |
| } | |
| const GUARDIAN_MEMORY_CONTEXT_PROBE: &str = "guardian memory context probe"; | |
| const GUARDIAN_SKILL_NAME: &str = "guardian-context-probe"; | |
| const GUARDIAN_SKILL_BODY_PROBE: &str = "guardian skill body probe"; | |
| // The memories extension depends on codex-core, so this probe verifies the nested Guardian config | |
| // at request assembly without introducing a circular test dependency. | |
| struct GuardianMemoryContextEnabled(bool); | |
| struct GuardianMemoryContextProbe; | |
| impl codex_extension_api::ThreadLifecycleContributor<Config> for GuardianMemoryContextProbe { | |
| fn on_thread_start<'a>( | |
| &'a self, | |
| input: codex_extension_api::ThreadStartInput<'a, Config>, | |
| ) -> codex_extension_api::ExtensionFuture<'a, ()> { | |
| Box::pin(async move { | |
| input.thread_store.insert(GuardianMemoryContextEnabled( | |
| input.config.memories.use_memories, | |
| )); | |
| }) | |
| } | |
| } | |
| impl codex_extension_api::ContextContributor for GuardianMemoryContextProbe { | |
| fn contribute_thread_context<'a>( | |
| &'a self, | |
| _session_store: &'a codex_extension_api::ExtensionData, | |
| thread_store: &'a codex_extension_api::ExtensionData, | |
| ) -> codex_extension_api::ExtensionFuture<'a, Vec<codex_extension_api::PromptFragment>> { | |
| Box::pin(async move { | |
| if thread_store | |
| .get::<GuardianMemoryContextEnabled>() | |
| .is_some_and(|enabled| enabled.0) | |
| { | |
| vec![codex_extension_api::PromptFragment::developer_policy( | |
| GUARDIAN_MEMORY_CONTEXT_PROBE, | |
| codex_extension_api::ContentItemKind( | |
| "guardian.memory_context_probe".to_string(), | |
| ), | |
| )] | |
| } else { | |
| Vec::new() | |
| } | |
| }) | |
| } | |
| } | |
| async fn guardian_test_session_and_turn( | |
| server: &wiremock::MockServer, | |
| ) -> (Arc<Session>, Arc<TurnContext>) { | |
| guardian_test_session_and_turn_with_base_url(server.uri().as_str()).await | |
| } | |
| async fn guardian_test_session_turn_and_rx( | |
| server: &wiremock::MockServer, | |
| ) -> ( | |
| Arc<Session>, | |
| Arc<TurnContext>, | |
| async_channel::Receiver<Event>, | |
| ) { | |
| let (mut session, mut turn, rx) = | |
| crate::session::tests::make_session_and_context_with_rx().await; | |
| Arc::get_mut(&mut session) | |
| .expect("session should be uniquely owned") | |
| .thread_id = fixed_guardian_parent_session_id(); | |
| let mut config = (*turn.config).clone(); | |
| config.model_provider.base_url = Some(format!("{}/v1", server.uri())); | |
| let config = Arc::new(config); | |
| let models_manager = test_support::models_manager_with_provider( | |
| config.codex_home.to_path_buf(), | |
| Arc::clone(&session.services.auth_manager), | |
| config.model_provider.clone(), | |
| ); | |
| Arc::get_mut(&mut session) | |
| .expect("session should be uniquely owned") | |
| .services | |
| .models_manager = models_manager; | |
| crate::guardian::test_host::install(&session, &config); | |
| let turn_mut = Arc::get_mut(&mut turn).expect("turn should be uniquely owned"); | |
| turn_mut.config = Arc::clone(&config); | |
| turn_mut.provider = | |
| create_model_provider(config.model_provider.clone(), turn_mut.auth_manager.clone()); | |
| (session, turn, rx) | |
| } | |
| fn native_guardian_cwd(path: &str) -> LegacyAppPathString { | |
| LegacyAppPathString::from_abs_path(&test_path_buf(path).abs()) | |
| } | |
| fn guardian_exec_command_request(id: &str) -> GuardianApprovalRequest { | |
| GuardianApprovalRequest::ExecCommand { | |
| id: id.to_string(), | |
| environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), | |
| command: vec!["git".to_string(), "push".to_string()], | |
| cwd: test_path_buf("/repo/codex-rs/core").abs().into(), | |
| guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"), | |
| sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, | |
| additional_permissions: None, | |
| justification: Some("Need to push the reviewed docs fix.".to_string()), | |
| tty: false, | |
| } | |
| } | |
| fn guardian_mcp_request(server: &str, tool_name: &str) -> GuardianApprovalRequest { | |
| GuardianApprovalRequest::McpToolCall { | |
| id: "mcp-1".to_string(), | |
| server: server.to_string(), | |
| tool_name: tool_name.to_string(), | |
| arguments: Some(serde_json::json!({ | |
| "code": "await browser.open('https://example.com')", | |
| })), | |
| connector_id: Some("connector-1".to_string()), | |
| connector_name: Some("Connected tools".to_string()), | |
| connector_description: None, | |
| connected_account_email: None, | |
| tool_title: Some("Execute JavaScript".to_string()), | |
| tool_description: None, | |
| annotations: Some(GuardianMcpAnnotations { | |
| destructive_hint: None, | |
| open_world_hint: Some(true), | |
| read_only_hint: None, | |
| }), | |
| } | |
| } | |
| async fn guardian_test_session_and_turn_with_base_url( | |
| base_url: &str, | |
| ) -> (Arc<Session>, Arc<TurnContext>) { | |
| let (mut session, mut turn) = crate::session::tests::make_session_and_context().await; | |
| session.thread_id = fixed_guardian_parent_session_id(); | |
| let mut config = (*turn.config).clone(); | |
| config.model_provider.base_url = Some(format!("{base_url}/v1")); | |
| let config = Arc::new(config); | |
| let models_manager = test_support::models_manager_with_provider( | |
| config.codex_home.to_path_buf(), | |
| Arc::clone(&session.services.auth_manager), | |
| config.model_provider.clone(), | |
| ); | |
| session.services.models_manager = models_manager; | |
| crate::guardian::test_host::install(&session, &config); | |
| turn.config = Arc::clone(&config); | |
| turn.provider = create_model_provider(config.model_provider.clone(), turn.auth_manager.clone()); | |
| (Arc::new(session), Arc::new(turn)) | |
| } | |
| async fn seed_guardian_parent_history(session: &Arc<Session>, turn: &Arc<TurnContext>) { | |
| session | |
| .record_conversation_items( | |
| turn.as_ref(), | |
| turn.model_info(), | |
| &[ | |
| ResponseItem::Message { | |
| id: None, | |
| role: "user".to_string(), | |
| content: vec![ContentItem::InputText { | |
| text: "Please check the repo visibility and push the docs fix if needed." | |
| .to_string(), | |
| }], | |
| phase: None, | |
| internal_chat_message_metadata_passthrough: None, | |
| }, | |
| ResponseItem::FunctionCall { | |
| id: None, | |
| name: "gh_repo_view".to_string(), | |
| namespace: None, | |
| arguments: "{\"repo\":\"openai/codex\"}".to_string(), | |
| call_id: "call-1".to_string(), | |
| encrypted_function_args: None, | |
| internal_chat_message_metadata_passthrough: None, | |
| }, | |
| ResponseItem::FunctionCallOutput { | |
| id: None, | |
| call_id: Some("call-1".to_string()), | |
| name: None, | |
| namespace: None, | |
| output: codex_protocol::models::FunctionCallOutputPayload::from_text( | |
| "repo visibility: public".to_string(), | |
| ), | |
| internal_chat_message_metadata_passthrough: None, | |
| }, | |
| ResponseItem::Message { | |
| id: None, | |
| role: "assistant".to_string(), | |
| content: vec![ContentItem::OutputText { | |
| text: "The repo is public; I now need approval to push the docs fix." | |
| .to_string(), | |
| }], | |
| phase: None, | |
| internal_chat_message_metadata_passthrough: None, | |
| }, | |
| ], | |
| ) | |
| .await; | |
| } | |
| fn rollout_item_contains_message_text(item: &RolloutItem, needle: &str) -> bool { | |
| let RolloutItem::ResponseItem(response_item) = item else { | |
| return false; | |
| }; | |
| response_item_contains_message_text(response_item, needle) | |
| } | |
| fn response_item_contains_message_text(item: &ResponseItem, needle: &str) -> bool { | |
| let ResponseItem::Message { content, .. } = item else { | |
| return false; | |
| }; | |
| content.iter().any(|item| match item { | |
| ContentItem::InputText { text } | ContentItem::OutputText { text } => text.contains(needle), | |
| ContentItem::InputImage { .. } | ContentItem::InputAudio { .. } => false, | |
| }) | |
| } | |
| fn format_guardian_requests_snapshot( | |
| scenario: &str, | |
| requests: &[(&str, &ResponsesRequest)], | |
| ) -> String { | |
| let bodies = requests | |
| .iter() | |
| .map(|(_, request)| { | |
| let mut body = request.body_json(); | |
| // Project instructions depend on the checkout. Leave discovery enabled and omit | |
| // only this content from these snapshots, as the previous renderer did. | |
| for item in body["input"].as_array_mut().expect("request input") { | |
| if item["type"] == "message" && item["role"] == "user" { | |
| item["content"] | |
| .as_array_mut() | |
| .expect("message content") | |
| .retain(|part| { | |
| !part["text"] | |
| .as_str() | |
| .is_some_and(|text| text.starts_with("# AGENTS.md instructions")) | |
| }); | |
| } | |
| } | |
| body | |
| }) | |
| .collect::<Vec<_>>(); | |
| let entries = requests | |
| .iter() | |
| .zip(&bodies) | |
| .map(|((label, _), body)| SnapshotEntry::body(body).labeled(label)) | |
| .collect::<Vec<_>>(); | |
| context_snapshot::format_context_snapshot( | |
| scenario, | |
| &entries, | |
| &ContextSnapshotOptions::default().rewrite_known_segments(), | |
| ) | |
| } | |
| fn normalize_guardian_snapshot_paths(text: String) -> String { | |
| let mut text = text; | |
| for canonical_path in ["/repo/codex-rs/core", "/repo"] { | |
| let platform_path = test_path_buf(canonical_path).display().to_string(); | |
| if platform_path == canonical_path { | |
| continue; | |
| } | |
| let escaped_platform_path = serde_json::to_string(&platform_path) | |
| .expect("test path should serialize") | |
| .trim_matches('"') | |
| .to_string(); | |
| // Function-call JSON strings are escaped once more by the one-line item renderer. | |
| let rendered_platform_path = escaped_platform_path.replace('\\', "\\\\"); | |
| text = text | |
| .replace(&rendered_platform_path, canonical_path) | |
| .replace(&escaped_platform_path, canonical_path) | |
| .replace(&platform_path, canonical_path); | |
| } | |
| text | |
| } | |
| fn guardian_prompt_text(items: &[codex_protocol::user_input::UserInput]) -> String { | |
| items | |
| .iter() | |
| .map(|item| match item { | |
| codex_protocol::user_input::UserInput::Text { text, .. } => text.as_str(), | |
| _ => "", | |
| }) | |
| .collect::<String>() | |
| } | |
| fn last_user_message_text_from_body(body: &serde_json::Value) -> String { | |
| body["input"] | |
| .as_array() | |
| .expect("request input array") | |
| .iter() | |
| .filter(|item| item.get("role").and_then(serde_json::Value::as_str) == Some("user")) | |
| .filter_map(|item| item.get("content").and_then(serde_json::Value::as_array)) | |
| .next_back() | |
| .expect("user message content") | |
| .iter() | |
| .filter(|span| span.get("type").and_then(serde_json::Value::as_str) == Some("input_text")) | |
| .filter_map(|span| span.get("text").and_then(serde_json::Value::as_str)) | |
| .collect::<String>() | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| async fn build_guardian_prompt_delta_mode_handles_empty_delta() -> anyhow::Result<()> { | |
| let (session, turn) = guardian_test_session_and_turn_with_base_url("http://localhost").await; | |
| seed_guardian_parent_history(&session, &turn).await; | |
| let prompt = build_guardian_prompt_items( | |
| session.as_ref(), | |
| /*retry_reason*/ None, | |
| GuardianApprovalRequest::ExecCommand { | |
| id: "shell-2".to_string(), | |
| environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), | |
| command: vec!["git".to_string(), "push".to_string()], | |
| cwd: test_path_buf("/repo/codex-rs/core").abs().into(), | |
| guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"), | |
| sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, | |
| additional_permissions: None, | |
| justification: Some("Need to push the second docs fix.".to_string()), | |
| tty: false, | |
| }, | |
| GuardianPromptMode::Delta { | |
| cursor: GuardianTranscriptCursor { | |
| parent_history_version: 0, | |
| transcript_entry_count: 4, | |
| }, | |
| }, | |
| ) | |
| .await?; | |
| let text = guardian_prompt_text(&prompt.context.into_user_inputs()?); | |
| assert!(text.contains(">>> TRANSCRIPT DELTA START\n")); | |
| assert!(text.contains("<no retained transcript delta entries>")); | |
| assert!(text.contains(">>> TRANSCRIPT DELTA END\n")); | |
| assert_eq!(prompt.transcript_cursor.transcript_entry_count, 4); | |
| Ok(()) | |
| } | |
| 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(()) | |
| } | |
| async fn build_guardian_prompt_stale_delta_version_falls_back_to_full_prompt() -> anyhow::Result<()> | |
| { | |
| let (session, turn) = guardian_test_session_and_turn_with_base_url("http://localhost").await; | |
| seed_guardian_parent_history(&session, &turn).await; | |
| session | |
| .replace_history( | |
| vec![ | |
| ResponseItem::Message { | |
| id: None, | |
| role: "user".to_string(), | |
| content: vec![ContentItem::InputText { | |
| text: "Compacted retained user request.".to_string(), | |
| }], | |
| phase: None, | |
| internal_chat_message_metadata_passthrough: None, | |
| }, | |
| ResponseItem::Message { | |
| id: None, | |
| role: "assistant".to_string(), | |
| content: vec![ContentItem::OutputText { | |
| text: "Compacted summary of earlier guardian context.".to_string(), | |
| }], | |
| phase: None, | |
| internal_chat_message_metadata_passthrough: None, | |
| }, | |
| ], | |
| /*reference_context_item*/ None, | |
| ) | |
| .await; | |
| session | |
| .record_conversation_items( | |
| turn.as_ref(), | |
| turn.model_info(), | |
| &[ | |
| ResponseItem::Message { | |
| id: None, | |
| role: "user".to_string(), | |
| content: vec![ContentItem::InputText { | |
| text: "Please push after the compaction.".to_string(), | |
| }], | |
| phase: None, | |
| internal_chat_message_metadata_passthrough: None, | |
| }, | |
| ResponseItem::Message { | |
| id: None, | |
| role: "assistant".to_string(), | |
| content: vec![ContentItem::OutputText { | |
| text: "I need approval for the post-compaction push.".to_string(), | |
| }], | |
| phase: None, | |
| internal_chat_message_metadata_passthrough: None, | |
| }, | |
| ], | |
| ) | |
| .await; | |
| let prompt = build_guardian_prompt_items( | |
| session.as_ref(), | |
| /*retry_reason*/ None, | |
| GuardianApprovalRequest::ExecCommand { | |
| id: "shell-4".to_string(), | |
| environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), | |
| command: vec!["git".to_string(), "push".to_string()], | |
| cwd: test_path_buf("/repo/codex-rs/core").abs().into(), | |
| guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"), | |
| sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, | |
| additional_permissions: None, | |
| justification: Some("Need to push after the compaction.".to_string()), | |
| tty: false, | |
| }, | |
| GuardianPromptMode::Delta { | |
| cursor: GuardianTranscriptCursor { | |
| parent_history_version: 0, | |
| transcript_entry_count: 4, | |
| }, | |
| }, | |
| ) | |
| .await?; | |
| let text = guardian_prompt_text(&prompt.context.into_user_inputs()?); | |
| assert!(text.contains("whose request action you are assessing")); | |
| assert!(text.contains(">>> TRANSCRIPT START\n")); | |
| assert!(!text.contains("TRANSCRIPT DELTA")); | |
| assert!(text.contains("[3] user: Please push after the compaction.")); | |
| assert!(text.contains("[4] assistant: I need approval for the post-compaction push.")); | |
| assert_eq!(prompt.transcript_cursor.parent_history_version, 1); | |
| assert_eq!(prompt.transcript_cursor.transcript_entry_count, 4); | |
| Ok(()) | |
| } | |
| fn collect_guardian_transcript_entries( | |
| history: &dyn codex_guardian_context::SectionHistory, | |
| node_repl_result_token_limit: usize, | |
| ) -> Vec<ConversationTranscriptEntry> { | |
| prompt::collect_guardian_context( | |
| history, | |
| node_repl_result_token_limit, | |
| &[], | |
| &[], | |
| /*planned_action*/ None, | |
| /*permissions*/ None, | |
| /*node_repl*/ None, | |
| ) | |
| .expect("collect Guardian context") | |
| .transcript_entries() | |
| .to_vec() | |
| } | |
| fn collect_guardian_transcript_entries_skips_contextual_user_messages() { | |
| let items = vec![ | |
| ResponseItem::Message { | |
| id: None, | |
| role: "user".to_string(), | |
| content: vec![ContentItem::InputText { | |
| text: "<environment_context>\n<cwd>/tmp</cwd>\n</environment_context>".to_string(), | |
| }], | |
| phase: None, | |
| internal_chat_message_metadata_passthrough: None, | |
| }, | |
| ResponseItem::Message { | |
| id: None, | |
| role: "assistant".to_string(), | |
| content: vec![ContentItem::OutputText { | |
| text: "hello".to_string(), | |
| }], | |
| phase: None, | |
| internal_chat_message_metadata_passthrough: None, | |
| }, | |
| ]; | |
| let entries = collect_guardian_transcript_entries(&items, GUARDIAN_MAX_TOOL_ENTRY_TOKENS); | |
| assert_eq!(entries.len(), 1); | |
| assert_eq!( | |
| entries[0], | |
| ConversationTranscriptEntry { | |
| kind: ConversationTranscriptEntryKind::ProtectedAssistant, | |
| text: "hello".to_string(), | |
| original_bytes: "hello".len(), | |
| } | |
| ); | |
| } | |
| 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, | |
| }] | |
| ); | |
| } | |
| 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, | |
| ) | |
| ); | |
| } | |
| } | |
| fn guardian_truncate_text_keeps_prefix_suffix_and_xml_marker() { | |
| let content = "prefix ".repeat(200) + &" suffix".repeat(200); | |
| let (truncated, was_truncated) = guardian_truncate_text(&content, /*token_cap*/ 20); | |
| assert!(truncated.starts_with("prefix")); | |
| assert!(truncated.contains("<truncated omitted_approx_tokens=\"")); | |
| assert!(truncated.ends_with("suffix")); | |
| assert!(was_truncated); | |
| } | |
| fn guardian_approval_request_to_json_renders_mcp_tool_call_shape() -> serde_json::Result<()> { | |
| let action = GuardianApprovalRequest::McpToolCall { | |
| id: "call-1".to_string(), | |
| server: "mcp_server".to_string(), | |
| tool_name: "browser_navigate".to_string(), | |
| arguments: Some(serde_json::json!({ | |
| "url": "https://example.com", | |
| })), | |
| connector_id: None, | |
| connector_name: Some("Playwright".to_string()), | |
| connector_description: None, | |
| connected_account_email: Some("owner@example.com".to_string()), | |
| tool_title: Some("Navigate".to_string()), | |
| tool_description: None, | |
| annotations: Some(GuardianMcpAnnotations { | |
| destructive_hint: Some(true), | |
| open_world_hint: None, | |
| read_only_hint: Some(false), | |
| }), | |
| }; | |
| assert_eq!( | |
| guardian_approval_request_to_json(&action)?, | |
| serde_json::json!({ | |
| "tool": "mcp_tool_call", | |
| "server": "mcp_server", | |
| "tool_name": "browser_navigate", | |
| "arguments": { | |
| "url": "https://example.com", | |
| }, | |
| "connector_name": "Playwright", | |
| "connected_account_email": "owner@example.com", | |
| "tool_title": "Navigate", | |
| "annotations": { | |
| "destructive_hint": true, | |
| "read_only_hint": false, | |
| }, | |
| }) | |
| ); | |
| Ok(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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" | |
| ); | |
| } | |
| 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); | |
| } | |
| enum ApprovalCancellation { | |
| BeforeRouting, | |
| BeforeCachedResult, | |
| } | |
| async fn cancelled_guardian_review_emits_terminal_abort_without_warning( | |
| moment: ApprovalCancellation, | |
| ) { | |
| let (mut session, turn, rx) = crate::session::tests::make_session_and_context_with_rx().await; | |
| let cancel_token = CancellationToken::new(); | |
| match moment { | |
| ApprovalCancellation::BeforeRouting => cancel_token.cancel(), | |
| ApprovalCancellation::BeforeCachedResult => { | |
| struct CancelWhileApproving(CancellationToken); | |
| impl codex_extension_api::ApprovalReviewContributor for CancelWhileApproving { | |
| fn decide<'a>( | |
| &'a self, | |
| _input: &'a codex_extension_api::ApprovalDecisionInput<'_>, | |
| ) -> codex_extension_api::ExtensionFuture< | |
| 'a, | |
| Option<codex_extension_api::ApprovalDecision>, | |
| > { | |
| self.0.cancel(); | |
| Box::pin(async { Some(codex_extension_api::ApprovalDecision::Allow) }) | |
| } | |
| } | |
| let mut extensions = codex_extension_api::ExtensionRegistryBuilder::<Config>::new(); | |
| extensions | |
| .approval_review_contributor(Arc::new(CancelWhileApproving(cancel_token.clone()))); | |
| Arc::get_mut(&mut session) | |
| .expect("unique test session") | |
| .services | |
| .extensions = Arc::new(extensions.build()); | |
| } | |
| } | |
| let decision = super::decide_approval( | |
| Arc::clone(&session), | |
| &turn, | |
| "review-cancelled-guardian".to_string(), | |
| GuardianApprovalRequest::ApplyPatch { | |
| id: "patch-1".to_string(), | |
| cwd: test_path_buf("/tmp").abs().into(), | |
| files: vec![test_path_buf("/tmp/guardian.txt").abs().into()], | |
| patch: "*** Begin Patch\n*** Update File: guardian.txt\n@@\n+hello\n*** End Patch" | |
| .to_string(), | |
| }, | |
| ApprovalRequestReasons { | |
| approval: None, | |
| retry: None, | |
| }, | |
| GuardianReviewOptions { | |
| require_guardian: true, | |
| plugin_attribution_override: None, | |
| approval_request_source: GuardianApprovalRequestSource::MainTurn, | |
| external_cancel: Some(cancel_token), | |
| require_synchronous_review: false, | |
| }, | |
| ) | |
| .await; | |
| assert_eq!(decision, Some(ReviewDecision::Abort)); | |
| let mut guardian_statuses = Vec::new(); | |
| let mut warnings = Vec::new(); | |
| while let Ok(event) = rx.try_recv() { | |
| match event.msg { | |
| EventMsg::GuardianAssessment(event) => guardian_statuses.push(event.status), | |
| EventMsg::GuardianWarning(event) => warnings.push(event.message), | |
| _ => {} | |
| } | |
| } | |
| assert_eq!( | |
| guardian_statuses, | |
| match moment { | |
| ApprovalCancellation::BeforeRouting => vec![ | |
| GuardianAssessmentStatus::InProgress, | |
| GuardianAssessmentStatus::Aborted | |
| ], | |
| ApprovalCancellation::BeforeCachedResult => vec![], | |
| } | |
| ); | |
| assert!(warnings.is_empty()); | |
| } | |
| 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), | |
| ); | |
| } | |
| } | |
| 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)); | |
| } | |
| 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 | |
| )); | |
| } | |
| 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)); | |
| } | |
| fn build_guardian_transcript_reserves_separate_budget_for_tool_evidence() { | |
| let repeated = "signal ".repeat(8_000); | |
| let mut items = [ | |
| ("user", "please figure out if the repo is public"), | |
| ( | |
| "assistant", | |
| "The public repo check is the main reason I want to escalate.", | |
| ), | |
| ] | |
| .into_iter() | |
| .map(|(role, text)| ResponseItem::Message { | |
| id: None, | |
| role: role.to_string(), | |
| content: vec![ContentItem::InputText { | |
| text: text.to_string(), | |
| }], | |
| phase: None, | |
| internal_chat_message_metadata_passthrough: None, | |
| }) | |
| .collect::<Vec<_>>(); | |
| items.extend((0..12).map(|index| ResponseItem::FunctionCall { | |
| id: None, | |
| name: format!("tool_{index}"), | |
| namespace: None, | |
| arguments: repeated.clone(), | |
| call_id: format!("call-{index}"), | |
| encrypted_function_args: None, | |
| internal_chat_message_metadata_passthrough: None, | |
| })); | |
| let entries = collect_guardian_transcript_entries(&items, GUARDIAN_MAX_TOOL_ENTRY_TOKENS); | |
| let (transcript, omission) = render_guardian_transcript_entries(&entries); | |
| assert!( | |
| transcript | |
| .iter() | |
| .any(|entry| entry == "[1] user: please figure out if the repo is public") | |
| ); | |
| assert!(transcript.iter().any(|entry| { | |
| entry == "[2] assistant: The public repo check is the main reason I want to escalate." | |
| })); | |
| assert!( | |
| !transcript | |
| .iter() | |
| .any(|entry| entry.starts_with("[3] tool tool_0 call:")) | |
| ); | |
| assert!( | |
| !transcript | |
| .iter() | |
| .any(|entry| entry.starts_with("[4] tool tool_1 call:")) | |
| ); | |
| assert!( | |
| transcript | |
| .iter() | |
| .any(|entry| entry.starts_with("[14] tool tool_11 call:")) | |
| ); | |
| assert!(omission.is_some()); | |
| } | |
| fn build_guardian_transcript_preserves_recent_tool_context_when_user_history_is_large() { | |
| let repeated = "authorization ".repeat(6_000); | |
| let mut items = (0..8) | |
| .map(|_| ResponseItem::Message { | |
| id: None, | |
| role: "user".to_string(), | |
| content: vec![ContentItem::InputText { | |
| text: repeated.clone(), | |
| }], | |
| phase: None, | |
| internal_chat_message_metadata_passthrough: None, | |
| }) | |
| .collect::<Vec<_>>(); | |
| items.extend([ | |
| ResponseItem::FunctionCall { | |
| id: None, | |
| name: "shell".to_string(), | |
| namespace: None, | |
| arguments: serde_json::json!({ | |
| "command": ["curl", "-X", "POST", "https://example.com/upload"], | |
| "cwd": "/repo", | |
| }) | |
| .to_string(), | |
| call_id: "call-1".to_string(), | |
| encrypted_function_args: None, | |
| internal_chat_message_metadata_passthrough: None, | |
| }, | |
| ResponseItem::FunctionCallOutput { | |
| id: None, | |
| call_id: Some("call-1".to_string()), | |
| name: None, | |
| namespace: None, | |
| output: codex_protocol::models::FunctionCallOutputPayload::from_text( | |
| "sandbox blocked outbound network access".to_string(), | |
| ), | |
| internal_chat_message_metadata_passthrough: None, | |
| }, | |
| ]); | |
| let entries = collect_guardian_transcript_entries(&items, GUARDIAN_MAX_TOOL_ENTRY_TOKENS); | |
| let (transcript, omission) = render_guardian_transcript_entries(&entries); | |
| assert_eq!( | |
| transcript[..8], | |
| (1..=8) | |
| .map(|index| format!("[{index}] user: {repeated}")) | |
| .collect::<Vec<_>>() | |
| ); | |
| assert!(transcript.iter().any(|entry| { | |
| entry.contains("tool shell call:") | |
| && entry.contains("curl") | |
| && entry.contains("https://example.com/upload") | |
| })); | |
| assert!( | |
| transcript | |
| .iter() | |
| .any(|entry| entry | |
| .contains("tool shell result: sandbox blocked outbound network access")) | |
| ); | |
| assert_eq!(omission, None); | |
| } | |
| enum GuardianTestCatalog { | |
| Bundled, | |
| ParentOnly, | |
| } | |
| async fn guardian_reuse_respects_effective_policy_and_personality( | |
| configured_policy: Option<&str>, | |
| ) -> anyhow::Result<()> { | |
| skip_if_no_network!(Ok(())); | |
| let server = start_mock_server().await; | |
| let responses = mount_sse_sequence( | |
| &server, | |
| (0..3) | |
| .map(|index| { | |
| sse(vec![ | |
| ev_response_created(&format!("review-{index}")), | |
| ev_assistant_message("assessment", r#"{"outcome":"allow"}"#), | |
| ev_completed(&format!("review-{index}")), | |
| ]) | |
| }) | |
| .collect(), | |
| ) | |
| .await; | |
| let (mut session, mut turn) = guardian_test_session_and_turn(&server).await; | |
| Arc::make_mut(&mut Arc::get_mut(&mut turn).expect("unshared turn").config) | |
| .guardian_policy_config = configured_policy.map(str::to_owned); | |
| let mut captured = GuardianReviewContext::from(&turn); | |
| let parent = Arc::make_mut(&mut captured.model_info); | |
| parent.slug = "captured-parent".to_string(); | |
| parent.auto_review_model_override = None; | |
| parent.model_messages = Some(serde_json::from_value(serde_json::json!({ | |
| "auto_review": { | |
| "policy": "captured policy", | |
| "policy_template": "captured template: {{ tenant_policy_config }}", | |
| }, | |
| }))?); | |
| captured.personality = Some(codex_protocol::config_types::Personality::Friendly); | |
| Arc::get_mut(&mut session) | |
| .expect("unshared session") | |
| .services | |
| .models_manager = Arc::new(StaticModelsManager::new( | |
| /*auth_manager*/ None, | |
| ModelsResponse { models: vec![] }, | |
| )); | |
| seed_guardian_parent_history(&session, &turn).await; | |
| let mut changed_policy = captured.clone(); | |
| Arc::make_mut(&mut changed_policy.model_info) | |
| .model_messages | |
| .as_mut() | |
| .unwrap() | |
| .auto_review | |
| .as_mut() | |
| .unwrap() | |
| .policy = Some("changed action policy".to_string()); | |
| let mut different_personality = changed_policy.clone(); | |
| different_personality.personality = Some(codex_protocol::config_types::Personality::Pragmatic); | |
| for (index, context) in [captured, changed_policy, different_personality] | |
| .into_iter() | |
| .enumerate() | |
| { | |
| let (outcome, _) = run_guardian_review_session_for_test( | |
| Arc::clone(&session), | |
| context, | |
| guardian_exec_command_request(&format!("action-{index}")), | |
| ApprovalRequestReasons::default(), | |
| guardian_output_schema(), | |
| /*external_cancel*/ None, | |
| /*max_attempts*/ 1, | |
| ) | |
| .await; | |
| assert!(matches!(outcome, GuardianReviewOutcome::Completed(_))); | |
| } | |
| let requests = responses.requests(); | |
| assert_eq!(requests.len(), 3); | |
| let initial_policy = configured_policy.unwrap_or("captured policy"); | |
| assert_eq!( | |
| requests[0].instructions_text(), | |
| guardian_policy_prompt_with_config_and_template( | |
| initial_policy, | |
| "captured template: {{ tenant_policy_config }}" | |
| ) | |
| ); | |
| let changed_policy = configured_policy.unwrap_or("changed action policy"); | |
| assert_eq!( | |
| requests[1].instructions_text(), | |
| guardian_policy_prompt_with_config_and_template( | |
| changed_policy, | |
| "captured template: {{ tenant_policy_config }}" | |
| ) | |
| ); | |
| let thread_ids = requests | |
| .iter() | |
| .map(|request| request.body_json()["client_metadata"]["thread_id"].clone()) | |
| .collect::<Vec<_>>(); | |
| assert_eq!(thread_ids[0] == thread_ids[1], configured_policy.is_some()); | |
| assert_ne!(thread_ids[1], thread_ids[2]); | |
| Ok(()) | |
| } | |
| async fn guardian_request_model_for_auto_review( | |
| auto_review_model_override: Option<String>, | |
| catalog: GuardianTestCatalog, | |
| ) -> anyhow::Result<( | |
| String, | |
| String, | |
| String, | |
| codex_analytics::GuardianReviewAnalyticsResult, | |
| )> { | |
| let server = start_mock_server().await; | |
| let guardian_assessment = serde_json::json!({ | |
| "outcome": "allow", | |
| }) | |
| .to_string(); | |
| let request_log = mount_sse_once( | |
| &server, | |
| sse(vec![ | |
| ev_response_created("resp-guardian"), | |
| ev_assistant_message("msg-guardian", &guardian_assessment), | |
| ev_completed("resp-guardian"), | |
| ]), | |
| ) | |
| .await; | |
| let (mut session, mut turn) = guardian_test_session_and_turn(&server).await; | |
| match catalog { | |
| GuardianTestCatalog::Bundled => {} | |
| GuardianTestCatalog::ParentOnly => { | |
| let parent_model = turn.model_info().as_ref().clone(); | |
| let auth_manager = Arc::clone(&session.services.auth_manager); | |
| let models_manager = StaticModelsManager::new( | |
| Some(auth_manager), | |
| ModelsResponse { | |
| models: vec![parent_model], | |
| }, | |
| ); | |
| Arc::get_mut(&mut session) | |
| .expect("session should be unique") | |
| .services | |
| .models_manager = Arc::new(models_manager); | |
| crate::guardian::test_host::install(&session, &turn.config); | |
| } | |
| } | |
| update_turn_settings_for_test( | |
| Arc::get_mut(&mut turn).expect("turn should be unique"), | |
| |settings| { | |
| Arc::make_mut(&mut settings.model_info).auto_review_model_override = | |
| auto_review_model_override | |
| }, | |
| ); | |
| let parent_model = turn.model_info().slug.clone(); | |
| let preferred_model = turn.provider.approval_review_preferred_model().to_string(); | |
| let parent_turn_id = turn.sub_id.clone(); | |
| seed_guardian_parent_history(&session, &turn).await; | |
| let (outcome, analytics_result) = run_guardian_review_session_for_test( | |
| Arc::clone(&session), | |
| turn, | |
| GuardianApprovalRequest::ExecCommand { | |
| id: "shell-1".to_string(), | |
| environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), | |
| command: vec!["git".to_string(), "push".to_string()], | |
| cwd: test_path_buf("/repo/codex-rs/core").abs().into(), | |
| guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"), | |
| sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, | |
| additional_permissions: None, | |
| justification: None, | |
| tty: false, | |
| }, | |
| ApprovalRequestReasons { | |
| approval: None, | |
| retry: Some("Sandbox denied outbound git push to github.com.".to_string()), | |
| }, | |
| guardian_output_schema(), | |
| /*external_cancel*/ None, | |
| /*max_attempts*/ 1, | |
| ) | |
| .await; | |
| let GuardianReviewOutcome::Completed(_) = outcome else { | |
| panic!("expected guardian assessment"); | |
| }; | |
| let request = request_log.single_request(); | |
| let request_body = request.body_json(); | |
| core_test_support::responses::assert_parent_turn(&request_body, Some(parent_turn_id.as_str()))?; | |
| let request_model = request_body | |
| .get("model") | |
| .and_then(|value| value.as_str()) | |
| .expect("guardian request should include a model") | |
| .to_string(); | |
| Ok(( | |
| request_model, | |
| parent_model, | |
| preferred_model, | |
| analytics_result, | |
| )) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| async fn guardian_review_request_layout_matches_model_visible_request_snapshot() | |
| -> anyhow::Result<()> { | |
| skip_if_no_network!(Ok(())); | |
| let server = start_mock_server().await; | |
| let guardian_assessment = serde_json::json!({ | |
| "risk_level": "medium", | |
| "user_authorization": "high", | |
| "outcome": "allow", | |
| "rationale": "The user explicitly requested pushing the reviewed branch to the known remote.", | |
| }) | |
| .to_string(); | |
| let request_log = mount_sse_once( | |
| &server, | |
| sse(vec![ | |
| ev_response_created("resp-guardian"), | |
| ev_assistant_message("msg-guardian", &guardian_assessment), | |
| ev_completed("resp-guardian"), | |
| ]), | |
| ) | |
| .await; | |
| let (mut session, mut turn) = crate::session::tests::make_session_and_context().await; | |
| session.thread_id = fixed_guardian_parent_session_id(); | |
| let temp_cwd = TempDir::new()?; | |
| let mut config = (*turn.config).clone(); | |
| config.cwd = temp_cwd.abs(); | |
| config.model_provider.base_url = Some(format!("{}/v1", server.uri())); | |
| config.memories.use_memories = true; | |
| config | |
| .features | |
| .enable(Feature::MemoryTool) | |
| .expect("memory tool feature is configurable"); | |
| let config = Arc::new(config); | |
| let models_manager = test_support::models_manager_with_provider( | |
| config.codex_home.to_path_buf(), | |
| Arc::clone(&session.services.auth_manager), | |
| config.model_provider.clone(), | |
| ); | |
| session.services.models_manager = models_manager; | |
| crate::guardian::test_host::install(&session, &config); | |
| let memory_extension = Arc::new(GuardianMemoryContextProbe); | |
| let mut extensions = codex_extension_api::ExtensionRegistryBuilder::<Config>::new(); | |
| extensions.thread_lifecycle_contributor(memory_extension.clone()); | |
| extensions.prompt_contributor(memory_extension); | |
| session.services.extensions = Arc::new(extensions.build()); | |
| let skill_dir = config | |
| .codex_home | |
| .to_path_buf() | |
| .join("skills") | |
| .join(GUARDIAN_SKILL_NAME); | |
| std::fs::create_dir_all(&skill_dir)?; | |
| std::fs::write( | |
| skill_dir.join("SKILL.md"), | |
| format!( | |
| "---\nname: {GUARDIAN_SKILL_NAME}\ndescription: Guardian skill injection probe.\n---\n\n{GUARDIAN_SKILL_BODY_PROBE}\n" | |
| ), | |
| )?; | |
| session.services.skills_service.clear_cache(); | |
| turn.config = Arc::clone(&config); | |
| turn.provider = create_model_provider(config.model_provider.clone(), turn.auth_manager.clone()); | |
| update_turn_settings_for_test(&mut turn, |settings| { | |
| Arc::make_mut(&mut settings.model_info).auto_review_model_override = | |
| Some("codex-auto-review".to_string()); | |
| }); | |
| let session = Arc::new(session); | |
| let turn = Arc::new(turn); | |
| seed_guardian_parent_history(&session, &turn).await; | |
| session | |
| .record_conversation_items( | |
| turn.as_ref(), | |
| turn.model_info(), | |
| &[ResponseItem::Message { | |
| id: None, | |
| role: "user".to_string(), | |
| content: vec![ContentItem::InputText { | |
| text: format!( | |
| "Use ${GUARDIAN_SKILL_NAME} before deciding whether the push is safe." | |
| ), | |
| }], | |
| phase: None, | |
| internal_chat_message_metadata_passthrough: None, | |
| }], | |
| ) | |
| .await; | |
| let request = GuardianApprovalRequest::ExecCommand { | |
| id: "shell-1".to_string(), | |
| environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), | |
| command: vec![ | |
| "git".to_string(), | |
| "push".to_string(), | |
| "origin".to_string(), | |
| "guardian-approval-mvp".to_string(), | |
| ], | |
| cwd: test_path_buf("/repo/codex-rs/core").abs().into(), | |
| guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"), | |
| sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, | |
| additional_permissions: None, | |
| justification: Some("Need to push the reviewed docs fix to the repo remote.".to_string()), | |
| tty: false, | |
| }; | |
| let outcome = run_guardian_review_session_for_test( | |
| Arc::clone(&session), | |
| Arc::clone(&turn), | |
| request, | |
| ApprovalRequestReasons { | |
| approval: None, | |
| retry: Some("Sandbox denied outbound git push to github.com.".to_string()), | |
| }, | |
| guardian_output_schema(), | |
| /*external_cancel*/ None, | |
| /*max_attempts*/ 1, | |
| ) | |
| .await; | |
| let (GuardianReviewOutcome::Completed(assessment), metadata) = outcome else { | |
| panic!("expected guardian assessment"); | |
| }; | |
| let guardian_thread_id = metadata | |
| .guardian_thread_id | |
| .as_deref() | |
| .expect("guardian thread id"); | |
| assert_eq!(assessment.outcome, GuardianAssessmentOutcome::Allow); | |
| assert_ne!(guardian_thread_id, session.thread_id.to_string()); | |
| ThreadId::from_string(guardian_thread_id).expect("guardian thread id should be a valid UUID"); | |
| assert!(matches!( | |
| metadata.guardian_session_kind, | |
| Some(codex_analytics::GuardianReviewSessionKind::TrunkNew) | |
| )); | |
| let request = request_log.single_request(); | |
| let turn_metadata: serde_json::Value = serde_json::from_str( | |
| &request | |
| .header("x-codex-turn-metadata") | |
| .expect("guardian turn metadata"), | |
| )?; | |
| assert_eq!(turn_metadata["turn_trigger"], "guardian_review"); | |
| let request_body = request.body_json(); | |
| assert!( | |
| request_body.get("tools").is_none(), | |
| "guardian request should use Responses Lite tool input" | |
| ); | |
| let guardian_tools = request_body["input"] | |
| .as_array() | |
| .and_then(|input| input.first()) | |
| .filter(|item| item["type"] == "additional_tools") | |
| .and_then(|item| item["tools"].as_array()) | |
| .and_then(|tools| { | |
| tools | |
| .iter() | |
| .find(|tool| tool["type"] == "namespace" && tool["name"] == "functions") | |
| }) | |
| .and_then(|namespace| namespace["tools"].as_array()) | |
| .expect("guardian request functions namespace"); | |
| let mut guardian_tool_names = guardian_tools | |
| .iter() | |
| .map(|tool| tool["name"].as_str().expect("guardian code-mode tool name")) | |
| .collect::<Vec<_>>(); | |
| guardian_tool_names.sort_unstable(); | |
| assert_eq!(guardian_tool_names, vec!["exec", "wait"]); | |
| let guardian_exec_description = guardian_tools | |
| .iter() | |
| .find(|tool| tool["name"] == "exec") | |
| .and_then(|tool| tool["description"].as_str()) | |
| .expect("guardian code-mode exec description"); | |
| let mut guardian_nested_tool_names = guardian_exec_description | |
| .lines() | |
| .filter_map(|line| line.strip_prefix("### `")) | |
| .filter_map(|line| line.strip_suffix('`')) | |
| .collect::<Vec<_>>(); | |
| guardian_nested_tool_names.sort_unstable(); | |
| assert_eq!( | |
| guardian_nested_tool_names, | |
| vec!["exec_command", "view_image", "write_stdin"] | |
| ); | |
| let guardian_user_text = request.message_input_texts("user").join("\n"); | |
| assert!(guardian_user_text.contains(&format!( | |
| "Reviewed Codex session id: {}", | |
| fixed_guardian_parent_session_id() | |
| ))); | |
| assert!( | |
| guardian_user_text.contains(&format!("${GUARDIAN_SKILL_NAME}")), | |
| "guardian request should contain the untrusted skill mention from the parent transcript" | |
| ); | |
| assert!( | |
| !request.body_contains_text(GUARDIAN_SKILL_BODY_PROBE), | |
| "guardian request should not inject a skill body from its generated review prompt" | |
| ); | |
| assert!( | |
| !request.body_contains_text(GUARDIAN_MEMORY_CONTEXT_PROBE), | |
| "guardian request should not include memory context" | |
| ); | |
| assert_eq!( | |
| request_body.pointer("/text/format/strict"), | |
| Some(&serde_json::json!(false)) | |
| ); | |
| assert_eq!( | |
| request_body.pointer("/text/format/schema"), | |
| Some(&serde_json::json!({ | |
| "type": "object", | |
| "additionalProperties": false, | |
| "properties": { | |
| "risk_level": { | |
| "type": "string", | |
| "enum": ["low", "medium", "high", "critical"] | |
| }, | |
| "user_authorization": { | |
| "type": "string", | |
| "enum": ["unknown", "low", "medium", "high"] | |
| }, | |
| "outcome": { | |
| "type": "string", | |
| "enum": ["allow", "deny"] | |
| }, | |
| "rationale": { | |
| "type": "string" | |
| } | |
| }, | |
| "required": ["outcome"] | |
| })) | |
| ); | |
| let request_model = request_body | |
| .get("model") | |
| .and_then(|value| value.as_str()) | |
| .expect("guardian request should include a model"); | |
| let request_reasoning_effort = request_body | |
| .get("reasoning") | |
| .and_then(|reasoning| reasoning.get("effort")) | |
| .and_then(|value| value.as_str()); | |
| assert_eq!(metadata.guardian_model.as_deref(), Some(request_model)); | |
| assert_eq!( | |
| metadata.guardian_reasoning_effort.as_deref(), | |
| request_reasoning_effort | |
| ); | |
| assert_eq!(metadata.had_prior_review_context, Some(false)); | |
| assert!( | |
| metadata.time_to_first_token_ms.is_some(), | |
| "guardian review metadata should capture TTFT when the nested turn completes" | |
| ); | |
| let mut settings = Settings::clone_current(); | |
| settings.set_snapshot_path("snapshots"); | |
| settings.set_prepend_module_to_snapshot(false); | |
| settings.bind(|| { | |
| assert_snapshot!( | |
| "codex_core__guardian__tests__guardian_review_request_layout", | |
| normalize_guardian_snapshot_paths(format_guardian_requests_snapshot( | |
| "Guardian review request layout", | |
| &[("Guardian Review Request", &request)], | |
| )) | |
| ); | |
| }); | |
| Ok(()) | |
| } | |
| async fn build_guardian_prompt_items_includes_parent_session_id() -> anyhow::Result<()> { | |
| let (session, _) = crate::session::tests::make_session_and_context().await; | |
| let prompt = build_guardian_prompt_items( | |
| &session, | |
| /*retry_reason*/ None, | |
| GuardianApprovalRequest::ExecCommand { | |
| id: "shell-1".to_string(), | |
| environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), | |
| command: vec!["git".to_string(), "status".to_string()], | |
| cwd: test_path_buf("/repo").abs().into(), | |
| guardian_cwd: native_guardian_cwd("/repo"), | |
| sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, | |
| additional_permissions: None, | |
| justification: None, | |
| tty: false, | |
| }, | |
| GuardianPromptMode::Full, | |
| ) | |
| .await?; | |
| let prompt_text = prompt | |
| .context | |
| .into_user_inputs()? | |
| .into_iter() | |
| .map(|item| match item { | |
| codex_protocol::user_input::UserInput::Text { text, .. } => text, | |
| codex_protocol::user_input::UserInput::Image { .. } => String::new(), | |
| _ => String::new(), | |
| }) | |
| .collect::<String>(); | |
| assert!( | |
| prompt_text.contains(&format!( | |
| ">>> TRANSCRIPT END\nReviewed Codex session id: {}\n", | |
| session.thread_id | |
| )), | |
| "guardian prompt should expose the parent session id immediately after the transcript end" | |
| ); | |
| Ok(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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(()) | |
| } | |
| 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, | |
| } | |
| async fn guardian_review_routes_required_actions( | |
| required_review: RequiredGuardianReview, | |
| ) -> anyhow::Result<()> { | |
| skip_if_no_network!(Ok(())); | |
| struct AutoApprovingReviewContributor; | |
| impl codex_extension_api::ApprovalReviewContributor for AutoApprovingReviewContributor { | |
| fn decide<'a>( | |
| &'a self, | |
| _input: &'a codex_extension_api::ApprovalDecisionInput<'_>, | |
| ) -> codex_extension_api::ExtensionFuture<'a, Option<codex_extension_api::ApprovalDecision>> | |
| { | |
| Box::pin(async { Some(codex_extension_api::ApprovalDecision::Allow) }) | |
| } | |
| } | |
| let server = start_mock_server().await; | |
| let denial = serde_json::json!({ | |
| "risk_level": "high", | |
| "user_authorization": "unknown", | |
| "outcome": "deny", | |
| "rationale": "The original attempt was blocked by the sandbox.", | |
| }) | |
| .to_string(); | |
| let request_log = mount_sse_once( | |
| &server, | |
| sse(vec![ | |
| ev_response_created("resp-escalated-retry"), | |
| ev_assistant_message("msg-escalated-retry", &denial), | |
| ev_completed("resp-escalated-retry"), | |
| ]), | |
| ) | |
| .await; | |
| let (mut session, mut turn) = guardian_test_session_and_turn(&server).await; | |
| let mut extensions = codex_extension_api::ExtensionRegistryBuilder::<Config>::new(); | |
| extensions.approval_review_contributor(Arc::new(AutoApprovingReviewContributor)); | |
| Arc::get_mut(&mut session) | |
| .expect("session should be uniquely owned") | |
| .services | |
| .extensions = Arc::new(extensions.build()); | |
| let guardian_v2_enabled = matches!( | |
| required_review, | |
| RequiredGuardianReview::LiveManagedModelWithGuardianV2 | |
| ); | |
| let turn_mut = Arc::get_mut(&mut turn).expect("turn should be uniquely owned"); | |
| Arc::make_mut(&mut turn_mut.config) | |
| .features | |
| .set_enabled(Feature::GuardianV2, guardian_v2_enabled)?; | |
| seed_guardian_parent_history(&session, &turn).await; | |
| let retry_reason = "The sandbox blocked the original command."; | |
| let mut context = GuardianReviewContext::from(&turn); | |
| let (request, reasons) = match required_review { | |
| RequiredGuardianReview::SandboxRetry => ( | |
| guardian_exec_command_request("shell-sandbox-retry"), | |
| ApprovalRequestReasons { | |
| approval: None, | |
| retry: Some(retry_reason.to_string()), | |
| }, | |
| ), | |
| RequiredGuardianReview::EscalatedPermission => ( | |
| GuardianApprovalRequest::ExecCommand { | |
| id: "shell-escalated-permission".to_string(), | |
| environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), | |
| command: vec!["git".to_string(), "push".to_string()], | |
| cwd: test_path_buf("/repo/codex-rs/core").abs().into(), | |
| guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"), | |
| sandbox_permissions: crate::sandboxing::SandboxPermissions::RequireEscalated, | |
| additional_permissions: None, | |
| justification: Some("Need to push the reviewed docs fix.".to_string()), | |
| tty: false, | |
| }, | |
| ApprovalRequestReasons::default(), | |
| ), | |
| RequiredGuardianReview::LiveManagedModel | |
| | RequiredGuardianReview::LiveManagedModelWithGuardianV2 => { | |
| Arc::make_mut(&mut context.model_info).slug = "required-action-model".to_string(); | |
| // The admitted turn has neither this model nor the new requirement. | |
| let mut config = session.get_config().await.as_ref().clone(); | |
| let requirements = codex_config::ConfigRequirements { | |
| auto_review_required_models: Some(Sourced::new( | |
| std::collections::BTreeSet::from([context.model_info.slug.clone()]), | |
| RequirementSource::Unknown, | |
| )), | |
| ..Default::default() | |
| }; | |
| config.config_layer_stack = ConfigLayerStack::new( | |
| config | |
| .config_layer_stack | |
| .all_layers_low_to_high() | |
| .cloned() | |
| .collect(), | |
| requirements, | |
| config.config_layer_stack.requirements_toml().clone(), | |
| )?; | |
| session.refresh_mcp_config(config).await; | |
| ( | |
| guardian_exec_command_request("shell-live-managed-model"), | |
| ApprovalRequestReasons::default(), | |
| ) | |
| } | |
| }; | |
| let is_retry = reasons.retry.is_some(); | |
| let decision = review_approval_request( | |
| &session, | |
| context, | |
| "review-required-guardian".to_string(), | |
| request, | |
| reasons, | |
| ) | |
| .await; | |
| if guardian_v2_enabled { | |
| assert_eq!(decision, ReviewDecision::Approved); | |
| assert!(request_log.requests().is_empty()); | |
| } else { | |
| assert!(matches!(decision, ReviewDecision::Denied { .. })); | |
| assert_eq!( | |
| request_log | |
| .single_request() | |
| .body_contains_text(retry_reason), | |
| is_retry, | |
| ); | |
| } | |
| Ok(()) | |
| } | |
| async fn guardian_ephemeral_retry_preserves_parallel_trunk_and_fork_history() -> anyhow::Result<()> | |
| { | |
| const TEST_STACK_SIZE_BYTES: usize = 4 * 1024 * 1024; | |
| let handle = std::thread::Builder::new() | |
| .name("guardian_ephemeral_retry_preserves_parallel_trunk_and_fork_history".to_string()) | |
| .stack_size(TEST_STACK_SIZE_BYTES) | |
| .spawn(|| -> anyhow::Result<()> { | |
| let runtime = tokio::runtime::Builder::new_current_thread() | |
| .enable_all() | |
| .build()?; | |
| runtime.block_on(Box::pin(async { | |
| let first_assessment = serde_json::json!({ | |
| "risk_level": "low", | |
| "user_authorization": "high", | |
| "outcome": "allow", | |
| "rationale": "first guardian rationale", | |
| }) | |
| .to_string(); | |
| let second_assessment = serde_json::json!({ | |
| "risk_level": "low", | |
| "user_authorization": "high", | |
| "outcome": "allow", | |
| "rationale": "second guardian rationale", | |
| }) | |
| .to_string(); | |
| let third_assessment = serde_json::json!({ | |
| "risk_level": "low", | |
| "user_authorization": "high", | |
| "outcome": "allow", | |
| "rationale": "third guardian rationale", | |
| }) | |
| .to_string(); | |
| let (gate_tx, gate_rx) = tokio::sync::oneshot::channel(); | |
| let (server, _) = start_streaming_sse_server(vec![ | |
| vec![StreamingSseChunk { | |
| gate: None, | |
| body: sse(vec![ | |
| ev_response_created("resp-guardian-1"), | |
| ev_assistant_message("msg-guardian-1", &first_assessment), | |
| ev_completed("resp-guardian-1"), | |
| ]), | |
| }], | |
| vec![ | |
| StreamingSseChunk { | |
| gate: None, | |
| body: sse(vec![ev_response_created("resp-guardian-2")]), | |
| }, | |
| StreamingSseChunk { | |
| gate: Some(gate_rx), | |
| body: sse(vec![ | |
| ev_assistant_message("msg-guardian-2", &second_assessment), | |
| ev_completed("resp-guardian-2"), | |
| ]), | |
| }, | |
| ], | |
| vec![StreamingSseChunk { | |
| gate: None, | |
| body: sse(vec![ | |
| ev_response_created("resp-guardian-3"), | |
| ev_assistant_message("msg-guardian-3", "not valid guardian json"), | |
| ev_completed("resp-guardian-3"), | |
| ]), | |
| }], | |
| vec![StreamingSseChunk { | |
| gate: None, | |
| body: sse(vec![ | |
| ev_response_created("resp-guardian-4"), | |
| ev_assistant_message("msg-guardian-4", &third_assessment), | |
| ev_completed("resp-guardian-4"), | |
| ]), | |
| }], | |
| ]) | |
| .await; | |
| let (mut session, turn) = guardian_test_session_and_turn_with_base_url(server.uri()).await; | |
| // Isolate feedback from other tests using the fixed parent session ID. | |
| Arc::get_mut(&mut session) | |
| .expect("session should be uniquely owned") | |
| .thread_id = ThreadId::new(); | |
| turn.turn_metadata_state | |
| .set_parent_turn_id("upstream-parent-turn".to_string()); | |
| turn.turn_metadata_state | |
| .set_root_turn_id("causal-root-turn".to_string()); | |
| seed_guardian_parent_history(&session, &turn).await; | |
| let initial_request = GuardianApprovalRequest::ExecCommand { | |
| id: "shell-guardian-1".to_string(), | |
| environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), | |
| command: vec!["git".to_string(), "status".to_string()], | |
| cwd: test_path_buf("/repo/codex-rs/core").abs().into(), | |
| guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"), | |
| sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, | |
| additional_permissions: None, | |
| justification: Some("Inspect repo state before proceeding.".to_string()), | |
| tty: false, | |
| }; | |
| assert_eq!( | |
| review_approval_request( | |
| &session, | |
| &turn, | |
| "review-shell-guardian-1".to_string(), | |
| initial_request, | |
| ApprovalRequestReasons::default() | |
| ) | |
| .await, | |
| ReviewDecision::Approved | |
| ); | |
| session | |
| .record_conversation_items( | |
| turn.as_ref(), turn.model_info(), | |
| &[ | |
| ResponseItem::Message { | |
| id: None, | |
| role: "user".to_string(), | |
| content: vec![ContentItem::InputText { | |
| text: "Please inspect pending changes before pushing.".to_string(), | |
| }], | |
| phase: None, | |
| internal_chat_message_metadata_passthrough: None,}, | |
| ResponseItem::Message { | |
| id: None, | |
| role: "assistant".to_string(), | |
| content: vec![ContentItem::OutputText { | |
| text: "I need approval to run git diff.".to_string(), | |
| }], | |
| phase: None, | |
| internal_chat_message_metadata_passthrough: None,}, | |
| ], | |
| ) | |
| .await; | |
| let second_request = GuardianApprovalRequest::ExecCommand { | |
| id: "shell-guardian-2".to_string(), | |
| environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), | |
| command: vec!["git".to_string(), "diff".to_string()], | |
| cwd: test_path_buf("/repo/codex-rs/core").abs().into(), | |
| guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"), | |
| sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, | |
| additional_permissions: None, | |
| justification: Some("Inspect pending changes before proceeding.".to_string()), | |
| tty: false, | |
| }; | |
| let third_request = GuardianApprovalRequest::ExecCommand { | |
| id: "shell-guardian-3".to_string(), | |
| environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), | |
| command: vec!["git".to_string(), "push".to_string()], | |
| cwd: test_path_buf("/repo/codex-rs/core").abs().into(), | |
| guardian_cwd: native_guardian_cwd("/repo/codex-rs/core"), | |
| sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, | |
| additional_permissions: None, | |
| justification: Some("Inspect whether pushing is safe before proceeding.".to_string()), | |
| tty: false, | |
| }; | |
| let session_for_second = Arc::clone(&session); | |
| let turn_for_second = Arc::clone(&turn); | |
| let mut second_review = tokio::spawn(async move { | |
| review_approval_request( | |
| &session_for_second, | |
| &turn_for_second, | |
| "review-shell-guardian-2".to_string(), | |
| second_request, | |
| ApprovalRequestReasons { | |
| approval: None, | |
| retry: Some("trunk follow-up".to_string()), | |
| }, | |
| ) | |
| .await | |
| }); | |
| let second_request_observed = tokio::time::timeout(Duration::from_secs(5), async { | |
| loop { | |
| if server.requests().await.len() >= 2 { | |
| break; | |
| } | |
| tokio::task::yield_now().await; | |
| } | |
| }) | |
| .await; | |
| assert!( | |
| second_request_observed.is_ok(), | |
| "second guardian request was not observed" | |
| ); | |
| session | |
| .record_conversation_items( | |
| turn.as_ref(), turn.model_info(), | |
| &[ | |
| ResponseItem::Message { | |
| id: None, | |
| role: "user".to_string(), | |
| content: vec![ContentItem::InputText { | |
| text: "Now inspect whether pushing is safe.".to_string(), | |
| }], | |
| phase: None, | |
| internal_chat_message_metadata_passthrough: None,}, | |
| ResponseItem::Message { | |
| id: None, | |
| role: "assistant".to_string(), | |
| content: vec![ContentItem::OutputText { | |
| text: "I need approval to push after the diff check.".to_string(), | |
| }], | |
| phase: None, | |
| internal_chat_message_metadata_passthrough: None,}, | |
| ], | |
| ) | |
| .await; | |
| let third_decision = review_approval_request( | |
| &session, | |
| &turn, | |
| "review-shell-guardian-3".to_string(), | |
| third_request, | |
| ApprovalRequestReasons { | |
| approval: None, | |
| retry: Some("parallel follow-up".to_string()), | |
| }, | |
| ) | |
| .await; | |
| assert_eq!(third_decision, ReviewDecision::Approved); | |
| let requests = server.requests().await; | |
| assert_eq!(requests.len(), 4); | |
| let first_request_body = serde_json::from_slice::<serde_json::Value>(&requests[0])?; | |
| let second_request_body = serde_json::from_slice::<serde_json::Value>(&requests[1])?; | |
| let failed_ephemeral_request_body = | |
| serde_json::from_slice::<serde_json::Value>(&requests[2])?; | |
| let retried_ephemeral_request_body = | |
| serde_json::from_slice::<serde_json::Value>(&requests[3])?; | |
| let mut reviewer_turn_ids = std::collections::BTreeSet::new(); | |
| for (body, expected_root) in [ | |
| (&first_request_body, Some("causal-root-turn")), | |
| (&second_request_body, Some("causal-root-turn")), | |
| (&failed_ephemeral_request_body, Some("causal-root-turn")), | |
| (&retried_ephemeral_request_body, Some("causal-root-turn")), | |
| ] { | |
| assert_parent_turn(body, Some(turn.sub_id.as_str()))?; | |
| assert_root_turn(body, expected_root)?; | |
| assert_ne!(body["client_metadata"]["turn_id"], turn.sub_id); | |
| reviewer_turn_ids.insert( | |
| body["client_metadata"]["turn_id"].as_str().expect("reviewer turn id") | |
| ); | |
| } | |
| assert_eq!(reviewer_turn_ids.len(), 4); | |
| assert_eq!( | |
| second_request_body["prompt_cache_key"], | |
| failed_ephemeral_request_body["prompt_cache_key"], | |
| "forked guardian review should reuse the trunk guardian prompt cache key" | |
| ); | |
| assert_eq!( | |
| failed_ephemeral_request_body["prompt_cache_key"], | |
| retried_ephemeral_request_body["prompt_cache_key"], | |
| "retried ephemeral review should preserve the guardian prompt cache key" | |
| ); | |
| let third_request_body_text = retried_ephemeral_request_body.to_string(); | |
| assert!( | |
| third_request_body_text.contains("first guardian rationale"), | |
| "forked guardian review should include the last committed trunk assessment" | |
| ); | |
| let third_user_message = last_user_message_text_from_body(&retried_ephemeral_request_body); | |
| assert!(third_user_message.contains(">>> TRANSCRIPT DELTA START\n")); | |
| assert!( | |
| third_user_message.contains("[5] user: Please inspect pending changes before pushing.") | |
| ); | |
| assert!(third_user_message.contains("[7] user: Now inspect whether pushing is safe.")); | |
| assert!(!third_user_message.contains("[1] user: Please check the repo visibility")); | |
| assert!( | |
| !third_request_body_text.contains("second guardian rationale"), | |
| "forked guardian review should not include the still in-flight trunk assessment" | |
| ); | |
| assert!( | |
| tokio::time::timeout(Duration::from_millis(100), &mut second_review) | |
| .await | |
| .is_err(), | |
| "the trunk guardian review should still be blocked on its gated response" | |
| ); | |
| gate_tx | |
| .send(()) | |
| .expect("second guardian review gate should still be open"); | |
| assert_eq!(second_review.await?, ReviewDecision::Approved); | |
| let feedback = codex_feedback::guardian_review_failures(&[session.thread_id()]) | |
| .attachment | |
| .expect("failed ephemeral review survives cleanup and subsequent allowed reviews"); | |
| let record: serde_json::Value = serde_json::from_slice(&feedback.buffer)?; | |
| assert_eq!( | |
| serde_json::json!({ | |
| "reviewed_thread_id": record["reviewed_thread_id"], | |
| "reviewed_turn_id": record["reviewed_turn_id"], | |
| "target_item_id": record["target_item_id"], | |
| "reviewer_thread_id": record["reviewer_thread_id"], | |
| "status": record["status"], | |
| "decision": record["decision"], | |
| "command": serde_json::from_str::<serde_json::Value>( | |
| record["action"].as_str().expect("reviewed action") | |
| )?["command"], | |
| }), | |
| serde_json::json!({ | |
| "reviewed_thread_id": session.thread_id(), | |
| "reviewed_turn_id": turn.sub_id, | |
| "target_item_id": "shell-guardian-3", | |
| "reviewer_thread_id": failed_ephemeral_request_body["client_metadata"]["thread_id"], | |
| "status": "invalid_decision", | |
| "decision": "not valid guardian json", | |
| "command": ["git", "push"], | |
| }) | |
| ); | |
| server.shutdown().await; | |
| Ok(()) | |
| })) | |
| })?; | |
| match handle.join() { | |
| Ok(result) => result, | |
| Err(_) => Err(anyhow::anyhow!( | |
| "guardian_ephemeral_retry_preserves_parallel_trunk_and_fork_history thread panicked" | |
| )), | |
| } | |
| } | |
| 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() | |
| ); | |
| } | |
| 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) | |
| ); | |
| } | |
| 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)) | |
| ); | |
| } | |
| 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, | |
| )) | |
| ); | |
| } | |
| 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); | |
| } | |
| 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") | |
| ) | |
| ); | |
| } | |
| 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); | |
| } | |
| 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); | |
| } | |
| 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())); | |
| } | |
| 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, | |
| ) | |
| ); | |
| } | |
| 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, | |
| )) | |
| ); | |
| } | |
| async fn guardian_review_session_config_uses_default_guardian_policy_without_requirements_override() | |
| { | |
| let codex_home = tempfile::tempdir().expect("create temp dir"); | |
| let workspace = tempfile::tempdir().expect("create temp dir"); | |
| let config_layer_stack = | |
| ConfigLayerStack::new(Vec::new(), Default::default(), Default::default()) | |
| .expect("config layer stack"); | |
| let parent_config = Config::load_config_with_layer_stack( | |
| LOCAL_FS.as_ref(), | |
| ConfigToml::default(), | |
| ConfigOverrides { | |
| cwd: Some(workspace.path().to_path_buf()), | |
| ..Default::default() | |
| }, | |
| codex_home.abs(), | |
| config_layer_stack, | |
| ) | |
| .await | |
| .expect("load config"); | |
| let guardian_config = build_guardian_review_session_config_for_test( | |
| crate::guardian::test_host::build_reviewer_config(&parent_config).expect("reviewer config"), | |
| /*live_network_config*/ None, | |
| "active-model", | |
| /*reasoning_effort*/ None, | |
| ReasoningSummary::default(), | |
| /*personality*/ None, | |
| /*model_messages*/ None, | |
| ) | |
| .expect("guardian config"); | |
| assert_eq!(guardian_config.developer_instructions, None); | |
| assert_eq!( | |
| guardian_config.base_instructions, | |
| Some(guardian_policy_prompt_with_config_and_template( | |
| BUNDLED_GUARDIAN_POLICY, | |
| BUNDLED_GUARDIAN_POLICY_TEMPLATE, | |
| )) | |
| ); | |
| } | |
| // Keep the existing reviewer tests on the production decision path. | |
| async fn review_approval_request( | |
| session: &Arc<Session>, | |
| context: impl Into<GuardianReviewContext>, | |
| review_id: String, | |
| request: GuardianApprovalRequest, | |
| reasons: ApprovalRequestReasons, | |
| ) -> ReviewDecision { | |
| super::decide_approval( | |
| Arc::clone(session), | |
| context, | |
| review_id, | |
| request, | |
| reasons, | |
| GuardianReviewOptions { | |
| require_guardian: true, | |
| plugin_attribution_override: None, | |
| approval_request_source: GuardianApprovalRequestSource::MainTurn, | |
| external_cancel: None, | |
| require_synchronous_review: false, | |
| }, | |
| ) | |
| .await | |
| .expect("Guardian should handle the request") | |
| } | |
| /// Whether this turn should route allowed approval prompts through the guardian | |
| /// reviewer instead of surfacing them to the user. ARC may still block actions | |
| /// earlier in the flow. | |
| fn routes_approval_to_guardian(turn: &crate::session::turn_context::TurnContext) -> bool { | |
| routes_approval_to_guardian_with_reviewer(turn, turn.config.approvals_reviewer) | |
| } | |
| /// Whether an approval with its own reviewer selection should be routed through guardian. | |
| fn routes_approval_to_guardian_with_reviewer( | |
| turn: &crate::session::turn_context::TurnContext, | |
| approvals_reviewer: ApprovalsReviewer, | |
| ) -> bool { | |
| routes_approval_policy_to_guardian(turn.approval_policy(), approvals_reviewer) | |
| } | |