#![allow(clippy::unwrap_used)] use anyhow::Context; use anyhow::Result; #[cfg(unix)] use codex_config::test_support::CloudConfigBundleFixture; use codex_config::types::ApprovalsReviewer; use codex_core::CodexThread; use codex_core::TurnInputRequest; use codex_core::config::Constrained; use codex_core::config::ThreadStoreConfig; use codex_core::sandboxing::SandboxPermissions; use codex_features::Feature; use codex_protocol::approvals::NetworkApprovalProtocol; use codex_protocol::approvals::NetworkPolicyAmendment; use codex_protocol::approvals::NetworkPolicyRuleAction; use codex_protocol::config_types::CollaborationMode; #[cfg(unix)] use codex_protocol::config_types::EnvironmentVariablePattern; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; #[cfg(unix)] use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::PermissionProfile; 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::ApplyPatchApprovalRequestEvent; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecApprovalRequestEvent; use codex_protocol::protocol::ExecPolicyAmendment; use codex_protocol::protocol::GranularApprovalConfig; use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::user_input::UserInput; use core_test_support::managed_network_requirements_loader; use core_test_support::responses::ev_apply_patch_custom_tool_call; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_function_call_with_namespace; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::local_requests; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; use core_test_support::wait_for_event_with_timeout; use pretty_assertions::assert_eq; use regex_lite::Regex; use serde_json::Value; use serde_json::json; use std::fs; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use tempfile::TempDir; use test_case::test_case; use wiremock::Mock; use wiremock::MockServer; use wiremock::Request; use wiremock::ResponseTemplate; use wiremock::matchers::method; use wiremock::matchers::path; #[derive(Clone, Copy)] enum TargetPath { Workspace(&'static str), OutsideWorkspace(&'static str), } impl TargetPath { fn resolve_for_patch(self, test: &TestCodex) -> (PathBuf, String) { match self { TargetPath::Workspace(name) => { let path = test.cwd.path().join(name); (path, name.to_string()) } TargetPath::OutsideWorkspace(name) => { let path = test.home.path().join(name); (path.clone(), path.display().to_string()) } } } } #[derive(Clone)] enum ActionKind { WriteFile { target: TargetPath, content: &'static str, }, FetchUrlNoProxy { endpoint: &'static str, response_body: &'static str, }, FetchUrl { endpoint: &'static str, response_body: &'static str, }, RunCommand { command: &'static str, }, RunCommandWithPolicy { command: &'static str, policy_src: &'static str, }, RunCommandWithPrefixRule { command: &'static str, prefix_rule: &'static [&'static str], }, RunUnifiedExecCommand { command: &'static str, justification: Option<&'static str>, }, ApplyPatchFreeform { target: TargetPath, content: &'static str, }, ApplyPatchShell { target: TargetPath, content: &'static str, }, } const DEFAULT_UNIFIED_EXEC_JUSTIFICATION: &str = "Requires escalated permissions to bypass the sandbox in tests."; const WORKSPACE_PERMISSION_PROFILE_CONFIG: &str = r#"default_permissions = ":workspace" "#; impl ActionKind { fn policy_src(&self) -> Option<&'static str> { match self { ActionKind::RunCommandWithPolicy { policy_src, .. } => Some(*policy_src), ActionKind::WriteFile { .. } | ActionKind::FetchUrlNoProxy { .. } | ActionKind::FetchUrl { .. } | ActionKind::RunCommand { .. } | ActionKind::RunCommandWithPrefixRule { .. } | ActionKind::RunUnifiedExecCommand { .. } | ActionKind::ApplyPatchFreeform { .. } | ActionKind::ApplyPatchShell { .. } => None, } } async fn prepare( &self, test: &TestCodex, server: &MockServer, call_id: &str, sandbox_permissions: SandboxPermissions, ) -> Result<(Value, Option)> { match self { ActionKind::WriteFile { target, content } => { let (path, _) = target.resolve_for_patch(test); let _ = fs::remove_file(&path); let path_str = path.display().to_string(); let script = format!( "from pathlib import Path; path = Path({path_str:?}); content = {content:?}; path.write_text(content, encoding='utf-8'); print(path.read_text(encoding='utf-8'), end='')", ); let command = format!("python3 -c {script:?}"); let event = shell_event( call_id, &command, /*timeout_ms*/ 5_000, sandbox_permissions, )?; Ok((event, Some(command))) } ActionKind::FetchUrl { endpoint, response_body, } => { Mock::given(method("GET")) .and(path(*endpoint)) .respond_with( ResponseTemplate::new(200).set_body_string(response_body.to_string()), ) .mount(server) .await; let url = format!("{}{}", server.uri(), endpoint); let escaped_url = url.replace('\'', "\\'"); let script = format!( "import sys\nimport urllib.request\nurl = '{escaped_url}'\ntry:\n data = urllib.request.urlopen(url, timeout=2).read().decode()\n print('OK:' + data.strip())\nexcept Exception as exc:\n print('ERR:' + exc.__class__.__name__)\n sys.exit(1)", ); let command = format!("python3 -c \"{script}\""); let event = shell_event( call_id, &command, /*timeout_ms*/ 5_000, sandbox_permissions, )?; Ok((event, Some(command))) } ActionKind::FetchUrlNoProxy { endpoint, response_body, } => { Mock::given(method("GET")) .and(path(*endpoint)) .respond_with( ResponseTemplate::new(200).set_body_string(response_body.to_string()), ) .mount(server) .await; let url = format!("{}{}", server.uri(), endpoint); let escaped_url = url.replace('\'', "\\'"); let script = format!( "import sys\nimport urllib.request\nurl = '{escaped_url}'\nopener = urllib.request.build_opener(urllib.request.ProxyHandler({{}}))\ntry:\n data = opener.open(url, timeout=2).read().decode()\n print('OK:' + data.strip())\nexcept Exception as exc:\n print('ERR:' + exc.__class__.__name__)\n sys.exit(1)", ); let command = format!("python3 -c \"{script}\""); let event = shell_event( call_id, &command, /*timeout_ms*/ 5_000, sandbox_permissions, )?; Ok((event, Some(command))) } ActionKind::RunCommand { command } => { // Bazel Linux runners can be heavily oversubscribed while this // matrix runs, so avoid making scheduling latency look like an // approval behavior failure. let event = shell_event( call_id, command, /*timeout_ms*/ 30_000, sandbox_permissions, )?; Ok((event, Some(command.to_string()))) } ActionKind::RunCommandWithPolicy { command, .. } => { // Bazel Linux runners can be heavily oversubscribed while this // matrix runs, so avoid making scheduling latency look like an // approval behavior failure. let event = shell_event( call_id, command, /*timeout_ms*/ 30_000, sandbox_permissions, )?; Ok((event, Some(command.to_string()))) } ActionKind::RunCommandWithPrefixRule { command, prefix_rule, } => { let event = shell_event_with_prefix_rule( call_id, command, /*timeout_ms*/ 30_000, sandbox_permissions, Some(prefix_rule.iter().map(|part| (*part).to_string()).collect()), )?; Ok((event, Some(command.to_string()))) } ActionKind::RunUnifiedExecCommand { command, justification, } => { let event = exec_command_event( call_id, command, Some(1000), sandbox_permissions, *justification, )?; Ok((event, Some(command.to_string()))) } ActionKind::ApplyPatchFreeform { target, content } => { let (path, patch_path) = target.resolve_for_patch(test); let _ = fs::remove_file(&path); let patch = build_add_file_patch(&patch_path, content); Ok((ev_apply_patch_custom_tool_call(call_id, &patch), None)) } ActionKind::ApplyPatchShell { target, content } => { let (path, patch_path) = target.resolve_for_patch(test); let _ = fs::remove_file(&path); let patch = build_add_file_patch(&patch_path, content); let command = shell_apply_patch_command(&patch); // Bazel may need to launch the configured Codex helper binary // to apply the verified patch, which can exceed the normal // short command timeout on slower CI runners. let timeout_ms = 30_000; let event = shell_event(call_id, &command, timeout_ms, sandbox_permissions)?; Ok((event, Some(command))) } } } } fn build_add_file_patch(patch_path: &str, content: &str) -> String { format!("*** Begin Patch\n*** Add File: {patch_path}\n+{content}\n*** End Patch\n") } fn shell_apply_patch_command(patch: &str) -> String { let mut script = String::from("apply_patch <<'PATCH'\n"); script.push_str(patch); if !patch.ends_with('\n') { script.push('\n'); } script.push_str("PATCH\n"); script } fn shell_event( call_id: &str, command: &str, timeout_ms: u64, sandbox_permissions: SandboxPermissions, ) -> Result { shell_event_with_prefix_rule( call_id, command, timeout_ms, sandbox_permissions, /*prefix_rule*/ None, ) } fn shell_event_with_prefix_rule( call_id: &str, command: &str, timeout_ms: u64, sandbox_permissions: SandboxPermissions, prefix_rule: Option>, ) -> Result { let mut args = json!({ "cmd": command, "yield_time_ms": timeout_ms, }); if sandbox_permissions.requests_sandbox_override() { args["sandbox_permissions"] = json!(sandbox_permissions); args["justification"] = json!(DEFAULT_UNIFIED_EXEC_JUSTIFICATION); } if let Some(prefix_rule) = prefix_rule { args["prefix_rule"] = json!(prefix_rule); } let args_str = serde_json::to_string(&args)?; Ok(ev_function_call(call_id, "exec_command", &args_str)) } fn exec_command_event( call_id: &str, cmd: &str, yield_time_ms: Option, sandbox_permissions: SandboxPermissions, justification: Option<&str>, ) -> Result { let mut args = json!({ "cmd": cmd.to_string(), }); if let Some(yield_time_ms) = yield_time_ms { args["yield_time_ms"] = json!(yield_time_ms); } if sandbox_permissions.requests_sandbox_override() { args["sandbox_permissions"] = json!(sandbox_permissions); let reason = justification.unwrap_or(DEFAULT_UNIFIED_EXEC_JUSTIFICATION); args["justification"] = json!(reason); } let args_str = serde_json::to_string(&args)?; Ok(ev_function_call(call_id, "exec_command", &args_str)) } #[derive(Clone)] enum Expectation { FileCreated { target: TargetPath, content: &'static str, }, FileCreatedNoExitCode { target: TargetPath, content: &'static str, }, PatchApplied { target: TargetPath, content: &'static str, }, FileNotCreated { target: TargetPath, message_contains: &'static [&'static str], }, NetworkSuccess { body_contains: &'static str, }, NetworkSuccessNoExitCode { body_contains: &'static str, }, NetworkFailure { expect_tag: &'static str, }, CommandSuccess { stdout_contains: &'static str, }, CommandSuccessNoExitCode { stdout_contains: &'static str, }, CommandFailure { output_contains: &'static str, }, } impl Expectation { fn verify(&self, test: &TestCodex, result: &CommandResult) -> Result<()> { match self { Expectation::FileCreated { target, content } => { let (path, _) = target.resolve_for_patch(test); assert_eq!( result.exit_code, Some(0), "expected successful exit for {path:?}" ); assert!( result.stdout.contains(content), "stdout missing {content:?}: {}", result.stdout ); let file_contents = fs::read_to_string(&path)?; assert!( file_contents.contains(content), "file contents missing {content:?}: {file_contents}" ); let _ = fs::remove_file(path); } Expectation::FileCreatedNoExitCode { target, content } => { let (path, _) = target.resolve_for_patch(test); assert!( result.exit_code.is_none() || result.exit_code == Some(0), "expected no exit code for {path:?}", ); assert!( result.stdout.contains(content), "stdout missing {content:?}: {}", result.stdout ); let file_contents = fs::read_to_string(&path)?; assert!( file_contents.contains(content), "file contents missing {content:?}: {file_contents}" ); let _ = fs::remove_file(path); } Expectation::PatchApplied { target, content } => { let (path, _) = target.resolve_for_patch(test); match result.exit_code { Some(0) | None => { if result.exit_code.is_none() { assert!( result.stdout.contains("Success."), "patch output missing success indicator: {}", result.stdout ); } } Some(code) => panic!( "expected successful patch exit for {:?}, got {code} with stdout {}", path, result.stdout ), } let file_contents = fs::read_to_string(&path)?; assert!( file_contents.contains(content), "patched file missing {content:?}: {file_contents}" ); let _ = fs::remove_file(path); } Expectation::FileNotCreated { target, message_contains, } => { let (path, _) = target.resolve_for_patch(test); assert_ne!( result.exit_code, Some(0), "expected non-zero exit for {path:?}" ); for needle in *message_contains { if needle.contains('|') { let options: Vec<&str> = needle.split('|').collect(); let matches_any = options.iter().any(|option| result.stdout.contains(option)); assert!( matches_any, "stdout missing one of {options:?}: {}", result.stdout ); } else { assert!( result.stdout.contains(needle), "stdout missing {needle:?}: {}", result.stdout ); } } assert!( !path.exists(), "command should not create {path:?}, but file exists" ); } Expectation::NetworkSuccess { body_contains } => { assert_eq!( result.exit_code, Some(0), "expected successful network exit: {}", result.stdout ); assert!( result.stdout.contains("OK:"), "stdout missing OK prefix: {}", result.stdout ); assert!( result.stdout.contains(body_contains), "stdout missing body text {body_contains:?}: {}", result.stdout ); } Expectation::NetworkSuccessNoExitCode { body_contains } => { assert!( result.exit_code.is_none() || result.exit_code == Some(0), "expected no exit code for successful network call: {}", result.stdout ); assert!( result.stdout.contains("OK:"), "stdout missing OK prefix: {}", result.stdout ); assert!( result.stdout.contains(body_contains), "stdout missing body text {body_contains:?}: {}", result.stdout ); } Expectation::NetworkFailure { expect_tag } => { assert_ne!( result.exit_code, Some(0), "expected non-zero exit for network failure: {}", result.stdout ); assert!( result.stdout.contains("ERR:"), "stdout missing ERR prefix: {}", result.stdout ); assert!( result.stdout.contains(expect_tag), "stdout missing expected tag {expect_tag:?}: {}", result.stdout ); } Expectation::CommandSuccess { stdout_contains } => { assert_eq!( result.exit_code, Some(0), "expected successful command exit: {}", result.stdout ); assert!( result.stdout.contains(stdout_contains), "command stdout missing {stdout_contains:?}: {}", result.stdout ); } Expectation::CommandSuccessNoExitCode { stdout_contains } => { assert!( result.exit_code.is_none() || result.exit_code == Some(0), "expected no exit code for command: {}", result.stdout ); assert!( result.stdout.contains(stdout_contains), "command stdout missing {stdout_contains:?}: {}", result.stdout ); } Expectation::CommandFailure { output_contains } => { assert_ne!( result.exit_code, Some(0), "expected non-zero exit for command failure: {}", result.stdout ); assert!( result.stdout.contains(output_contains), "command failure stderr missing {output_contains:?}: {}", result.stdout ); } } Ok(()) } } #[derive(Clone)] enum ExpectedExecPolicyAmendment { Prefix(&'static [&'static str]), FullCommand, } #[derive(Clone)] enum Outcome { Auto, ExecApproval { decision: ReviewDecision, expected_reason: Option<&'static str>, }, ExecApprovalWithAmendment { decision: ReviewDecision, expected_reason: Option<&'static str>, expected_execpolicy_amendment: Option, }, PatchApproval { decision: ReviewDecision, expected_reason: Option<&'static str>, }, } #[derive(Clone)] struct ScenarioSpec { name: &'static str, approval_policy: AskForApproval, sandbox_policy: SandboxPolicy, action: ActionKind, sandbox_permissions: SandboxPermissions, features: Vec, outcome: Outcome, expectation: Expectation, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ScenarioGroup { DangerFullAccess, ReadOnly, WorkspaceWrite, ApplyPatch, UnifiedExec, } #[derive(Debug, Eq, PartialEq)] struct CommandResult { exit_code: Option, stdout: String, } async fn submit_turn( test: &TestCodex, prompt: &str, approval_policy: AskForApproval, sandbox_policy: SandboxPolicy, ) -> Result<()> { let session_model = test.session_configured.model.clone(); test.codex .start_or_steer_turn( TurnInputRequest::user_input(vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), }]) .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_requests(test.config.cwd.clone())), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(sandbox_policy), collaboration_mode: Some(CollaborationMode { mode: ModeKind::Default, settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() }), ) .await?; Ok(()) } async fn submit_turn_preserving_active_permission_profile( test: &TestCodex, prompt: &str, approval_policy: AskForApproval, ) -> Result<()> { let session_model = test.session_configured.model.clone(); test.codex .start_or_steer_turn( TurnInputRequest::user_input(vec![UserInput::Text { text: prompt.into(), text_elements: Vec::new(), }]) .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_requests(test.config.cwd.clone())), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), collaboration_mode: Some(CollaborationMode { mode: ModeKind::Default, settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() }), ) .await?; Ok(()) } fn assert_active_workspace_permission_profile(test: &TestCodex) { assert_eq!( test.session_configured .active_permission_profile .as_ref() .map(|profile| profile.id.as_str()), Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE) ); } fn parse_result(item: &Value) -> CommandResult { let output_str = item .get("output") .and_then(Value::as_str) .expect("shell output payload"); match serde_json::from_str::(output_str) { Ok(parsed) => { let exit_code = parsed["metadata"]["exit_code"].as_i64(); let stdout = parsed["output"].as_str().unwrap_or_default().to_string(); CommandResult { exit_code, stdout } } Err(_) => { let structured = Regex::new(r"(?s)^Exit code:\s*(-?\d+).*?Output:\n(.*)$").unwrap(); let regex = Regex::new(r"(?s)^.*?Process exited with code (\d+)\n.*?Output:\n(.*)$").unwrap(); // parse freeform output if let Some(captures) = structured.captures(output_str) { let exit_code = captures.get(1).unwrap().as_str().parse::().unwrap(); let output = captures.get(2).unwrap().as_str(); CommandResult { exit_code: Some(exit_code), stdout: output.to_string(), } } else if let Some(captures) = regex.captures(output_str) { let exit_code = captures.get(1).unwrap().as_str().parse::().unwrap(); let output = captures.get(2).unwrap().as_str(); CommandResult { exit_code: Some(exit_code), stdout: output.to_string(), } } else { CommandResult { exit_code: None, stdout: output_str.to_string(), } } } } } async fn expect_exec_approval( test: &TestCodex, expected_command: &str, ) -> ExecApprovalRequestEvent { let event = wait_for_event(&test.codex, |event| { matches!( event, EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) ) }) .await; match event { EventMsg::ExecApprovalRequest(approval) => { let last_arg = approval .command .last() .map(std::string::String::as_str) .unwrap_or_default(); assert_eq!(last_arg, expected_command); approval } EventMsg::TurnComplete(_) => panic!("expected approval request before completion"), other => panic!("unexpected event: {other:?}"), } } async fn expect_patch_approval( test: &TestCodex, expected_call_id: &str, ) -> ApplyPatchApprovalRequestEvent { let event = wait_for_event_with_timeout( &test.codex, |event| { matches!( event, EventMsg::ApplyPatchApprovalRequest(_) | EventMsg::ExecApprovalRequest(_) | EventMsg::Error(_) | EventMsg::TurnAborted(_) | EventMsg::ShutdownComplete | EventMsg::TurnComplete(_) ) }, Duration::from_secs(15), ) .await; match event { EventMsg::ApplyPatchApprovalRequest(approval) => { assert_eq!(approval.call_id, expected_call_id); approval } EventMsg::TurnComplete(_) => panic!("expected patch approval request before completion"), other => panic!("unexpected event: {other:?}"), } } async fn wait_for_completion_without_approval(test: &TestCodex) { let event = wait_for_event(&test.codex, |event| { matches!( event, EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) ) }) .await; match event { EventMsg::TurnComplete(_) => {} EventMsg::ExecApprovalRequest(event) => { panic!("unexpected approval request: {:?}", event.command) } other => panic!("unexpected event: {other:?}"), } } async fn wait_for_completion(test: &TestCodex) { wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) }) .await; } fn body_contains(req: &Request, text: &str) -> bool { let is_zstd = req .headers .get("content-encoding") .and_then(|value| value.to_str().ok()) .is_some_and(|value| { value .split(',') .any(|entry| entry.trim().eq_ignore_ascii_case("zstd")) }); let bytes = if is_zstd { zstd::stream::decode_all(std::io::Cursor::new(&req.body)).ok() } else { Some(req.body.clone()) }; bytes .and_then(|body| String::from_utf8(body).ok()) .is_some_and(|body| body.contains(text)) } async fn wait_for_spawned_thread(test: &TestCodex) -> Result> { let deadline = tokio::time::Instant::now() + Duration::from_secs(2); loop { let ids = test.thread_manager.list_thread_ids().await; if let Some(thread_id) = ids .iter() .find(|id| **id != test.session_configured.thread_id) { return test .thread_manager .get_thread(*thread_id) .await .map_err(anyhow::Error::from); } if tokio::time::Instant::now() >= deadline { anyhow::bail!("timed out waiting for spawned thread"); } tokio::time::sleep(Duration::from_millis(10)).await; } } fn scenarios() -> Vec { use AskForApproval::*; let workspace_write = |network_access| SandboxPolicy::WorkspaceWrite { writable_roots: vec![], network_access, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, }; vec![ ScenarioSpec { name: "danger_full_access_on_request_allows_outside_write", approval_policy: OnRequest, sandbox_policy: SandboxPolicy::DangerFullAccess, action: ActionKind::WriteFile { target: TargetPath::OutsideWorkspace("dfa_on_request.txt"), content: "danger-on-request", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::Auto, expectation: Expectation::FileCreated { target: TargetPath::OutsideWorkspace("dfa_on_request.txt"), content: "danger-on-request", }, }, ScenarioSpec { name: "danger_full_access_on_request_allows_outside_write_gpt_5_1_no_exit", approval_policy: OnRequest, sandbox_policy: SandboxPolicy::DangerFullAccess, action: ActionKind::WriteFile { target: TargetPath::OutsideWorkspace("dfa_on_request_5_1.txt"), content: "danger-on-request", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::Auto, expectation: Expectation::FileCreated { target: TargetPath::OutsideWorkspace("dfa_on_request_5_1.txt"), content: "danger-on-request", }, }, ScenarioSpec { name: "danger_full_access_on_request_allows_network", approval_policy: OnRequest, sandbox_policy: SandboxPolicy::DangerFullAccess, action: ActionKind::FetchUrlNoProxy { endpoint: "/dfa/network", response_body: "danger-network-ok", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::Auto, expectation: Expectation::NetworkSuccess { body_contains: "danger-network-ok", }, }, ScenarioSpec { name: "danger_full_access_on_request_allows_network_gpt_5_1_no_exit", approval_policy: OnRequest, sandbox_policy: SandboxPolicy::DangerFullAccess, action: ActionKind::FetchUrlNoProxy { endpoint: "/dfa/network", response_body: "danger-network-ok", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::Auto, expectation: Expectation::NetworkSuccessNoExitCode { body_contains: "danger-network-ok", }, }, ScenarioSpec { name: "simple_command_unless_trusted_requires_approval", approval_policy: UnlessTrusted, sandbox_policy: SandboxPolicy::DangerFullAccess, action: ActionKind::RunCommand { command: "echo trusted-unless", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::ExecApproval { decision: ReviewDecision::denied("blocked in untrusted project"), expected_reason: None, }, expectation: Expectation::CommandFailure { output_contains: "blocked in untrusted project", }, }, ScenarioSpec { name: "simple_command_unless_trusted_requires_approval_gpt_5_1_no_exit", approval_policy: UnlessTrusted, sandbox_policy: SandboxPolicy::DangerFullAccess, action: ActionKind::RunCommand { command: "echo trusted-unless", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::ExecApproval { decision: ReviewDecision::denied("blocked in untrusted project"), expected_reason: None, }, expectation: Expectation::CommandFailure { output_contains: "blocked in untrusted project", }, }, ScenarioSpec { name: "cat_redirect_unless_trusted_requires_approval", approval_policy: UnlessTrusted, sandbox_policy: workspace_write(false), action: ActionKind::RunCommand { command: r#"cat < "hello" > /var/test.txt"#, }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::ExecApproval { decision: ReviewDecision::denied("blocked by distinctive approval policy"), expected_reason: None, }, expectation: Expectation::CommandFailure { output_contains: "blocked by distinctive approval policy", }, }, ScenarioSpec { name: "cat_redirect_on_request_requires_approval", approval_policy: OnRequest, sandbox_policy: workspace_write(false), action: ActionKind::RunCommand { command: r#"cat < "hello" > /var/test.txt"#, }, sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], outcome: Outcome::ExecApproval { decision: ReviewDecision::denied("rejected by user"), expected_reason: None, }, expectation: Expectation::CommandFailure { output_contains: "rejected by user", }, }, ScenarioSpec { name: "simple_command_escalation_on_request_requires_approval", approval_policy: OnRequest, sandbox_policy: workspace_write(false), action: ActionKind::RunCommand { command: "echo known-safe-escalation", }, sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], outcome: Outcome::ExecApprovalWithAmendment { decision: ReviewDecision::denied("rejected by user"), expected_reason: None, expected_execpolicy_amendment: Some(ExpectedExecPolicyAmendment::Prefix(&[ "echo", "known-safe-escalation", ])), }, expectation: Expectation::CommandFailure { output_contains: "rejected by user", }, }, ScenarioSpec { name: "simple_command_escalation_granular_sandbox_enabled_requires_approval", approval_policy: Granular(GranularApprovalConfig { sandbox_approval: true, rules: true, skill_approval: true, request_permissions: true, mcp_elicitations: true, }), sandbox_policy: workspace_write(false), action: ActionKind::RunCommand { command: "echo known-safe-escalation", }, sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], outcome: Outcome::ExecApprovalWithAmendment { decision: ReviewDecision::denied("rejected by user"), expected_reason: None, expected_execpolicy_amendment: Some(ExpectedExecPolicyAmendment::Prefix(&[ "echo", "known-safe-escalation", ])), }, expectation: Expectation::CommandFailure { output_contains: "rejected by user", }, }, ScenarioSpec { name: "simple_command_escalation_granular_sandbox_disabled_rejects", approval_policy: Granular(GranularApprovalConfig { sandbox_approval: false, rules: true, skill_approval: true, request_permissions: true, mcp_elicitations: true, }), sandbox_policy: workspace_write(false), action: ActionKind::RunCommand { command: "echo known-safe-escalation-granular-disabled", }, sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], outcome: Outcome::Auto, expectation: Expectation::CommandFailure { output_contains: "you cannot ask for escalated permissions", }, }, ScenarioSpec { name: "cat_heredoc_inner_allow_rule_requires_escalation_approval", approval_policy: OnRequest, sandbox_policy: workspace_write(false), action: ActionKind::RunCommandWithPolicy { command: "cat <<'EOF'\nhello\nEOF", policy_src: r#"prefix_rule(pattern=["cat"], decision="allow")"#, }, sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], outcome: Outcome::ExecApprovalWithAmendment { decision: ReviewDecision::denied("rejected by user"), expected_reason: None, expected_execpolicy_amendment: Some(ExpectedExecPolicyAmendment::FullCommand), }, expectation: Expectation::CommandFailure { output_contains: "rejected by user", }, }, ScenarioSpec { name: "cat_heredoc_file_redirect_prefix_rule_requires_escalation_approval", approval_policy: OnRequest, sandbox_policy: workspace_write(false), action: ActionKind::RunCommandWithPrefixRule { command: r#"cat <<'EOF' > /tmp/out.txt hello EOF"#, prefix_rule: &["cat"], }, sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], outcome: Outcome::ExecApproval { decision: ReviewDecision::denied("rejected by user"), expected_reason: None, }, expectation: Expectation::CommandFailure { output_contains: "rejected by user", }, }, ScenarioSpec { name: "cat_heredoc_variable_assignment_policy_requires_escalation_approval", approval_policy: OnRequest, sandbox_policy: workspace_write(false), action: ActionKind::RunCommandWithPolicy { command: r#"PATH=/tmp/evil:$PATH cat <<'EOF' hello EOF"#, policy_src: r#"prefix_rule(pattern=["cat"], decision="allow")"#, }, sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], outcome: Outcome::ExecApproval { decision: ReviewDecision::denied("rejected by user"), expected_reason: None, }, expectation: Expectation::CommandFailure { output_contains: "rejected by user", }, }, ScenarioSpec { name: "python_heredoc_requested_prefix_rule_proposes_full_command", approval_policy: OnRequest, sandbox_policy: workspace_write(false), action: ActionKind::RunCommandWithPrefixRule { command: r#"python3 <<'PY' print('hello') PY"#, prefix_rule: &["python3"], }, sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], outcome: Outcome::ExecApprovalWithAmendment { decision: ReviewDecision::denied("rejected by user"), expected_reason: None, expected_execpolicy_amendment: Some(ExpectedExecPolicyAmendment::FullCommand), }, expectation: Expectation::CommandFailure { output_contains: "rejected by user", }, }, ScenarioSpec { name: "danger_full_access_unless_trusted_requests_approval", approval_policy: UnlessTrusted, sandbox_policy: SandboxPolicy::DangerFullAccess, action: ActionKind::WriteFile { target: TargetPath::OutsideWorkspace("dfa_unless_trusted.txt"), content: "danger-unless-trusted", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::ExecApproval { decision: ReviewDecision::Approved, expected_reason: None, }, expectation: Expectation::FileCreated { target: TargetPath::OutsideWorkspace("dfa_unless_trusted.txt"), content: "danger-unless-trusted", }, }, ScenarioSpec { name: "danger_full_access_unless_trusted_requests_approval_gpt_5_1_no_exit", approval_policy: UnlessTrusted, sandbox_policy: SandboxPolicy::DangerFullAccess, action: ActionKind::WriteFile { target: TargetPath::OutsideWorkspace("dfa_unless_trusted_5_1.txt"), content: "danger-unless-trusted", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::ExecApproval { decision: ReviewDecision::Approved, expected_reason: None, }, expectation: Expectation::FileCreatedNoExitCode { target: TargetPath::OutsideWorkspace("dfa_unless_trusted_5_1.txt"), content: "danger-unless-trusted", }, }, ScenarioSpec { name: "danger_full_access_never_allows_outside_write", approval_policy: Never, sandbox_policy: SandboxPolicy::DangerFullAccess, action: ActionKind::WriteFile { target: TargetPath::OutsideWorkspace("dfa_never.txt"), content: "danger-never", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::Auto, expectation: Expectation::FileCreated { target: TargetPath::OutsideWorkspace("dfa_never.txt"), content: "danger-never", }, }, ScenarioSpec { name: "danger_full_access_never_allows_outside_write_gpt_5_1_no_exit", approval_policy: Never, sandbox_policy: SandboxPolicy::DangerFullAccess, action: ActionKind::WriteFile { target: TargetPath::OutsideWorkspace("dfa_never_5_1.txt"), content: "danger-never", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::Auto, expectation: Expectation::FileCreatedNoExitCode { target: TargetPath::OutsideWorkspace("dfa_never_5_1.txt"), content: "danger-never", }, }, ScenarioSpec { name: "read_only_on_request_requires_approval", approval_policy: OnRequest, sandbox_policy: SandboxPolicy::new_read_only_policy(), action: ActionKind::WriteFile { target: TargetPath::Workspace("ro_on_request.txt"), content: "read-only-approval", }, sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], outcome: Outcome::ExecApproval { decision: ReviewDecision::Approved, expected_reason: None, }, expectation: Expectation::FileCreated { target: TargetPath::Workspace("ro_on_request.txt"), content: "read-only-approval", }, }, ScenarioSpec { name: "read_only_on_request_requires_approval_gpt_5_1_no_exit", approval_policy: OnRequest, sandbox_policy: SandboxPolicy::new_read_only_policy(), action: ActionKind::WriteFile { target: TargetPath::Workspace("ro_on_request_5_1.txt"), content: "read-only-approval", }, sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], outcome: Outcome::ExecApproval { decision: ReviewDecision::Approved, expected_reason: None, }, expectation: Expectation::FileCreatedNoExitCode { target: TargetPath::Workspace("ro_on_request_5_1.txt"), content: "read-only-approval", }, }, ScenarioSpec { name: "simple_command_on_request_read_only_runs_without_prompt", approval_policy: OnRequest, sandbox_policy: SandboxPolicy::new_read_only_policy(), action: ActionKind::RunCommand { command: "echo trusted-read-only", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::Auto, expectation: Expectation::CommandSuccess { stdout_contains: "trusted-read-only", }, }, ScenarioSpec { name: "simple_command_on_request_read_only_runs_without_prompt_gpt_5_1_no_exit", approval_policy: OnRequest, sandbox_policy: SandboxPolicy::new_read_only_policy(), action: ActionKind::RunCommand { command: "echo trusted-read-only", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::Auto, expectation: Expectation::CommandSuccessNoExitCode { stdout_contains: "trusted-read-only", }, }, ScenarioSpec { name: "read_only_on_request_blocks_network", approval_policy: OnRequest, sandbox_policy: SandboxPolicy::new_read_only_policy(), action: ActionKind::FetchUrl { endpoint: "/ro/network-blocked", response_body: "should-not-see", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::Auto, expectation: Expectation::NetworkFailure { expect_tag: "ERR:" }, }, ScenarioSpec { name: "read_only_on_request_denied_blocks_execution", approval_policy: OnRequest, sandbox_policy: SandboxPolicy::new_read_only_policy(), action: ActionKind::WriteFile { target: TargetPath::Workspace("ro_on_request_denied.txt"), content: "should-not-write", }, sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], outcome: Outcome::ExecApproval { decision: ReviewDecision::denied("rejected by user"), expected_reason: None, }, expectation: Expectation::FileNotCreated { target: TargetPath::Workspace("ro_on_request_denied.txt"), message_contains: &["rejected by user"], }, }, ScenarioSpec { name: "read_only_on_request_network_escalates_when_approved", approval_policy: OnRequest, sandbox_policy: SandboxPolicy::new_read_only_policy(), action: ActionKind::FetchUrl { endpoint: "/ro/network-approved", response_body: "read-only-network-ok", }, sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], outcome: Outcome::ExecApproval { decision: ReviewDecision::Approved, expected_reason: None, }, expectation: Expectation::NetworkSuccess { body_contains: "read-only-network-ok", }, }, ScenarioSpec { name: "read_only_on_request_network_escalates_when_approved_gpt_5_1_no_exit", approval_policy: OnRequest, sandbox_policy: SandboxPolicy::new_read_only_policy(), action: ActionKind::FetchUrl { endpoint: "/ro/network-approved", response_body: "read-only-network-ok", }, sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], outcome: Outcome::ExecApproval { decision: ReviewDecision::Approved, expected_reason: None, }, expectation: Expectation::NetworkSuccessNoExitCode { body_contains: "read-only-network-ok", }, }, ScenarioSpec { name: "apply_patch_exec_command_requires_patch_approval", approval_policy: UnlessTrusted, sandbox_policy: SandboxPolicy::DangerFullAccess, action: ActionKind::ApplyPatchShell { target: TargetPath::Workspace("apply_patch_shell.txt"), content: "shell-apply-patch", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::PatchApproval { decision: ReviewDecision::Approved, expected_reason: None, }, expectation: Expectation::PatchApplied { target: TargetPath::Workspace("apply_patch_shell.txt"), content: "shell-apply-patch", }, }, ScenarioSpec { name: "apply_patch_freeform_auto_inside_workspace", approval_policy: OnRequest, sandbox_policy: SandboxPolicy::DangerFullAccess, action: ActionKind::ApplyPatchFreeform { target: TargetPath::Workspace("apply_patch_freeform.txt"), content: "freeform-apply-patch", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::Auto, expectation: Expectation::PatchApplied { target: TargetPath::Workspace("apply_patch_freeform.txt"), content: "freeform-apply-patch", }, }, ScenarioSpec { name: "apply_patch_freeform_danger_allows_outside_workspace", approval_policy: OnRequest, sandbox_policy: SandboxPolicy::DangerFullAccess, action: ActionKind::ApplyPatchFreeform { target: TargetPath::OutsideWorkspace("apply_patch_freeform_danger.txt"), content: "freeform-patch-danger", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::Auto, expectation: Expectation::PatchApplied { target: TargetPath::OutsideWorkspace("apply_patch_freeform_danger.txt"), content: "freeform-patch-danger", }, }, ScenarioSpec { name: "apply_patch_freeform_outside_requires_patch_approval", approval_policy: OnRequest, sandbox_policy: workspace_write(false), action: ActionKind::ApplyPatchFreeform { target: TargetPath::OutsideWorkspace("apply_patch_freeform_outside.txt"), content: "freeform-patch-outside", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::PatchApproval { decision: ReviewDecision::Approved, expected_reason: None, }, expectation: Expectation::PatchApplied { target: TargetPath::OutsideWorkspace("apply_patch_freeform_outside.txt"), content: "freeform-patch-outside", }, }, ScenarioSpec { name: "apply_patch_freeform_outside_denied_blocks_patch", approval_policy: OnRequest, sandbox_policy: workspace_write(false), action: ActionKind::ApplyPatchFreeform { target: TargetPath::OutsideWorkspace("apply_patch_freeform_outside_denied.txt"), content: "freeform-patch-outside-denied", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::PatchApproval { decision: ReviewDecision::denied("rejected by user"), expected_reason: None, }, expectation: Expectation::FileNotCreated { target: TargetPath::OutsideWorkspace("apply_patch_freeform_outside_denied.txt"), message_contains: &["patch rejected by user"], }, }, ScenarioSpec { name: "apply_patch_exec_command_outside_requires_patch_approval", approval_policy: OnRequest, sandbox_policy: workspace_write(false), action: ActionKind::ApplyPatchShell { target: TargetPath::OutsideWorkspace("apply_patch_shell_outside.txt"), content: "shell-patch-outside", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::PatchApproval { decision: ReviewDecision::Approved, expected_reason: None, }, expectation: Expectation::PatchApplied { target: TargetPath::OutsideWorkspace("apply_patch_shell_outside.txt"), content: "shell-patch-outside", }, }, ScenarioSpec { name: "apply_patch_freeform_unless_trusted_requires_patch_approval", approval_policy: UnlessTrusted, sandbox_policy: SandboxPolicy::DangerFullAccess, action: ActionKind::ApplyPatchFreeform { target: TargetPath::Workspace("apply_patch_freeform_unless_trusted.txt"), content: "freeform-patch-unless-trusted", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::PatchApproval { decision: ReviewDecision::Approved, expected_reason: None, }, expectation: Expectation::PatchApplied { target: TargetPath::Workspace("apply_patch_freeform_unless_trusted.txt"), content: "freeform-patch-unless-trusted", }, }, ScenarioSpec { name: "apply_patch_freeform_never_rejects_outside_workspace", approval_policy: Never, sandbox_policy: workspace_write(false), action: ActionKind::ApplyPatchFreeform { target: TargetPath::OutsideWorkspace("apply_patch_freeform_never.txt"), content: "freeform-patch-never", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::Auto, expectation: Expectation::FileNotCreated { target: TargetPath::OutsideWorkspace("apply_patch_freeform_never.txt"), message_contains: &[ "patch rejected: writing outside of the project; rejected by user approval settings", ], }, }, ScenarioSpec { name: "read_only_never_reports_sandbox_failure", approval_policy: Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), action: ActionKind::WriteFile { target: TargetPath::Workspace("ro_never.txt"), content: "read-only-never", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::Auto, expectation: Expectation::FileNotCreated { target: TargetPath::Workspace("ro_never.txt"), message_contains: if cfg!(target_os = "linux") { &["Permission denied|Read-only file system"] } else { &[ "Permission denied|Operation not permitted|operation not permitted|\ Read-only file system", ] }, }, }, ScenarioSpec { name: "simple_command_never_runs_without_prompt", approval_policy: Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), action: ActionKind::RunCommand { command: "echo trusted-never", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::Auto, expectation: Expectation::CommandSuccess { stdout_contains: "trusted-never", }, }, ScenarioSpec { name: "workspace_write_on_request_allows_workspace_write", approval_policy: OnRequest, sandbox_policy: workspace_write(false), action: ActionKind::WriteFile { target: TargetPath::Workspace("ww_on_request.txt"), content: "workspace-on-request", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::Auto, expectation: Expectation::FileCreated { target: TargetPath::Workspace("ww_on_request.txt"), content: "workspace-on-request", }, }, ScenarioSpec { name: "workspace_write_network_disabled_blocks_network", approval_policy: OnRequest, sandbox_policy: workspace_write(false), action: ActionKind::FetchUrl { endpoint: "/ww/network-blocked", response_body: "workspace-network-blocked", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::Auto, expectation: Expectation::NetworkFailure { expect_tag: "ERR:" }, }, ScenarioSpec { name: "workspace_write_on_request_requires_approval_outside_workspace", approval_policy: OnRequest, sandbox_policy: workspace_write(false), action: ActionKind::WriteFile { target: TargetPath::OutsideWorkspace("ww_on_request_outside.txt"), content: "workspace-on-request-outside", }, sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![], outcome: Outcome::ExecApproval { decision: ReviewDecision::Approved, expected_reason: None, }, expectation: Expectation::FileCreated { target: TargetPath::OutsideWorkspace("ww_on_request_outside.txt"), content: "workspace-on-request-outside", }, }, ScenarioSpec { name: "workspace_write_network_enabled_allows_network", approval_policy: OnRequest, sandbox_policy: workspace_write(true), action: ActionKind::FetchUrl { endpoint: "/ww/network-ok", response_body: "workspace-network-ok", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::Auto, expectation: Expectation::NetworkSuccess { body_contains: "workspace-network-ok", }, }, ScenarioSpec { name: "workspace_write_never_blocks_outside_workspace", approval_policy: Never, sandbox_policy: workspace_write(false), action: ActionKind::WriteFile { target: TargetPath::OutsideWorkspace("ww_never.txt"), content: "workspace-never", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::Auto, expectation: Expectation::FileNotCreated { target: TargetPath::OutsideWorkspace("ww_never.txt"), message_contains: if cfg!(target_os = "linux") { &["Permission denied|Read-only file system"] } else { &[ "Permission denied|Operation not permitted|operation not permitted|\ Read-only file system", ] }, }, }, ScenarioSpec { name: "unified exec on request no approval for simple command", approval_policy: OnRequest, sandbox_policy: SandboxPolicy::DangerFullAccess, action: ActionKind::RunUnifiedExecCommand { command: "echo \"hello unified exec\"", justification: None, }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![Feature::UnifiedExec], outcome: Outcome::Auto, expectation: Expectation::CommandSuccess { stdout_contains: "hello unified exec", }, }, #[cfg(not(all(target_os = "linux", target_arch = "aarch64")))] // Linux sandbox arg0 test workaround doesn't work on ARM ScenarioSpec { name: "unified exec on request escalated requires approval", approval_policy: OnRequest, sandbox_policy: SandboxPolicy::new_read_only_policy(), action: ActionKind::RunUnifiedExecCommand { command: "python3 -c 'print('\"'\"'escalated unified exec'\"'\"')'", justification: Some(DEFAULT_UNIFIED_EXEC_JUSTIFICATION), }, sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![Feature::UnifiedExec], outcome: Outcome::ExecApproval { decision: ReviewDecision::Approved, expected_reason: Some(DEFAULT_UNIFIED_EXEC_JUSTIFICATION), }, expectation: Expectation::CommandSuccess { stdout_contains: "escalated unified exec", }, }, ScenarioSpec { name: "unified exec on request requires approval unless trusted", approval_policy: AskForApproval::UnlessTrusted, sandbox_policy: SandboxPolicy::DangerFullAccess, action: ActionKind::RunUnifiedExecCommand { command: "git reset --hard", justification: None, }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![Feature::UnifiedExec], outcome: Outcome::ExecApproval { decision: ReviewDecision::denied("rejected by user"), expected_reason: None, }, expectation: Expectation::CommandFailure { output_contains: "rejected by user", }, }, ScenarioSpec { name: "heredoc with redirect still requires approval", approval_policy: AskForApproval::OnRequest, sandbox_policy: workspace_write(false), action: ActionKind::RunUnifiedExecCommand { command: "cat <<'EOF' > /tmp/out.txt \nhello\nEOF", justification: None, }, sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![Feature::UnifiedExec], outcome: Outcome::ExecApproval { decision: ReviewDecision::denied("rejected by user"), expected_reason: None, }, expectation: Expectation::CommandFailure { output_contains: "rejected by user", }, }, ScenarioSpec { name: "compound command with one safe command still requires approval", approval_policy: AskForApproval::OnRequest, sandbox_policy: workspace_write(false), action: ActionKind::RunUnifiedExecCommand { command: "cat ./one.txt && touch ./two.txt", justification: None, }, sandbox_permissions: SandboxPermissions::RequireEscalated, features: vec![Feature::UnifiedExec], outcome: Outcome::ExecApproval { decision: ReviewDecision::denied("rejected by user"), expected_reason: None, }, expectation: Expectation::CommandFailure { output_contains: "rejected by user", }, }, // Deny these commands before execution. The nonexistent find target also // keeps a regression from deleting anything in the test workspace. ScenarioSpec { name: "find_brace_expansion_unless_trusted_requires_approval", approval_policy: UnlessTrusted, sandbox_policy: workspace_write(false), action: ActionKind::RunCommand { command: "find ./missing-approval-target -{delete,print}", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::ExecApproval { decision: ReviewDecision::denied("rejected dynamic shell word"), expected_reason: None, }, expectation: Expectation::CommandFailure { output_contains: "rejected dynamic shell word", }, }, ScenarioSpec { name: "rg_brace_expansion_unless_trusted_requires_approval", approval_policy: UnlessTrusted, sandbox_policy: workspace_write(false), action: ActionKind::RunCommand { command: "rg --pre{=,=sh} pattern missing-approval-payload.sh", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::ExecApproval { decision: ReviewDecision::denied("rejected dynamic shell word"), expected_reason: None, }, expectation: Expectation::CommandFailure { output_contains: "rejected dynamic shell word", }, }, ScenarioSpec { name: "brace_expansion_literal_allow_rule_requires_approval", approval_policy: UnlessTrusted, sandbox_policy: workspace_write(false), action: ActionKind::RunCommandWithPolicy { command: "find ./missing-approval-target -{delete,print}", policy_src: r#"prefix_rule(pattern=["find", "./missing-approval-target", "-{delete,print}"], decision="allow")"#, }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::ExecApproval { decision: ReviewDecision::denied("rejected dynamic shell word"), expected_reason: None, }, expectation: Expectation::CommandFailure { output_contains: "rejected dynamic shell word", }, }, ScenarioSpec { name: "rg_brace_expansion_literal_allow_rule_requires_approval", approval_policy: UnlessTrusted, sandbox_policy: workspace_write(false), action: ActionKind::RunCommandWithPolicy { command: "rg --pre{=,=sh} pattern missing-approval-payload.sh", policy_src: r#"prefix_rule(pattern=["rg", "--pre{=,=sh}"], decision="allow")"#, }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::ExecApproval { decision: ReviewDecision::denied("rejected dynamic shell word"), expected_reason: None, }, expectation: Expectation::CommandFailure { output_contains: "rejected dynamic shell word", }, }, ScenarioSpec { name: "find_glob_unless_trusted_requires_approval", approval_policy: UnlessTrusted, sandbox_policy: workspace_write(false), action: ActionKind::RunCommand { command: "find ./missing-approval-target -del*", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::ExecApproval { decision: ReviewDecision::denied("rejected dynamic shell word"), expected_reason: None, }, expectation: Expectation::CommandFailure { output_contains: "rejected dynamic shell word", }, }, ScenarioSpec { name: "find_escape_unless_trusted_requires_approval", approval_policy: UnlessTrusted, sandbox_policy: workspace_write(false), action: ActionKind::RunCommand { command: r"find ./missing-approval-target -de\lete", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::ExecApproval { decision: ReviewDecision::denied("rejected dynamic shell word"), expected_reason: None, }, expectation: Expectation::CommandFailure { output_contains: "rejected dynamic shell word", }, }, ScenarioSpec { name: "find_quoted_escape_unless_trusted_requires_approval", approval_policy: UnlessTrusted, sandbox_policy: workspace_write(false), action: ActionKind::RunCommand { command: "find ./missing-approval-target \"-de\\\nlete\"", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::ExecApproval { decision: ReviewDecision::denied("rejected dynamic shell word"), expected_reason: None, }, expectation: Expectation::CommandFailure { output_contains: "rejected dynamic shell word", }, }, ScenarioSpec { name: "heredoc_glob_literal_allow_rule_requires_approval", approval_policy: UnlessTrusted, sandbox_policy: workspace_write(false), action: ActionKind::RunCommandWithPolicy { command: "find ./missing-approval-target -del* <<'EOF'\nEOF", policy_src: r#"prefix_rule(pattern=["find", "./missing-approval-target", "-del*"], decision="allow")"#, }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::ExecApproval { decision: ReviewDecision::denied("rejected dynamic shell word"), expected_reason: None, }, expectation: Expectation::CommandFailure { output_contains: "rejected dynamic shell word", }, }, ScenarioSpec { name: "quoted_shell_metacharacters_unless_trusted_require_approval", approval_policy: UnlessTrusted, sandbox_policy: workspace_write(false), action: ActionKind::RunCommand { command: r#"echo -g"*.py" '-{delete,print}'"#, }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], outcome: Outcome::ExecApproval { decision: ReviewDecision::denied("blocked in untrusted project"), expected_reason: None, }, expectation: Expectation::CommandFailure { output_contains: "blocked in untrusted project", }, }, ] } #[test_case(ScenarioGroup::DangerFullAccess ; "danger_full_access")] #[test_case(ScenarioGroup::ReadOnly ; "read_only")] #[test_case(ScenarioGroup::WorkspaceWrite ; "workspace_write")] #[test_case(ScenarioGroup::ApplyPatch ; "apply_patch")] #[test_case(ScenarioGroup::UnifiedExec ; "unified_exec")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn approval_matrix_covers_group(group: ScenarioGroup) -> Result<()> { run_scenario_group(group).await } async fn run_scenario_group(group: ScenarioGroup) -> Result<()> { skip_if_no_network!(Ok(())); let scenarios = scenarios() .into_iter() .filter(|scenario| scenario_group(scenario) == group) .collect::>(); assert!(!scenarios.is_empty(), "expected scenarios for {group:?}"); for scenario in scenarios { run_scenario(&scenario) .await .with_context(|| format!("approval scenario failed: {}", scenario.name))?; } Ok(()) } fn scenario_group(scenario: &ScenarioSpec) -> ScenarioGroup { match &scenario.action { ActionKind::ApplyPatchFreeform { .. } | ActionKind::ApplyPatchShell { .. } => { ScenarioGroup::ApplyPatch } ActionKind::RunUnifiedExecCommand { .. } => ScenarioGroup::UnifiedExec, ActionKind::WriteFile { .. } | ActionKind::FetchUrlNoProxy { .. } | ActionKind::FetchUrl { .. } | ActionKind::RunCommand { .. } | ActionKind::RunCommandWithPolicy { .. } | ActionKind::RunCommandWithPrefixRule { .. } => match &scenario.sandbox_policy { SandboxPolicy::DangerFullAccess => ScenarioGroup::DangerFullAccess, SandboxPolicy::ReadOnly { .. } => ScenarioGroup::ReadOnly, SandboxPolicy::WorkspaceWrite { .. } => ScenarioGroup::WorkspaceWrite, SandboxPolicy::ExternalSandbox { .. } => ScenarioGroup::WorkspaceWrite, }, } } async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> { eprintln!("running approval scenario: {}", scenario.name); let server = start_mock_server().await; let approval_policy = scenario.approval_policy; let sandbox_policy = scenario.sandbox_policy.clone(); let features = scenario.features.clone(); let policy_src = scenario.action.policy_src(); let thread_store_id = format!("approval-scenario-{}", scenario.name); let mut builder = test_codex() .with_model("gpt-5.5") .with_config(move |config| { // These scenarios assert tool behavior, not rollout persistence. config.experimental_thread_store = ThreadStoreConfig::InMemory { id: thread_store_id, }; config.permissions.approval_policy = Constrained::allow_any(approval_policy); config .set_legacy_sandbox_policy(sandbox_policy.clone()) .expect("set sandbox policy"); for feature in features { config .features .enable(feature) .expect("test config should allow feature update"); } }); if let Some(policy_src) = policy_src { builder = builder.with_pre_build_hook(move |home| { let rules_dir = home.join("rules"); fs::create_dir_all(&rules_dir).expect("create rules dir"); fs::write(rules_dir.join("default.rules"), policy_src).expect("write policy"); }); } let test = builder.build(&server).await?; let call_id = scenario.name; let (event, expected_command) = scenario .action .prepare(&test, &server, call_id, scenario.sandbox_permissions) .await?; if let Some(command) = expected_command.as_deref() { eprintln!("approval scenario {} command: {command}", scenario.name); } let _ = mount_sse_once( &server, sse(vec![ ev_response_created("resp-1"), event, ev_completed("resp-1"), ]), ) .await; let results_mock = mount_sse_once( &server, sse(vec![ ev_assistant_message("msg-1", "done"), ev_completed("resp-2"), ]), ) .await; submit_turn( &test, scenario.name, scenario.approval_policy, scenario.sandbox_policy.clone(), ) .await?; match &scenario.outcome { Outcome::Auto => { wait_for_completion_without_approval(&test).await; } Outcome::ExecApproval { decision, expected_reason, } => { let command = expected_command .as_deref() .expect("exec approval requires shell command"); let approval = expect_exec_approval(&test, command).await; if let Some(expected_reason) = expected_reason { assert_eq!( approval.reason.as_deref(), Some(*expected_reason), "unexpected approval reason for {}", scenario.name ); } test.codex .submit(Op::ExecApproval { id: approval.effective_approval_id(), turn_id: None, decision: decision.clone(), }) .await?; wait_for_completion(&test).await; } Outcome::ExecApprovalWithAmendment { decision, expected_reason, expected_execpolicy_amendment, } => { let command = expected_command .as_deref() .expect("exec approval requires shell command"); let approval = expect_exec_approval(&test, command).await; if let Some(expected_reason) = expected_reason { assert_eq!( approval.reason.as_deref(), Some(*expected_reason), "unexpected approval reason for {}", scenario.name ); } let expected_execpolicy_amendment = expected_execpolicy_amendment.as_ref().map(|expected| { ExecPolicyAmendment::new(match expected { ExpectedExecPolicyAmendment::Prefix(command) => { command.iter().map(|part| (*part).to_string()).collect() } ExpectedExecPolicyAmendment::FullCommand => approval.command.clone(), }) }); assert_eq!( approval.proposed_execpolicy_amendment, expected_execpolicy_amendment, "unexpected execpolicy amendment for {}", scenario.name ); test.codex .submit(Op::ExecApproval { id: approval.effective_approval_id(), turn_id: None, decision: decision.clone(), }) .await?; wait_for_completion(&test).await; } Outcome::PatchApproval { decision, expected_reason, } => { let approval = expect_patch_approval(&test, call_id).await; if let Some(expected_reason) = expected_reason { assert_eq!( approval.reason.as_deref(), Some(*expected_reason), "unexpected patch approval reason for {}", scenario.name ); } test.codex .submit(Op::PatchApproval { id: approval.call_id, decision: decision.clone(), }) .await?; wait_for_completion(&test).await; } } let output_request = results_mock.single_request(); let output_item = if matches!(scenario.action, ActionKind::ApplyPatchFreeform { .. }) { output_request.custom_tool_call_output(call_id) } else { output_request.function_call_output(call_id) }; let result = parse_result(&output_item); eprintln!( "approval scenario {} result: exit_code={:?} stdout={:?}", scenario.name, result.exit_code, result.stdout ); let verification_result = scenario.expectation.verify(&test, &result); test.codex.shutdown_and_wait().await?; verification_result } #[tokio::test(flavor = "current_thread")] #[cfg(unix)] async fn approving_apply_patch_for_session_skips_future_prompts_for_same_file() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; let approval_policy = AskForApproval::UnlessTrusted; let sandbox_policy = SandboxPolicy::DangerFullAccess; let sandbox_policy_for_config = sandbox_policy.clone(); let mut builder = test_codex() .with_model("gpt-5.5") .with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config .set_legacy_sandbox_policy(sandbox_policy_for_config) .expect("set sandbox policy"); config.approvals_reviewer = ApprovalsReviewer::User; }); let test = builder.build(&server).await?; let target = TargetPath::OutsideWorkspace("apply_patch_allow_session.txt"); let (path, patch_path) = target.resolve_for_patch(&test); let _path_cleanup = tempfile::TempPath::try_from_path(path.clone())?; let _ = fs::remove_file(&path); let patch_add = build_add_file_patch(&patch_path, "before"); let patch_update = format!( "*** Begin Patch\n*** Update File: {patch_path}\n@@\n-before\n+after\n*** End Patch\n" ); let call_id_1 = "apply_patch_allow_session_1"; let call_id_2 = "apply_patch_allow_session_2"; let _ = mount_sse_once( &server, sse(vec![ ev_response_created("resp-1"), ev_apply_patch_custom_tool_call(call_id_1, &patch_add), ev_completed("resp-1"), ]), ) .await; let _ = mount_sse_once( &server, sse(vec![ ev_assistant_message("msg-1", "done"), ev_completed("resp-2"), ]), ) .await; submit_turn( &test, "apply_patch allow session", approval_policy, sandbox_policy.clone(), ) .await?; let approval = expect_patch_approval(&test, call_id_1).await; test.codex .submit(Op::PatchApproval { id: approval.call_id, decision: ReviewDecision::ApprovedForSession, }) .await?; wait_for_completion(&test).await; assert!(fs::read_to_string(&path)?.contains("before")); let _ = mount_sse_once( &server, sse(vec![ ev_response_created("resp-3"), ev_apply_patch_custom_tool_call(call_id_2, &patch_update), ev_completed("resp-3"), ]), ) .await; let _ = mount_sse_once( &server, sse(vec![ ev_assistant_message("msg-2", "done"), ev_completed("resp-4"), ]), ) .await; submit_turn( &test, "apply_patch allow session followup", approval_policy, sandbox_policy.clone(), ) .await?; let event = wait_for_event(&test.codex, |event| { matches!( event, EventMsg::ApplyPatchApprovalRequest(_) | EventMsg::TurnComplete(_) ) }) .await; match event { EventMsg::TurnComplete(_) => {} EventMsg::ApplyPatchApprovalRequest(event) => { panic!("unexpected patch approval request: {:?}", event.call_id) } other => panic!("unexpected event: {other:?}"), } assert!(fs::read_to_string(&path)?.contains("after")); let _ = fs::remove_file(path); Ok(()) } #[cfg(unix)] #[test_case(true, None; "absent_heading_preserves_original_notice")] #[test_case(true, Some("Permission rule remembered"); "catalog_notice_with_full_permissions")] #[test_case(false, Some("Permission rule remembered"); "catalog_notice_with_permissions_omitted")] #[test_case(false, None; "bundled_notice_with_permissions_omitted")] #[tokio::test(flavor = "current_thread")] async fn approving_execpolicy_amendment_emits_one_saved_prefix_notice( include_permissions_instructions: bool, catalog_heading: Option<&'static str>, ) -> Result<()> { use codex_protocol::openai_models::ApprovalMessages; use codex_protocol::openai_models::ModelMessages; use codex_protocol::openai_models::PermissionMessages; let full_approval_override = catalog_heading.is_some(); let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; let sandbox_policy = SandboxPolicy::new_read_only_policy(); let sandbox_policy_for_config = sandbox_policy.clone(); let mut builder = test_codex() .with_model_info_override("gpt-5.4", move |model_info| { model_info.use_responses_lite = true; model_info.model_messages = Some(ModelMessages { permissions: Some(PermissionMessages { approved_command_prefix_saved: catalog_heading.map(str::to_owned), ..Default::default() }), approvals: full_approval_override.then(|| ApprovalMessages { on_request: Some("Full catalog approval message".to_string()), ..Default::default() }), ..Default::default() }); }) .with_config(move |config| { config.include_permissions_instructions = include_permissions_instructions; config.permissions.approval_policy = Constrained::allow_any(approval_policy); config .features .disable(Feature::DeferredExecutor) .expect("test config should allow feature update"); config .set_legacy_sandbox_policy(sandbox_policy_for_config) .expect("set sandbox policy"); }); let test = builder.build(&server).await?; let call_id = "permissions-prefix"; let (event, expected_command) = ActionKind::RunCommand { command: "touch permissions-prefix.txt", } .prepare( &test, &server, call_id, SandboxPermissions::RequireEscalated, ) .await?; let expected_command = expected_command.expect("execpolicy amendment scenario should produce a shell command"); let expected_execpolicy_amendment = ExecPolicyAmendment::new(vec![ "touch".to_string(), "permissions-prefix.txt".to_string(), ]); let _ = mount_sse_once( &server, sse(vec![ ev_response_created("resp-permissions-prefix-1"), event, ev_completed("resp-permissions-prefix-1"), ]), ) .await; let follow_up = mount_sse_once( &server, sse(vec![ ev_assistant_message("msg-permissions-prefix", "done"), ev_completed("resp-permissions-prefix-2"), ]), ) .await; submit_turn(&test, "permissions-prefix", approval_policy, sandbox_policy).await?; let approval = expect_exec_approval(&test, expected_command.as_str()).await; assert_eq!( approval.proposed_execpolicy_amendment, Some(expected_execpolicy_amendment.clone()) ); test.codex .submit(Op::ExecApproval { id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::ApprovedExecpolicyAmendment { proposed_execpolicy_amendment: expected_execpolicy_amendment, }, }) .await?; wait_for_completion(&test).await; let request = follow_up.single_request(); let developer_messages = request.message_input_texts("developer"); let permissions = developer_messages .iter() .filter(|text| text.contains("")) .collect::>(); assert_eq!( permissions.len(), usize::from(include_permissions_instructions) ); if include_permissions_instructions && full_approval_override { assert!(permissions[0].contains("Full catalog approval message")); } let expected_heading = match catalog_heading { Some(_) => "Approved command prefix saved:\nPermission rule remembered", None => "Approved command prefix saved:", }; let expected_text = format!("{expected_heading}\n- [\"touch\", \"permissions-prefix.txt\"]"); let mut notices = Vec::new(); for item in request.inputs_of_type("message") { let annotations = &item["internal_chat_message_metadata_passthrough"]; let Some(kinds) = annotations["content_item_kinds"].as_array() else { continue; }; for (index, kind) in kinds.iter().enumerate() { if kind != "permissions.approved_command_prefix_saved" { continue; } assert_eq!(item["role"], "developer"); assert_eq!( annotations["content_item_metadata"][index], json!({"provenance": {"type": "harness", "hook": false}}), ); notices.push(item["content"][index]["text"].clone()); } } assert_eq!(notices, vec![json!(expected_text)]); assert!(test.cwd.path().join("permissions-prefix.txt").is_file()); Ok(()) } #[tokio::test(flavor = "current_thread")] #[cfg(unix)] async fn approving_execpolicy_amendment_persists_policy_and_skips_future_prompts() -> Result<()> { let server = start_mock_server().await; let approval_policy = AskForApproval::UnlessTrusted; let sandbox_policy = SandboxPolicy::new_workspace_write_policy(); let sandbox_policy_for_config = sandbox_policy.clone(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config .set_legacy_sandbox_policy(sandbox_policy_for_config) .expect("set sandbox policy"); }); let test = builder.build(&server).await?; let allow_prefix_path = test.cwd.path().join("allow-prefix.txt"); let _ = fs::remove_file(&allow_prefix_path); let call_id_first = "allow-prefix-first"; let (first_event, expected_command) = ActionKind::RunCommand { command: "touch allow-prefix.txt", } .prepare( &test, &server, call_id_first, SandboxPermissions::UseDefault, ) .await?; let expected_command = expected_command.expect("execpolicy amendment scenario should produce a shell command"); let expected_execpolicy_amendment = ExecPolicyAmendment::new(vec!["touch".to_string(), "allow-prefix.txt".to_string()]); let _ = mount_sse_once( &server, sse(vec![ ev_response_created("resp-allow-prefix-1"), first_event, ev_completed("resp-allow-prefix-1"), ]), ) .await; let first_results = mount_sse_once( &server, sse(vec![ ev_assistant_message("msg-allow-prefix-1", "done"), ev_completed("resp-allow-prefix-2"), ]), ) .await; submit_turn( &test, "allow-prefix-first", approval_policy, sandbox_policy.clone(), ) .await?; let approval = expect_exec_approval(&test, expected_command.as_str()).await; assert_eq!( approval.proposed_execpolicy_amendment, Some(expected_execpolicy_amendment.clone()) ); test.codex .submit(Op::ExecApproval { id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::ApprovedExecpolicyAmendment { proposed_execpolicy_amendment: expected_execpolicy_amendment.clone(), }, }) .await?; wait_for_completion(&test).await; let developer_messages = first_results .single_request() .message_input_texts("developer"); assert!( developer_messages .iter() .any(|message| message.contains(r#"["touch", "allow-prefix.txt"]"#)), "expected developer message documenting saved rule, got: {developer_messages:?}" ); let policy_path = test.home.path().join("rules").join("default.rules"); let policy_contents = fs::read_to_string(&policy_path)?; assert!( policy_contents .contains(r#"prefix_rule(pattern=["touch", "allow-prefix.txt"], decision="allow")"#), "unexpected policy contents: {policy_contents}" ); let first_output = parse_result( &first_results .single_request() .function_call_output(call_id_first), ); assert_eq!(first_output.exit_code.unwrap_or(0), 0); assert!( first_output.stdout.is_empty(), "unexpected stdout: {}", first_output.stdout ); assert_eq!( fs::read_to_string(&allow_prefix_path)?, "", "unexpected file contents after first run" ); let call_id_second = "allow-prefix-second"; let (second_event, second_command) = ActionKind::RunCommand { command: "touch allow-prefix.txt", } .prepare( &test, &server, call_id_second, SandboxPermissions::UseDefault, ) .await?; assert_eq!(second_command.as_deref(), Some(expected_command.as_str())); let _ = mount_sse_once( &server, sse(vec![ ev_response_created("resp-allow-prefix-3"), second_event, ev_completed("resp-allow-prefix-3"), ]), ) .await; let second_results = mount_sse_once( &server, sse(vec![ ev_assistant_message("msg-allow-prefix-2", "done"), ev_completed("resp-allow-prefix-4"), ]), ) .await; submit_turn( &test, "allow-prefix-second", approval_policy, sandbox_policy.clone(), ) .await?; wait_for_completion_without_approval(&test).await; let second_output = parse_result( &second_results .single_request() .function_call_output(call_id_second), ); assert_eq!(second_output.exit_code.unwrap_or(0), 0); assert!( second_output.stdout.is_empty(), "unexpected stdout: {}", second_output.stdout ); assert_eq!( fs::read_to_string(&allow_prefix_path)?, "", "unexpected file contents after second run" ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn spawned_subagent_execpolicy_amendment_propagates_to_parent_session() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; let approval_policy = AskForApproval::UnlessTrusted; let sandbox_policy = SandboxPolicy::new_workspace_write_policy(); let sandbox_policy_for_config = sandbox_policy.clone(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config .set_legacy_sandbox_policy(sandbox_policy_for_config) .expect("set sandbox policy"); config .features .enable(Feature::Collab) .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; const PARENT_PROMPT: &str = "spawn a child that repeats a command"; const CHILD_PROMPT: &str = "run the same command twice"; const SPAWN_CALL_ID: &str = "spawn-child-1"; const CHILD_CALL_ID_1: &str = "child-touch-1"; const PARENT_CALL_ID_2: &str = "parent-touch-2"; let child_file = test.cwd.path().join("subagent-allow-prefix.txt"); let _ = fs::remove_file(&child_file); let spawn_args = serde_json::to_string(&json!({ "message": CHILD_PROMPT, }))?; mount_sse_once_match( &server, |req: &Request| body_contains(req, PARENT_PROMPT), sse(vec![ ev_response_created("resp-parent-1"), ev_function_call_with_namespace( SPAWN_CALL_ID, "multi_agent_v1", "spawn_agent", &spawn_args, ), ev_completed("resp-parent-1"), ]), ) .await; let child_cmd_args = serde_json::to_string(&json!({ "cmd": "touch subagent-allow-prefix.txt", "yield_time_ms": 10_000, "prefix_rule": ["touch", "subagent-allow-prefix.txt"], }))?; mount_sse_once_match( &server, |req: &Request| body_contains(req, CHILD_PROMPT) && !body_contains(req, SPAWN_CALL_ID), sse(vec![ ev_response_created("resp-child-1"), ev_function_call(CHILD_CALL_ID_1, "exec_command", &child_cmd_args), ev_completed("resp-child-1"), ]), ) .await; mount_sse_once_match( &server, |req: &Request| body_contains(req, CHILD_CALL_ID_1), sse(vec![ ev_response_created("resp-child-2"), ev_assistant_message("msg-child-2", "child done"), ev_completed("resp-child-2"), ]), ) .await; mount_sse_once_match( &server, |req: &Request| body_contains(req, SPAWN_CALL_ID), sse(vec![ ev_response_created("resp-parent-2"), ev_assistant_message("msg-parent-2", "parent done"), ev_completed("resp-parent-2"), ]), ) .await; let _ = mount_sse_once( &server, sse(vec![ ev_response_created("resp-parent-3"), ev_function_call(PARENT_CALL_ID_2, "exec_command", &child_cmd_args), ev_completed("resp-parent-3"), ]), ) .await; let _ = mount_sse_once( &server, sse(vec![ ev_response_created("resp-parent-4"), ev_assistant_message("msg-parent-4", "parent rerun done"), ev_completed("resp-parent-4"), ]), ) .await; submit_turn( &test, PARENT_PROMPT, approval_policy, sandbox_policy.clone(), ) .await?; let child = wait_for_spawned_thread(&test).await?; let approval_event = wait_for_event_with_timeout( &child, |event| { matches!( event, EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) ) }, Duration::from_secs(2), ) .await; let EventMsg::ExecApprovalRequest(approval) = approval_event else { panic!("expected child approval before completion"); }; let expected_execpolicy_amendment = ExecPolicyAmendment::new(vec![ "touch".to_string(), "subagent-allow-prefix.txt".to_string(), ]); assert_eq!( approval.proposed_execpolicy_amendment, Some(expected_execpolicy_amendment.clone()) ); child .submit(Op::ExecApproval { id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::ApprovedExecpolicyAmendment { proposed_execpolicy_amendment: expected_execpolicy_amendment, }, }) .await?; let child_event = wait_for_event_with_timeout( &child, |event| { matches!( event, EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) ) }, Duration::from_secs(2), ) .await; match child_event { EventMsg::TurnComplete(_) => {} EventMsg::ExecApprovalRequest(ev) => { panic!("unexpected second child approval request: {:?}", ev.command) } other => panic!("unexpected event: {other:?}"), } assert!( child_file.exists(), "expected subagent command to create file" ); fs::remove_file(&child_file)?; assert!( !child_file.exists(), "expected child file to be removed before parent rerun" ); submit_turn( &test, "parent reruns child command", approval_policy, sandbox_policy, ) .await?; wait_for_completion_without_approval(&test).await; Ok(()) } #[cfg(unix)] #[test_case("direct", false, false; "explicit_zsh")] #[test_case("direct", true, false; "non_login_in_login_enabled_session")] #[test_case("direct", true, true; "login_startup")] #[test_case("filtered_startup", false, false; "filtered_global_startup")] #[test_case("startup_override", false, false; "global_startup_overrides")] #[test_case("additional_permissions", false, false; "filtered_host_additional_permissions")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn shell_startup_credentials_are_brokered( shell_mode: &'static str, allow_login_shell: bool, login: bool, ) -> Result<()> { use codex_otel::MetricsClient; use codex_otel::MetricsConfig; use opentelemetry_sdk::metrics::data::AggregatedMetrics; use opentelemetry_sdk::metrics::data::HistogramDataPoint; use opentelemetry_sdk::metrics::data::MetricData; use opentelemetry_sdk::metrics::data::SumDataPoint; use std::collections::BTreeMap; skip_if_no_network!(Ok(())); const GH_HOST: &str = "github.example.com"; const REAL_GITHUB_TOKEN: &str = "ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; const CUSTOM_HOST: &str = "api.custom.example"; const REAL_CUSTOM_TOKEN: &str = "custom_abcdefghijklmnopqrstuvwx"; let request_extra_permissions = shell_mode == "additional_permissions"; let approval_policy = if request_extra_permissions { AskForApproval::OnRequest } else { AskForApproval::Never }; let global_startup = matches!(shell_mode, "filtered_startup" | "startup_override"); let builder = { let Some(zsh) = codex_core::shell::get_shell(codex_core::shell::ShellType::Zsh) else { return Ok(()); }; test_codex() .with_user_shell(zsh) .with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config.features.enable(Feature::ShellTool).unwrap(); }) }; let outside_dir = tempfile::tempdir_in(std::env::current_dir()?)?; let outside_path = outside_dir.path().join("unsandboxed-snapshot-write"); let outside_path_arg = shlex::try_join([outside_path.to_string_lossy().as_ref()])?; let home = Arc::new(TempDir::new()?); let startup_dir = home.path().join("startup"); fs::create_dir(&startup_dir)?; let untrusted_snapshot_condition = r#"[[ "$ZSH_EXECUTION_STRING" == *"command env -0"* ]]"#; fs::write( startup_dir.join(".zshenv"), format!( "export GH_HOST='{GH_HOST}'\n\ if [[ ! -o login ]]; then export GH_ENTERPRISE_TOKEN='{REAL_GITHUB_TOKEN}'; fi\n\ export CUSTOM_API_KEY='{REAL_CUSTOM_TOKEN}'\n\ export CUSTOM_HOST='{CUSTOM_HOST}'\n\ export APP_SETTING EXCLUDED_SETTING\n\ export AUTH_HEADER=\"Bearer $GH_ENTERPRISE_TOKEN\"\n\ export BROKERED_STARTUP_CWD=\"$PWD\"\n\ if {untrusted_snapshot_condition}; then\n\ print -r -- escaped > {outside_path_arg} 2>/dev/null || true\n\ fi\n\ function setopt() {{\n\ builtin setopt \"$@\"\n\ if {untrusted_snapshot_condition}; then\n\ print -r -- escaped > {outside_path_arg} 2>/dev/null || true\n\ fi\n\ }}\n" ), )?; fs::write( startup_dir.join(".zshrc"), format!( "export LOGIN_SNAPSHOT_READY=ready\nexport GH_ENTERPRISE_TOKEN='{REAL_GITHUB_TOKEN}'\n\ export AUTH_HEADER=\"Bearer $GH_ENTERPRISE_TOKEN\"\n" ), )?; fs::write( startup_dir.join(".zprofile"), "export LOGIN_SHELL_READY=ready\n", )?; let startup_shell = if global_startup { let Some(zsh) = codex_core::shell::get_shell(codex_core::shell::ShellType::Zsh) else { return Ok(()); }; let zsh_path = zsh.derive_exec_args("", /*use_login_shell*/ false)[0].clone(); let path = startup_dir.join("zsh"); codex_utils_cargo_bin::write_executable( &path, &format!( "#!/bin/sh\nexport GH_ENTERPRISE_TOKEN='{REAL_GITHUB_TOKEN}'\nexport CUSTOM_API_KEY='{REAL_CUSTOM_TOKEN}'\nexport AUTH_HEADER=\"Bearer $GH_ENTERPRISE_TOKEN\"\nexec {} \"$@\"\n", shlex::try_quote(&zsh_path)? ), )?; fs::set_permissions(&path, fs::Permissions::from_mode(/*mode*/ 0o700))?; Some(path) } else { None }; fs::write( home.path().join("config.toml"), format!( r#"default_permissions = "brokered" permissions = {{ brokered = {{ extends = ":workspace", network = {{ enabled = true, allow_local_binding = true }} }} }} [features.network_proxy] enabled = true credential_broker = true [features.network_proxy.credentials.custom] env = ["CUSTOM_API_KEY"] patterns = ["custom_[a-z]{{24}}"] url_prefix_from_env = "CUSTOM_HOST" [shell_environment_policy.set] ZDOTDIR = "{}" "#, startup_dir.display() ), )?; let mut builder = builder .with_home(home) .with_cloud_config_bundle(managed_network_requirements_loader()); if !allow_login_shell { let Some(bash) = codex_core::shell::get_shell(codex_core::shell::ShellType::Bash) else { return Ok(()); }; builder = builder.with_user_shell(bash); } let mut builder = builder.with_config(move |config| { config.permissions.allow_login_shell = allow_login_shell; config.permissions.shell_environment_policy.exclude.push( EnvironmentVariablePattern::new_case_insensitive("EXCLUDED_SETTING"), ); if request_extra_permissions { config .features .enable(Feature::ExecPermissionApprovals) .unwrap(); config .features .enable(Feature::RequestPermissionsTool) .unwrap(); config.permissions.shell_environment_policy.exclude.push( EnvironmentVariablePattern::new_case_insensitive("CUSTOM_HOST"), ); } else if shell_mode == "filtered_startup" { config.permissions.shell_environment_policy.exclude.extend([ EnvironmentVariablePattern::new_case_insensitive("GH_ENTERPRISE_TOKEN"), EnvironmentVariablePattern::new_case_insensitive("CUSTOM_API_KEY"), ]); } else if shell_mode == "startup_override" { config.permissions.shell_environment_policy.r#set.extend([ ("GH_ENTERPRISE_TOKEN".to_string(), String::new()), ("CUSTOM_API_KEY".to_string(), String::new()), ("AUTH_HEADER".to_string(), "enterprise-header".to_string()), ]); } config .features .enable(Feature::ShellSnapshot) .expect("test config should allow ShellSnapshot override"); }); let server = start_mock_server().await; let mut test = builder.build(&server).await?; let metrics = if login { // Keep the collector local to this thread so parallel tests cannot affect counts. let metrics = MetricsClient::new( MetricsConfig::in_memory("test", "test", "1", Default::default()).with_runtime_reader(), )?; let mut options = test.start_thread_options().await; options.thread_extension_init.insert(metrics.clone()); let thread = test.thread_manager.start_thread(options).await?; test.codex.shutdown_and_wait().await?; test.codex = thread.thread; test.session_configured = thread.session_configured; Some(metrics) } else { None }; let snapshot_dir = test.home.path().join("shell_snapshots"); let workdir = if allow_login_shell && !login { test.cwd.path().to_path_buf() } else { let workdir = test.cwd.path().join("brokered-subdirectory"); fs::create_dir(&workdir)?; workdir }; let call_id = "shell-brokered-github-credential"; let command = r#"printf '%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n' "$GH_HOST" "$GH_ENTERPRISE_TOKEN" "$CUSTOM_HOST" "$CUSTOM_API_KEY" "$CODEX_NETWORK_PROXY_CREDENTIAL_BROKER_ACTIVE" "$PWD" "$BROKERED_STARTUP_CWD" "${LOGIN_SNAPSHOT_READY-unset}" "$AUTH_HEADER""#; let command = format!( "APP_SETTING=production; EXCLUDED_SETTING=denied; [ \"$(/usr/bin/printenv APP_SETTING)\" = production ] || exit 1; if /usr/bin/printenv EXCLUDED_SETTING >/dev/null; then exit 1; fi; {command}" ); let command = if request_extra_permissions { let script = format!("{command}\nprintf escaped 2>/dev/null > {outside_path_arg} || true"); format!("/bin/sh -c {}", shlex::try_quote(&script)?) } else { command.to_string() }; let snapshot_dir_arg = shlex::try_join([snapshot_dir.to_string_lossy().as_ref()])?; let command = format!("{command}; /bin/cat {snapshot_dir_arg}/*.sh > captured-snapshot"); let Some(zsh) = codex_core::shell::get_shell(codex_core::shell::ShellType::Zsh) else { return Ok(()); }; let mut arguments = json!({ "cmd": command, "shell": startup_shell.map_or_else( || zsh.derive_exec_args("", /*use_login_shell*/ false)[0].clone(), |path| path.display().to_string(), ), "workdir": workdir, "login": login, "yield_time_ms": 10_000, }); if request_extra_permissions { let approved_dir = test.home.path().join("approved-command"); fs::create_dir(&approved_dir)?; arguments["sandbox_permissions"] = json!(SandboxPermissions::WithAdditionalPermissions); arguments["additional_permissions"] = json!({"file_system": {"write": [approved_dir]}}); } let event = ev_function_call(call_id, "exec_command", &serde_json::to_string(&arguments)?); let responses = mount_sse_sequence( &server, vec![ sse(vec![ ev_response_created("resp-shell-broker-1"), event, ev_completed("resp-shell-broker-1"), ]), sse(vec![ ev_assistant_message("msg-shell-broker", "done"), ev_completed("resp-shell-broker-2"), ]), ], ) .await; submit_turn_preserving_active_permission_profile( &test, "show the brokered GitHub environment", approval_policy, ) .await?; if request_extra_permissions { let mut approvals = 0; loop { let event = wait_for_event(&test.codex, |event| { matches!( event, EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) ) }) .await; let EventMsg::ExecApprovalRequest(approval) = event else { break; }; approvals += 1; test.codex .submit(Op::ExecApproval { id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::Approved, }) .await?; } assert!(approvals > 0, "additional permissions must be approved"); } else { wait_for_completion_without_approval(&test).await; } let output = responses.requests()[1].function_call_output(call_id); let result = parse_result(&output); assert_eq!(result.exit_code, Some(0), "command failed: {result:?}"); if let Some(metrics) = metrics { let snapshot = metrics.snapshot()?; let mut counts = BTreeMap::new(); for metric in snapshot .scope_metrics() .flat_map(opentelemetry_sdk::metrics::data::ScopeMetrics::metrics) { let count = match (metric.name(), metric.data()) { ("codex.shell_snapshot.command", AggregatedMetrics::U64(MetricData::Sum(sum))) => { for point in sum.data_points() { let tags = point .attributes() .map(|tag| (tag.key.as_str(), tag.value.to_string())) .collect::>(); assert_eq!( ["version", "state", "outcome"] .map(|key| tags.get(key).map(String::as_str)), [Some("v1"), Some("protected"), Some("used")], ); } sum.data_points().map(SumDataPoint::value).sum::() } ( "codex.shell_snapshot.wait_ms", AggregatedMetrics::F64(MetricData::Histogram(histogram)), ) => histogram.data_points().map(HistogramDataPoint::count).sum(), _ => continue, }; counts.insert(metric.name(), count); } assert_eq!( counts, BTreeMap::from([ ("codex.shell_snapshot.command", 1), ("codex.shell_snapshot.wait_ms", 1), ]), ); } assert!(!result.stdout.contains(REAL_GITHUB_TOKEN)); assert!(!result.stdout.contains(REAL_CUSTOM_TOKEN)); let values = result.stdout.lines().collect::>(); assert_eq!(values.len(), 9, "unexpected command output: {result:?}"); assert_eq!(values[0], GH_HOST); assert_ne!(values[1], REAL_GITHUB_TOKEN); if global_startup { assert_eq!(values[1], ""); assert_eq!(values[3], ""); assert_eq!( values[8], if shell_mode == "startup_override" { "enterprise-header" } else { "" } ); } else { assert!(values[1].starts_with("ghp_")); assert_eq!(values[1].len(), REAL_GITHUB_TOKEN.len()); assert!(values[3].starts_with("custom_")); assert_eq!(values[3].len(), REAL_CUSTOM_TOKEN.len()); assert_eq!(values[8], format!("Bearer {}", values[1])); } assert_eq!( values[2], if request_extra_permissions { "" } else { CUSTOM_HOST } ); assert_ne!(values[3], REAL_CUSTOM_TOKEN); assert_eq!(values[4], "1"); assert_eq!(PathBuf::from(values[5]), fs::canonicalize(&workdir)?); assert_eq!(PathBuf::from(values[6]), fs::canonicalize(&workdir)?); assert_eq!(values[7], if login { "ready" } else { "unset" }); assert!( !outside_path.exists(), "shell snapshot startup wrote outside the command sandbox" ); let snapshot = fs::read_to_string(workdir.join("captured-snapshot"))?; assert!(snapshot.contains("# Snapshot file")); assert!( !snapshot.contains(REAL_GITHUB_TOKEN), "shell snapshot persisted a real GitHub credential" ); assert!( !snapshot.contains(REAL_CUSTOM_TOKEN), "shell snapshot persisted a real configured credential" ); Ok(()) } #[cfg(unix)] #[test_case("escalated")] #[test_case("disabled")] #[test_case("failed")] #[test_case("managed_disabled")] #[test_case("managed_failed")] #[test_case("heredoc")] #[test_case("enabled")] #[test_case("flat_feature")] #[test_case("flat_profile_transition")] #[test_case("flat_yolo")] #[test_case("flat_yolo_profile_transition")] #[test_case("flat_yolo_unnamed_transition")] #[test_case("flat_yolo_legacy_transition")] #[test_case("flat_managed")] #[test_case("flat_no_proxy")] #[test_case("flat_disabled_proxy")] #[test_case("flat_policy_without_proxy")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn brokered_shell_snapshot_fallback(mode: &'static str) -> Result<()> { skip_if_no_network!(Ok(())); let snapshot_disabled = matches!(mode, "disabled" | "managed_disabled"); let snapshot_failed = matches!(mode, "failed" | "managed_failed"); let snapshot_unavailable = snapshot_disabled || snapshot_failed; const REAL_GITHUB_TOKEN: &str = "ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; const REAL_CUSTOM_TOKEN: &str = "custom_abcdefghijklmnopqrstuvwx"; let Some(bash) = codex_core::shell::get_shell(codex_core::shell::ShellType::Bash) else { return Ok(()); }; let startup_dir = tempfile::tempdir_in(std::env::current_dir()?)?; let startup_path = startup_dir.path().join("startup.sh"); let observed_path = startup_dir.path().join("startup-credentials"); let observed_path_arg = shlex::try_join([observed_path.to_string_lossy().as_ref()])?; let outside_path = startup_dir.path().join("outside-write"); fs::write( &startup_path, if mode == "escalated" { format!("printf '%s\\n' \"${{GH_TOKEN-}}\" >> {observed_path_arg}\n") } else if mode == "heredoc" { "brokered_heredoc() {\ncat < "", "flat_feature" | "flat_profile_transition" | "flat_disabled_proxy" | "flat_yolo" | "flat_yolo_profile_transition" | "flat_yolo_unnamed_transition" | "flat_yolo_legacy_transition" => "credential_masking = true, network_proxy = true", "flat_managed" => concat!( "credential_masking = true, network_proxy = { enabled = false, ", "credentials = { custom = { env = [\"CUSTOM_API_KEY\"], ", "patterns = [\"custom_[a-z]{24}\"], ", "url_prefixes = [\"https://custom.example.test\"] } } }", ), "flat_no_proxy" | "flat_policy_without_proxy" => "credential_masking = true", _ => "network_proxy = { enabled = true, credential_broker = true }", }; let network_enabled = !matches!(mode, "flat_no_proxy" | "flat_policy_without_proxy"); let cloud_config_bundle = match mode { "disabled" | "failed" | "managed_disabled" | "managed_failed" => { let feature_requirements = if mode.starts_with("managed_") { format!( "[features]\ncredential_masking = true\nshell_snapshot = {}\n", !snapshot_disabled, ) } else { String::new() }; CloudConfigBundleFixture::loader_with_enterprise_requirement(format!( "{feature_requirements}\n[experimental_network]\nenabled = true\nallow_local_binding = true\n\ [experimental_network.domains]\n\"credential-masking-denied.invalid\" = \"deny\"\n" )) } "flat_no_proxy" | "flat_profile_transition" => { CloudConfigBundleFixture::loader_with_enterprise_requirement("") } "flat_disabled_proxy" => CloudConfigBundleFixture::loader_with_enterprise_requirement( "[experimental_network]\nenabled = false", ), "flat_policy_without_proxy" => { CloudConfigBundleFixture::loader_with_enterprise_requirement( "[experimental_network]\nallow_local_binding = true", ) } _ => managed_network_requirements_loader(), }; let home = Arc::new(TempDir::new()?); let default_permissions = if mode == "flat_yolo" { ":danger-full-access" } else { "brokered" }; fs::write( home.path().join("config.toml"), format!( r#"default_permissions = "{default_permissions}" features = {{ {features} }} permissions = {{ brokered = {{ extends = ":workspace", network = {{ enabled = {network_enabled}, allow_local_binding = true }} }} }} [shell_environment_policy.set] BASH_ENV = "{}" GH_TOKEN = "{REAL_GITHUB_TOKEN}" "#, startup_path.display() ), )?; if snapshot_failed { fs::write(home.path().join("shell_snapshots"), "not a directory")?; } if mode == "escalated" { let rules_dir = home.path().join("rules"); fs::create_dir_all(&rules_dir)?; fs::write( rules_dir.join("default.rules"), r#"prefix_rule(pattern=["printenv", "GH_TOKEN"], decision="allow")"#, )?; } let approval_policy = if mode == "escalated" { AskForApproval::OnRequest } else { AskForApproval::Never }; let server = start_mock_server().await; let mut builder = test_codex() .with_home(home) .with_user_shell(bash) .with_cloud_config_bundle(cloud_config_bundle) .with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); if mode == "flat_managed" { config .features .enable(Feature::ShellSnapshotV2) .expect("test config should allow executor-owned shell snapshots"); } if snapshot_disabled { config.features.disable(Feature::ShellSnapshot) } else { config.features.enable(Feature::ShellSnapshot) } .expect("test config should allow ShellSnapshot override"); }); let test = builder.build(&server).await?; if mode.starts_with("flat_yolo") { assert!( test.config.permissions.network.is_some(), "managed requirements retain the configured proxy in YOLO mode" ); assert_eq!( test.config .startup_warnings .iter() .any(|warning| warning.starts_with("Credential masking is inactive")), mode == "flat_yolo", ); if mode != "flat_yolo" { test.codex .update_thread_settings(ThreadSettingsOverrides { permission_profile: (mode != "flat_yolo_legacy_transition") .then_some(PermissionProfile::Disabled), active_permission_profile: (mode == "flat_yolo_profile_transition").then_some( ActivePermissionProfile { id: ":danger-full-access".to_string(), extends: None, }, ), sandbox_policy: (mode == "flat_yolo_legacy_transition") .then_some(SandboxPolicy::DangerFullAccess), ..Default::default() }) .await?; } let warning = wait_for_event(&test.codex, |event| { matches!(event, EventMsg::Warning(warning) if warning.message.starts_with("Credential masking is inactive")) }) .await; let EventMsg::Warning(warning) = warning else { panic!("expected credential masking warning"); }; insta::allow_duplicates! { insta::assert_snapshot!(warning.message, @r"Credential masking is inactive because sandboxing is disabled. Commands will receive unmasked credentials. Select a sandboxed permission profile to use credential masking."); } } if mode == "flat_profile_transition" { assert!( !test .config .startup_warnings .iter() .any(|warning| warning.starts_with("Credential masking is inactive")) ); test.codex .update_thread_settings(ThreadSettingsOverrides { permission_profile: Some(PermissionProfile::read_only()), active_permission_profile: Some(ActivePermissionProfile::read_only()), ..Default::default() }) .await?; } if matches!( mode, "flat_no_proxy" | "flat_disabled_proxy" | "flat_policy_without_proxy" | "flat_profile_transition" ) { let warning = wait_for_event(&test.codex, |event| { matches!(event, EventMsg::Warning(warning) if warning.message.starts_with("Credential masking is inactive")) }) .await; let EventMsg::Warning(warning) = warning else { panic!("expected credential masking warning"); }; insta::allow_duplicates! { insta::assert_snapshot!(warning.message, @r"Credential masking is inactive because the network proxy is disabled. Commands will receive unmasked credentials. Enable `features.network_proxy` or configure managed network requirements to use credential masking."); } } let call_id = "escalated-brokered-shell-startup"; let command = if snapshot_unavailable { let outside_path_arg = shlex::try_join([outside_path.to_string_lossy().as_ref()])?; format!( "corp_login && printf '%s\\n' \"${{CODEX_NETWORK_PROXY_CREDENTIAL_BROKER_ACTIVE-}}\" && \ test -n \"$HTTP_PROXY\" && printf 'proxy-active\\n' && \ if (printf escaped > {outside_path_arg}) 2>/dev/null; then exit 1; else printf 'sandbox-denied\\n'; fi" ) } else if mode == "heredoc" { let snapshot_dir = test.home.path().join("shell_snapshots"); let snapshot_dir = shlex::try_join([snapshot_dir.to_string_lossy().as_ref()])?; format!( "cat {snapshot_dir}/*.sh > captured-snapshot && brokered_heredoc && printf '%s\\n' \"$GH_TOKEN\"" ) } else if mode == "flat_managed" { "corp_login && printf '%s\\n' \"$CUSTOM_API_KEY\"".to_string() } else if matches!(mode, "enabled" | "flat_feature") { "corp_login".to_string() } else { "printenv GH_TOKEN".to_string() }; let event = shell_event( call_id, &command, /*timeout_ms*/ 5_000, if mode == "escalated" { SandboxPermissions::RequireEscalated } else { SandboxPermissions::UseDefault }, )?; let mut response_events = vec![ sse(vec![ ev_response_created("resp-escalated-broker-startup-1"), event, ev_completed("resp-escalated-broker-startup-1"), ]), sse(vec![ ev_assistant_message("msg-escalated-broker-startup", "done"), ev_completed("resp-escalated-broker-startup-2"), ]), ]; if snapshot_unavailable { response_events.insert(1, sse(vec![ ev_response_created("resp-brokered-network-denial"), shell_event( "brokered-network-denial", "python3 -c \"import os, urllib.request; \ urllib.request.build_opener(urllib.request.ProxyHandler({'http': os.environ['HTTP_PROXY']}))\ .open('http://credential-masking-denied.invalid', timeout=2)\"", /*timeout_ms*/ 5_000, SandboxPermissions::UseDefault, )?, ev_completed("resp-brokered-network-denial"), ])); } let responses = mount_sse_sequence(&server, response_events).await; submit_turn_preserving_active_permission_profile( &test, "run the allowlisted command with escalated permissions", approval_policy, ) .await?; if snapshot_unavailable { let warning = wait_for_event(&test.codex, |event| { matches!(event, EventMsg::Warning(_) | EventMsg::TurnComplete(_)) }) .await; let EventMsg::Warning(warning) = warning else { panic!("expected credential masking fallback warning, got {warning:?}"); }; insta::allow_duplicates! { insta::assert_snapshot!(warning.message, @r"Credential masking is unavailable because a protected shell snapshot could not be created. This command will run with unmasked credentials."); } } wait_for_completion_without_approval(&test).await; let output = responses.requests()[1].function_call_output(call_id); if snapshot_unavailable { assert!(test.session_configured.network_proxy.is_some()); assert_eq!( parse_result(&output), CommandResult { exit_code: Some(0), stdout: format!("west\n{REAL_GITHUB_TOKEN}\n\nproxy-active\nsandbox-denied\n"), }, ); assert!( !outside_path.exists(), "fallback bypassed the filesystem sandbox" ); let denied_output = responses.requests()[2] .function_call_output_text("brokered-network-denial") .context("expected managed network denial")?; assert!(denied_output.contains("credential-masking-denied.invalid")); assert!(denied_output.contains("domain is explicitly denied by policy")); if snapshot_failed { fs::remove_file(test.home.path().join("shell_snapshots"))?; let mut first_snapshot_paths = None; for call_id in ["brokered-startup-retry", "brokered-startup-reuse"] { let event = shell_event( call_id, "printenv GH_TOKEN", /*timeout_ms*/ 5_000, SandboxPermissions::UseDefault, )?; let retry = mount_sse_sequence( &server, vec![ sse(vec![ ev_response_created(call_id), event, ev_completed(call_id), ]), sse(vec![ ev_response_created("resp-brokered-startup-done"), ev_assistant_message("msg-brokered-startup-done", "done"), ev_completed("resp-brokered-startup-done"), ]), ], ) .await; submit_turn_preserving_active_permission_profile( &test, "run the command after repairing snapshot storage", approval_policy, ) .await?; wait_for_completion_without_approval(&test).await; let result = parse_result(&retry.requests()[1].function_call_output(call_id)); assert_eq!( result.exit_code, Some(0), "snapshot retry failed: {result:?}" ); assert!(result.stdout.starts_with("ghp_")); assert!(!result.stdout.contains(REAL_GITHUB_TOKEN)); let mut snapshot_paths = fs::read_dir(test.home.path().join("shell_snapshots"))? .flatten() .map(|entry| entry.path()) .filter(|path| path.extension().is_some_and(|extension| extension == "sh")) .collect::>(); snapshot_paths.sort(); assert_eq!(snapshot_paths.len(), 1); if let Some(first_snapshot_paths) = &first_snapshot_paths { assert_eq!(&snapshot_paths, first_snapshot_paths); } else { first_snapshot_paths = Some(snapshot_paths); } } } return Ok(()); } let result = parse_result(&output); if mode == "heredoc" { assert_eq!( result.exit_code, Some(0), "heredoc replay failed: {result:?}" ); assert!(!output.to_string().contains(REAL_GITHUB_TOKEN)); let dummy = result .stdout .lines() .last() .context("live dummy credential")?; assert!(dummy.starts_with("ghp_")); assert_eq!( result.stdout, format!( "# exports 1\nexport LEGACY_SETTING=production\nAuthorization: {dummy}\n{dummy}\n" ) ); let snapshot = fs::read_to_string(test.cwd.path().join("captured-snapshot"))?; assert!(!snapshot.contains(REAL_GITHUB_TOKEN)); assert!(snapshot.contains("brokered_heredoc")); assert!(snapshot.contains("Authorization: ${GH_TOKEN}")); return Ok(()); } if matches!(mode, "enabled" | "flat_feature" | "flat_managed") { assert_eq!(result.exit_code, Some(0), "command failed: {result:?}"); let values = result.stdout.lines().collect::>(); assert_eq!(values.len(), if mode == "flat_managed" { 3 } else { 2 }); assert_eq!(values[0], "west"); assert!(values[1].starts_with("ghp_")); assert_ne!(values[1], REAL_GITHUB_TOKEN); if mode == "flat_managed" { assert!(values[2].starts_with("custom_")); assert_eq!(values[2].len(), REAL_CUSTOM_TOKEN.len()); assert_ne!(values[2], REAL_CUSTOM_TOKEN); } return Ok(()); } assert_eq!( result, CommandResult { exit_code: Some(0), stdout: format!("{REAL_GITHUB_TOKEN}\n"), }, "fail-open command did not receive the ordinary real-credential environment" ); if mode == "escalated" { let observations = fs::read_to_string(&observed_path)?; assert!(!observations.is_empty(), "startup file did not run"); assert!( !observations.lines().any(|value| value != REAL_GITHUB_TOKEN), "escalated startup observed a broker dummy" ); } Ok(()) } #[cfg(unix)] #[test_case(true; "credential_startup")] #[test_case(false; "ordinary_application_env")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn brokered_posix_startup_preserves_application_env(credential_startup: bool) -> Result<()> { skip_if_no_network!(Ok(())); const REAL_TOKEN: &str = "ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; let Some(shell) = codex_core::shell::get_shell(codex_core::shell::ShellType::Sh) else { return Ok(()); }; let home = Arc::new(TempDir::new()?); let startup = home.path().join("startup.sh"); let credential_export = format!("export GH_TOKEN='{REAL_TOKEN}'\n"); fs::write( &startup, format!( "export CORP_REGION=west\n{}", if credential_startup { credential_export.as_str() } else { "" } ), )?; fs::write( home.path().join("config.toml"), format!( r#"default_permissions = "brokered" features = {{ network_proxy = {{ enabled = true, credential_broker = true }} }} permissions = {{ brokered = {{ extends = ":workspace", network = {{ enabled = true, allow_local_binding = true }} }} }} [shell_environment_policy.set] ENV = "{}" "#, startup.display() ), )?; let shell_path = shell.derive_exec_args("", /*use_login_shell*/ false)[0].clone(); let server = start_mock_server().await; let test = test_codex() .with_home(home) .with_user_shell(shell) .with_cloud_config_bundle(managed_network_requirements_loader()) .with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(AskForApproval::Never); config.permissions.allow_login_shell = true; config.features.enable(Feature::ShellSnapshot).unwrap(); if !credential_startup { config .permissions .shell_environment_policy .r#set .insert("GH_TOKEN".to_string(), REAL_TOKEN.to_string()); } }) .build(&server) .await?; let script = r#"printf '%s\n%s\n%s\n%s\n' "$GH_TOKEN" "$CODEX_NETWORK_PROXY_CREDENTIAL_BROKER_ACTIVE" "${ENV-unset}" "$CORP_REGION""#; let command = format!( "{} 2>/dev/null", shlex::try_join([shell_path.as_str(), "-i", "-c", script])? ); let call_id = "brokered-posix-startup"; let responses = mount_sse_sequence( &server, vec![ sse(vec![ ev_response_created("posix-startup-1"), shell_event( call_id, &command, /*timeout_ms*/ 5_000, SandboxPermissions::UseDefault, )?, ev_completed("posix-startup-1"), ]), sse(vec![ ev_assistant_message("posix-startup-done", "done"), ev_completed("posix-startup-2"), ]), ], ) .await; submit_turn_preserving_active_permission_profile( &test, "show the POSIX startup environment", AskForApproval::Never, ) .await?; wait_for_completion_without_approval(&test).await; let result = parse_result(&responses.requests()[1].function_call_output(call_id)); assert_eq!(result.exit_code, Some(0), "command failed: {result:?}"); assert!(!result.stdout.contains(REAL_TOKEN)); let values = result.stdout.lines().collect::>(); assert_eq!(values.len(), 4, "unexpected command output: {result:?}"); assert!(values[0].starts_with("ghp_")); assert_eq!( &values[1..], &[ "1", if credential_startup { "unset" } else { startup.to_str().unwrap() }, "west" ] ); Ok(()) } /// Verifies that an allowlisted script retains the originating named profile /// when its shell tool call requests escalated, unsandboxed execution. /// /// Tool owners use this pattern when a trusted wrapper must run outside the /// current sandbox, but then needs to launch child commands back inside the /// same sandbox with `codex sandbox -P`. The nested invocation must also pass /// `--include-managed-config` so it continues to honor enterprise requirements. /// The test proves both halves of that contract: the wrapper writes outside the /// `:workspace` sandbox, while its inherited profile name remains `:workspace`. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[cfg(unix)] async fn allowed_escalated_exec_command_inherits_active_permission_profile() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; let home = Arc::new(TempDir::new()?); fs::write( home.path().join("config.toml"), WORKSPACE_PERMISSION_PROFILE_CONFIG, )?; let script_dir = tempfile::tempdir_in(std::env::current_dir()?)?; let script_path = script_dir.path().join("print-permission-profile.sh"); let outside_path = script_dir.path().join("unsandboxed-marker"); fs::write( &script_path, format!( r#"#!/bin/sh # Print the inherited profile so the test can verify that it reached this script. printenv CODEX_PERMISSION_PROFILE touch {outside_path:?} "# ), )?; let rules_dir = home.path().join("rules"); fs::create_dir_all(&rules_dir)?; let script_pattern = serde_json::to_string(&script_path.to_string_lossy())?; fs::write( rules_dir.join("default.rules"), format!(r#"prefix_rule(pattern=["/bin/sh", {script_pattern}], decision="allow")"#), )?; let approval_policy = AskForApproval::OnRequest; let mut builder = test_codex().with_home(home).with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); }); let test = builder.build(&server).await?; assert!(!outside_path.starts_with(test.config.cwd.as_path())); assert_active_workspace_permission_profile(&test); let call_id = "allowed-escalated-shell-inherits-permission-profile"; let command = format!("/bin/sh {script_path:?}"); let event = shell_event( call_id, &command, /*timeout_ms*/ 5_000, SandboxPermissions::RequireEscalated, )?; let _ = mount_sse_once( &server, sse(vec![ ev_response_created("resp-escalated-profile-1"), event, ev_completed("resp-escalated-profile-1"), ]), ) .await; let results = mount_sse_once( &server, sse(vec![ ev_assistant_message("msg-escalated-profile-1", "done"), ev_completed("resp-escalated-profile-2"), ]), ) .await; submit_turn_preserving_active_permission_profile( &test, "run the allowed script with escalated permissions", approval_policy, ) .await?; wait_for_completion_without_approval(&test).await; let result = parse_result(&results.single_request().function_call_output(call_id)); assert_eq!( result, CommandResult { exit_code: Some(0), stdout: format!("{BUILT_IN_PERMISSION_PROFILE_WORKSPACE}\n"), }, "the unsandboxed script should inherit CODEX_PERMISSION_PROFILE from the shell command" ); assert!( outside_path.exists(), "allowed escalated script should run outside the :workspace sandbox" ); Ok(()) } #[cfg(unix)] #[test_case(Some(ReviewDecision::Approved); "approved")] #[test_case(Some(ReviewDecision::denied("rejected by user")); "denied")] #[test_case(None; "allow_rule_skips_approval_without_widening")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn explicit_escalation_with_denied_reads(decision: Option) -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; let home = Arc::new(TempDir::new()?); if decision.is_none() { let rules = home.path().join("rules"); fs::create_dir(&rules)?; fs::write( rules.join("default.rules"), r#"prefix_rule(pattern=["/bin/sh", "escalation.sh"], decision="allow")"#, )?; } let outside_marker = home.path().join("escalated-marker"); let quoted_marker = shlex::try_join([outside_marker.to_string_lossy().as_ref()])?; let approval_policy = AskForApproval::OnRequest; let mut builder = test_codex() .with_home(home) .with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config.features.enable(Feature::UnifiedExec).unwrap(); let mut file_system = PermissionProfile::workspace_write_with( &[], NetworkSandboxPolicy::Restricted, /*exclude_tmpdir_env_var*/ true, /*exclude_slash_tmp*/ true, ) .file_system_sandbox_policy(); file_system.entries.push(FileSystemSandboxEntry::new( config.cwd.join("secret.txt").into(), FileSystemAccessMode::Deny, )); config .permissions .set_permission_profile(PermissionProfile::from_runtime_permissions( &file_system, NetworkSandboxPolicy::Restricted, )) .unwrap(); }) .with_workspace_setup(move |cwd, _fs| async move { fs::write(cwd.join("secret.txt"), "secret")?; fs::write( cwd.join("escalation.sh"), format!( "printf ran > ran || exit 10; (printf outside > {quoted_marker}) 2> errors; \ if cat secret.txt > errors 2>&1; then exit 11; fi; \ grep -q secret.txt errors || exit 12; printf 'protected\\n'" ), )?; Ok(()) }); let test = builder.build(&server).await?; let workspace_marker = test.cwd.path().join("ran"); let command = "/bin/sh escalation.sh"; let call_id = "explicit-deny-read-escalation"; let event = exec_command_event( call_id, command, Some(10_000), SandboxPermissions::RequireEscalated, /*justification*/ None, )?; mount_sse_once(&server, sse(vec![event, ev_completed("run")])).await; let response = mount_sse_once(&server, sse(vec![ev_completed("done")])).await; submit_turn_preserving_active_permission_profile(&test, "run the command", approval_policy) .await?; let approved = decision == Some(ReviewDecision::Approved); let rejected = decision.is_some() && !approved; if let Some(decision) = decision { let request = expect_exec_approval(&test, command).await; assert!(!outside_marker.exists() && !workspace_marker.exists()); test.codex .submit(Op::ExecApproval { id: request.effective_approval_id(), turn_id: None, decision, }) .await?; } wait_for_completion_without_approval(&test).await; let result = parse_result(&response.single_request().function_call_output(call_id)); if rejected { assert!(result.stdout.contains("rejected by user"), "{result:?}"); assert!(!workspace_marker.exists()); } else { assert_eq!( (result.exit_code, result.stdout.as_str()), (Some(0), "protected\n") ); assert_eq!(fs::read_to_string(workspace_marker)?, "ran"); } assert_eq!(outside_marker.exists(), approved); Ok(()) } #[tokio::test(flavor = "current_thread")] #[cfg(unix)] async fn invalid_requested_prefix_rule_falls_back_for_compound_command() -> Result<()> { let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; let sandbox_policy = SandboxPolicy::new_read_only_policy(); let sandbox_policy_for_config = sandbox_policy.clone(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config .set_legacy_sandbox_policy(sandbox_policy_for_config) .expect("set sandbox policy"); }); let test = builder.build(&server).await?; let call_id = "invalid-prefix-rule"; let command = "touch /tmp/codex-fallback-rule-test.txt && echo hello > /tmp/codex-fallback-rule-test.txt"; let event = shell_event_with_prefix_rule( call_id, command, /*timeout_ms*/ 1_000, SandboxPermissions::RequireEscalated, Some(vec!["touch".to_string()]), )?; let _ = mount_sse_once( &server, sse(vec![ ev_response_created("resp-invalid-prefix-1"), event, ev_completed("resp-invalid-prefix-1"), ]), ) .await; submit_turn( &test, "invalid-prefix-rule", approval_policy, sandbox_policy.clone(), ) .await?; let approval = expect_exec_approval(&test, command).await; let amendment = approval .proposed_execpolicy_amendment .expect("should have a proposed execpolicy amendment"); assert!(amendment.command.contains(&command.to_string())); Ok(()) } #[tokio::test(flavor = "current_thread")] #[cfg(unix)] async fn approving_fallback_rule_for_compound_command_works() -> Result<()> { let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; let sandbox_policy = SandboxPolicy::new_read_only_policy(); let sandbox_policy_for_config = sandbox_policy.clone(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config .set_legacy_sandbox_policy(sandbox_policy_for_config) .expect("set sandbox policy"); }); let test = builder.build(&server).await?; let call_id = "invalid-prefix-rule"; let command = "touch /tmp/codex-fallback-rule-test.txt && echo hello > /tmp/codex-fallback-rule-test.txt"; let event = shell_event_with_prefix_rule( call_id, command, /*timeout_ms*/ 10_000, SandboxPermissions::RequireEscalated, Some(vec!["touch".to_string()]), )?; let _ = mount_sse_once( &server, sse(vec![ ev_response_created("resp-invalid-prefix-1"), event, ev_completed("resp-invalid-prefix-1"), ]), ) .await; submit_turn( &test, "invalid-prefix-rule", approval_policy, sandbox_policy.clone(), ) .await?; let approval = expect_exec_approval(&test, command).await; let approval_id = approval.effective_approval_id(); let amendment = approval .proposed_execpolicy_amendment .expect("should have a proposed execpolicy amendment"); assert!(amendment.command.contains(&command.to_string())); test.codex .submit(Op::ExecApproval { id: approval_id, turn_id: None, decision: ReviewDecision::ApprovedExecpolicyAmendment { proposed_execpolicy_amendment: amendment.clone(), }, }) .await?; wait_for_completion(&test).await; let call_id = "invalid-prefix-rule-again"; let command = "touch /tmp/codex-fallback-rule-test.txt && echo hello > /tmp/codex-fallback-rule-test.txt"; let event = shell_event_with_prefix_rule( call_id, command, /*timeout_ms*/ 10_000, SandboxPermissions::RequireEscalated, Some(vec!["touch".to_string()]), )?; let _ = mount_sse_once( &server, sse(vec![ ev_response_created("resp-invalid-prefix-1"), event, ev_completed("resp-invalid-prefix-1"), ]), ) .await; let second_results = mount_sse_once( &server, sse(vec![ ev_assistant_message("msg-invalid-prefix-1", "done"), ev_completed("resp-invalid-prefix-2"), ]), ) .await; submit_turn( &test, "invalid-prefix-rule", approval_policy, sandbox_policy.clone(), ) .await?; wait_for_completion_without_approval(&test).await; let second_output = parse_result( &second_results .single_request() .function_call_output(call_id), ); assert_eq!(second_output.exit_code.unwrap_or(0), 0); assert!( second_output.stdout.is_empty(), "unexpected stdout: {}", second_output.stdout ); Ok(()) } #[tokio::test(flavor = "current_thread")] async fn denying_network_policy_amendment_persists_policy_and_skips_future_network_prompt() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; let home = Arc::new(TempDir::new()?); fs::write( home.path().join("config.toml"), r#"default_permissions = "workspace" [permissions.workspace.filesystem] ":minimal" = "read" [permissions.workspace.network] enabled = true mode = "limited" allow_local_binding = true "#, )?; let approval_policy = AskForApproval::OnRequest; let sandbox_policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], network_access: true, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, }; let sandbox_policy_for_config = sandbox_policy.clone(); let mut builder = test_codex() .with_home(home) .with_cloud_config_bundle(managed_network_requirements_loader()) .with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config .set_legacy_sandbox_policy(sandbox_policy_for_config) .expect("set sandbox policy"); }); let test = builder.build(&server).await?; assert!( test.config.managed_network_requirements_enabled(), "expected managed network requirements to be enabled" ); assert!( test.config.permissions.network.is_some(), "expected managed network proxy config to be present" ); test.session_configured .network_proxy .as_ref() .expect("expected runtime managed network proxy addresses"); let call_id_first = "allow-network-first"; // Use urllib without overriding proxy settings so managed-network sessions // continue to exercise the env-based proxy routing path under bubblewrap. let fetch_command = r#"python3 -c "import urllib.request; opener = urllib.request.build_opener(urllib.request.ProxyHandler()); print('OK:' + opener.open('http://codex-network-test.invalid', timeout=30).read().decode(errors='replace'))""# .to_string(); let first_event = shell_event( call_id_first, &fetch_command, /*timeout_ms*/ 30_000, SandboxPermissions::UseDefault, )?; let _ = mount_sse_once( &server, sse(vec![ ev_response_created("resp-allow-network-1"), first_event, ev_completed("resp-allow-network-1"), ]), ) .await; let first_results = mount_sse_once( &server, sse(vec![ ev_assistant_message("msg-allow-network-1", "done"), ev_completed("resp-allow-network-2"), ]), ) .await; submit_turn( &test, "allow-network-first", approval_policy, sandbox_policy.clone(), ) .await?; let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); let approval = loop { let remaining = deadline .checked_duration_since(std::time::Instant::now()) .expect("timed out waiting for network approval request"); let event = wait_for_event_with_timeout( &test.codex, |event| { matches!( event, EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) ) }, remaining, ) .await; match event { EventMsg::ExecApprovalRequest(approval) => { if approval.command.first().map(std::string::String::as_str) == Some("network-access") { break approval; } test.codex .submit(Op::ExecApproval { id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::Approved, }) .await?; } EventMsg::TurnComplete(_) => { panic!("expected network approval request before completion"); } other => panic!("unexpected event: {other:?}"), } }; let network_context = approval .network_approval_context .clone() .expect("expected network approval context"); assert_eq!(network_context.protocol, NetworkApprovalProtocol::Http); let expected_network_amendments = vec![ NetworkPolicyAmendment { host: network_context.host.clone(), action: NetworkPolicyRuleAction::Allow, }, NetworkPolicyAmendment { host: network_context.host.clone(), action: NetworkPolicyRuleAction::Deny, }, ]; assert_eq!( approval.proposed_network_policy_amendments, Some(expected_network_amendments.clone()) ); let deny_network_amendment = expected_network_amendments .into_iter() .find(|amendment| amendment.action == NetworkPolicyRuleAction::Deny) .expect("expected deny network policy amendment"); test.codex .submit(Op::ExecApproval { id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::NetworkPolicyAmendment { network_policy_amendment: deny_network_amendment.clone(), }, }) .await?; wait_for_completion(&test).await; let policy_path = test.home.path().join("rules").join("default.rules"); let policy_contents = fs::read_to_string(&policy_path)?; let expected_rule = format!( r#"network_rule(host="{}", protocol="{}", decision="deny", justification="Deny {} access to {}")"#, deny_network_amendment.host, match network_context.protocol { NetworkApprovalProtocol::Http => "http", NetworkApprovalProtocol::Https => "https_connect", NetworkApprovalProtocol::Socks5Tcp => "socks5_tcp", NetworkApprovalProtocol::Socks5Udp => "socks5_udp", }, match network_context.protocol { NetworkApprovalProtocol::Http => "http", NetworkApprovalProtocol::Https => "https_connect", NetworkApprovalProtocol::Socks5Tcp => "socks5_tcp", NetworkApprovalProtocol::Socks5Udp => "socks5_udp", }, deny_network_amendment.host ); assert!( policy_contents.contains(&expected_rule), "unexpected policy contents: {policy_contents}" ); assert!(first_results.requests().iter().any(|request| { request.body_contains_text(&format!( "Denied network rule saved in execpolicy (denylist): {}", deny_network_amendment.host )) })); let first_output = parse_result( &first_results .single_request() .function_call_output(call_id_first), ); Expectation::CommandFailure { output_contains: "", } .verify(&test, &first_output)?; let call_id_second = "allow-network-second"; let second_event = shell_event( call_id_second, &fetch_command, /*timeout_ms*/ 30_000, SandboxPermissions::UseDefault, )?; let _ = mount_sse_once( &server, sse(vec![ ev_response_created("resp-allow-network-3"), second_event, ev_completed("resp-allow-network-3"), ]), ) .await; let second_results = mount_sse_once( &server, sse(vec![ ev_assistant_message("msg-allow-network-2", "done"), ev_completed("resp-allow-network-4"), ]), ) .await; submit_turn( &test, "allow-network-second", approval_policy, sandbox_policy.clone(), ) .await?; let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); loop { let remaining = deadline .checked_duration_since(std::time::Instant::now()) .expect("timed out waiting for second turn completion"); let event = wait_for_event_with_timeout( &test.codex, |event| { matches!( event, EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) ) }, remaining, ) .await; match event { EventMsg::ExecApprovalRequest(approval) => { if approval.command.first().map(std::string::String::as_str) == Some("network-access") { panic!( "unexpected network approval request: {:?}", approval.command ); } test.codex .submit(Op::ExecApproval { id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::Approved, }) .await?; } EventMsg::TurnComplete(_) => break, other => panic!("unexpected event: {other:?}"), } } let second_output = parse_result( &second_results .single_request() .function_call_output(call_id_second), ); Expectation::CommandFailure { output_contains: "", } .verify(&test, &second_output)?; Ok(()) } #[cfg(not(target_os = "windows"))] #[tokio::test(flavor = "current_thread")] async fn network_approval_retry_keeps_deny_read_sandbox_for_escalated_command() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; let home = Arc::new(TempDir::new()?); fs::write( home.path().join("config.toml"), r#"default_permissions = "workspace" [permissions.workspace.filesystem] ":minimal" = "read" [permissions.workspace.network] enabled = true mode = "limited" allow_local_binding = true "#, )?; let approval_policy = AskForApproval::OnRequest; let sandbox_policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], network_access: true, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, }; let mut builder = test_codex() .with_home(home) .with_cloud_config_bundle(managed_network_requirements_loader()) .with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); }); let test = builder.build(&server).await?; assert!( test.config.managed_network_requirements_enabled(), "expected managed network requirements to be enabled" ); assert!( test.config.permissions.network.is_some(), "expected managed network proxy config to be present" ); test.session_configured .network_proxy .as_ref() .expect("expected runtime managed network proxy addresses"); let mut file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( &sandbox_policy, test.config.cwd.as_path(), ); file_system_sandbox_policy .entries .push(FileSystemSandboxEntry { path: FileSystemPath::GlobPattern { pattern: format!("{}/**/*.env", test.config.cwd.as_path().display()), }, access: FileSystemAccessMode::Deny, missing_path_behavior: None, }); assert!( file_system_sandbox_policy.has_denied_read_restrictions(), "test must exercise a permission profile with denied reads" ); let permission_profile = PermissionProfile::from_runtime_permissions( &file_system_sandbox_policy, NetworkSandboxPolicy::Restricted, ); let call_id = "deny-read-network-retry"; let fetch_command = r#"python3 -c "import urllib.request; opener = urllib.request.build_opener(urllib.request.ProxyHandler()); print('OK:' + opener.open('http://codex-network-test.invalid', timeout=30).read().decode(errors='replace'))""# .to_string(); let event = shell_event( call_id, &fetch_command, /*timeout_ms*/ 30_000, SandboxPermissions::RequireEscalated, )?; let _ = mount_sse_once( &server, sse(vec![ ev_response_created("resp-deny-read-network-1"), event, ev_completed("resp-deny-read-network-1"), ]), ) .await; let results = mount_sse_once( &server, sse(vec![ ev_assistant_message("msg-deny-read-network-1", "done"), ev_completed("resp-deny-read-network-2"), ]), ) .await; let (turn_sandbox_policy, turn_permission_profile) = turn_permission_fields(permission_profile, test.config.cwd.as_path()); let session_model = test.session_configured.model.clone(); test.codex .start_or_steer_turn( TurnInputRequest::user_input(vec![UserInput::Text { text: "deny-read network retry".into(), text_elements: Vec::new(), }]) .with_thread_settings(ThreadSettingsOverrides { environments: Some(local_requests(test.config.cwd.clone())), approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(turn_sandbox_policy), permission_profile: turn_permission_profile, collaboration_mode: Some(CollaborationMode { mode: ModeKind::Default, settings: Settings { model: session_model, reasoning_effort: None, developer_instructions: None, }, }), ..Default::default() }), ) .await?; let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); let mut command_approval_count = 0; let approval = loop { let remaining = deadline .checked_duration_since(std::time::Instant::now()) .expect("timed out waiting for network approval request"); let event = wait_for_event_with_timeout( &test.codex, |event| { matches!( event, EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) ) }, remaining, ) .await; match event { EventMsg::ExecApprovalRequest(approval) => { if approval.command.first().map(std::string::String::as_str) == Some("network-access") { break approval; } command_approval_count += 1; assert_eq!( command_approval_count, 1, "expected only the outer explicit escalation approval" ); test.codex .submit(Op::ExecApproval { id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::Approved, }) .await?; } EventMsg::TurnComplete(_) => { panic!("expected network approval request before completion"); } other => panic!("unexpected event: {other:?}"), } }; assert_eq!(command_approval_count, 1); let network_context = approval .network_approval_context .clone() .expect("expected network approval context"); assert_eq!(network_context.protocol, NetworkApprovalProtocol::Http); let allow_network_amendment = approval .proposed_network_policy_amendments .clone() .expect("expected proposed network policy amendments") .into_iter() .find(|amendment| amendment.action == NetworkPolicyRuleAction::Allow) .expect("expected allow network policy amendment"); test.codex .submit(Op::ExecApproval { id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::NetworkPolicyAmendment { network_policy_amendment: allow_network_amendment, }, }) .await?; wait_for_completion(&test).await; let output = parse_result(&results.single_request().function_call_output(call_id)); assert!( output.exit_code.is_some(), "expected the approved retry to report command output" ); Ok(()) } #[tokio::test(flavor = "current_thread")] async fn network_approval_flow_survives_danger_full_access_session_start() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; let home = Arc::new(TempDir::new()?); fs::write( home.path().join("config.toml"), r#"default_permissions = "workspace" [permissions.workspace.filesystem] ":minimal" = "read" [permissions.workspace.network] enabled = true mode = "limited" allow_local_binding = true "#, )?; let approval_policy = AskForApproval::OnRequest; let turn_sandbox_policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], network_access: true, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, }; let mut builder = test_codex() .with_home(home) .with_cloud_config_bundle(managed_network_requirements_loader()) .with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); let cwd = config.cwd.clone(); config .permissions .set_legacy_sandbox_policy(SandboxPolicy::DangerFullAccess, cwd.as_path()) .expect("test setup should allow sandbox policy"); }); let test = builder.build(&server).await?; assert!( !test.config.managed_network_requirements_enabled(), "expected managed network requirements to stay inactive in danger-full-access" ); assert!( test.config.permissions.network.is_some(), "expected managed network proxy config to be present" ); assert!( test.session_configured.network_proxy.is_none(), "expected session configured event to hide managed network proxy in danger-full-access" ); let call_id = "allow-network-after-yolo"; let fetch_command = r#"python3 -c "import urllib.request; opener = urllib.request.build_opener(urllib.request.ProxyHandler()); print('OK:' + opener.open('http://codex-network-test.invalid', timeout=30).read().decode(errors='replace'))""# .to_string(); let event = shell_event( call_id, &fetch_command, /*timeout_ms*/ 30_000, SandboxPermissions::UseDefault, )?; let _ = mount_sse_once( &server, sse(vec![ ev_response_created("resp-network-after-yolo-1"), event, ev_completed("resp-network-after-yolo-1"), ]), ) .await; let _ = mount_sse_once( &server, sse(vec![ ev_assistant_message("msg-network-after-yolo-1", "done"), ev_completed("resp-network-after-yolo-2"), ]), ) .await; submit_turn( &test, "allow-network-after-yolo", approval_policy, turn_sandbox_policy, ) .await?; let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); let approval = loop { let remaining = deadline .checked_duration_since(std::time::Instant::now()) .expect("timed out waiting for network approval request"); let event = wait_for_event_with_timeout( &test.codex, |event| { matches!( event, EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) ) }, remaining, ) .await; match event { EventMsg::ExecApprovalRequest(approval) => { if approval.command.first().map(std::string::String::as_str) == Some("network-access") { break approval; } test.codex .submit(Op::ExecApproval { id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::Approved, }) .await?; } EventMsg::TurnComplete(_) => { panic!("expected network approval request before completion"); } other => panic!("unexpected event: {other:?}"), } }; let network_context = approval .network_approval_context .clone() .expect("expected network approval context"); assert_eq!(network_context.protocol, NetworkApprovalProtocol::Http); test.codex .submit(Op::ExecApproval { id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::denied("rejected by user"), }) .await?; wait_for_completion(&test).await; Ok(()) } // todo(dylan) add ScenarioSpec support for rules #[tokio::test(flavor = "current_thread")] #[cfg(unix)] async fn compound_command_with_one_safe_command_still_requires_approval() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; let approval_policy = AskForApproval::UnlessTrusted; let sandbox_policy = SandboxPolicy::new_workspace_write_policy(); let sandbox_policy_for_config = sandbox_policy.clone(); let mut builder = test_codex().with_config(move |config| { config.permissions.approval_policy = Constrained::allow_any(approval_policy); config .set_legacy_sandbox_policy(sandbox_policy_for_config) .expect("set sandbox policy"); }); let test = builder.build(&server).await?; let rules_dir = test.home.path().join("rules"); fs::create_dir_all(&rules_dir)?; fs::write( rules_dir.join("default.rules"), r#"prefix_rule(pattern=["touch", "allow-prefix.txt"], decision="allow")"#, )?; let call_id = "heredoc-with-chained-prefix"; let command = "touch ./test.txt && rm ./test.txt"; let (event, expected_command) = ActionKind::RunCommand { command } .prepare(&test, &server, call_id, SandboxPermissions::UseDefault) .await?; let expected_command = expected_command.expect("compound command should produce a shell command"); let _ = mount_sse_once( &server, sse(vec![ ev_response_created("resp-heredoc-prefix-1"), event, ev_completed("resp-heredoc-prefix-1"), ]), ) .await; let _ = mount_sse_once( &server, sse(vec![ ev_assistant_message("msg-heredoc-prefix-1", "done"), ev_completed("resp-heredoc-prefix-2"), ]), ) .await; submit_turn( &test, "compound command", approval_policy, sandbox_policy.clone(), ) .await?; let approval = expect_exec_approval(&test, expected_command.as_str()).await; test.codex .submit(Op::ExecApproval { id: approval.effective_approval_id(), turn_id: None, decision: ReviewDecision::denied("rejected by user"), }) .await?; wait_for_completion(&test).await; Ok(()) }