diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 6be105021..71fa89f98 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -630,9 +630,11 @@ impl EscalationPolicy for CoreShellActionProvider { let decision_driven_by_policy = Self::decision_driven_by_policy(&evaluation.matched_rules, evaluation.decision); let unsandboxed_allowed = unsandboxed_execution_allowed(&self.file_system_sandbox_policy); - let needs_escalation = unsandboxed_allowed - && (self.sandbox_permissions.requires_escalated_permissions() - || decision_driven_by_policy); + let needs_escalation = match self.sandbox_permissions { + SandboxPermissions::UseDefault => unsandboxed_allowed && decision_driven_by_policy, + SandboxPermissions::RequireEscalated => unsandboxed_allowed, + SandboxPermissions::WithAdditionalPermissions => true, + }; let decision_source = if decision_driven_by_policy { DecisionSource::PrefixRule @@ -858,7 +860,7 @@ impl ShellCommandExecutor for CoreShellCommandExecutor { EscalationExecution::Unsandboxed => PreparedExec { command, cwd: workdir.to_path_buf(), - env, + env: exec_env_for_sandbox_permissions(&env, SandboxPermissions::RequireEscalated), arg0: Some(first_arg.clone()), }, EscalationExecution::TurnDefault => { diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index 8cfbd0736..ee5b9e7db 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -1,4 +1,5 @@ use super::CoreShellActionProvider; +use super::CoreShellCommandExecutor; use super::InterceptedExecPolicyContext; use super::ParsedShellCommand; use super::commands_for_intercepted_exec_policy; @@ -16,6 +17,8 @@ use codex_execpolicy::PolicyParser; use codex_execpolicy::RuleMatch; use codex_hooks::Hooks; use codex_hooks::HooksConfig; +use codex_network_proxy::PROXY_ACTIVE_ENV_KEY; +use codex_network_proxy::PROXY_ENV_KEYS; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::models::FileSystemPermissions; @@ -30,13 +33,16 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::GranularApprovalConfig; use codex_protocol::protocol::GuardianCommandSource; use codex_sandboxing::SandboxType; +use codex_sandboxing::policy_transforms::effective_permission_profile; use codex_shell_escalation::EscalationExecution; use codex_shell_escalation::EscalationPermissions; use codex_shell_escalation::ExecResult; use codex_shell_escalation::ResolvedPermissionProfile; +use codex_shell_escalation::ShellCommandExecutor; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use serde_json::Value; +use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -349,6 +355,108 @@ fn shell_request_escalation_execution_is_explicit() { ); } +#[tokio::test] +async fn unsandboxed_intercepted_exec_strips_managed_network_env() -> anyhow::Result<()> { + let workdir = test_sandbox_cwd(); + let executor = CoreShellCommandExecutor { + command: Vec::new(), + cwd: workdir.clone(), + permission_profile: PermissionProfile::workspace_write(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), + network_sandbox_policy: NetworkSandboxPolicy::Restricted, + sandbox: SandboxType::None, + env: HashMap::new(), + network: None, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + arg0: None, + sandbox_policy_cwd: workdir.clone(), + windows_sandbox_workspace_roots: vec![workdir.clone()], + codex_linux_sandbox_exe: None, + use_legacy_landlock: false, + }; + let mut env = HashMap::new(); + env.insert(PROXY_ACTIVE_ENV_KEY.to_string(), "1".to_string()); + for key in PROXY_ENV_KEYS { + env.insert((*key).to_string(), format!("proxy-{key}")); + } + + let prepared = executor + .prepare_escalated_exec( + &AbsolutePathBuf::from_absolute_path("/usr/bin/curl")?, + &["curl".to_string(), "example.com".to_string()], + &workdir, + env, + EscalationExecution::Unsandboxed, + ) + .await?; + + assert!( + !prepared.env.contains_key(PROXY_ACTIVE_ENV_KEY), + "unsandboxed intercepted exec should strip the managed-network active marker" + ); + for key in PROXY_ENV_KEYS { + assert!( + !prepared.env.contains_key(*key), + "unsandboxed intercepted exec should strip managed-network proxy env var {key}" + ); + } + + Ok(()) +} + +#[tokio::test] +async fn preapproved_additional_permissions_escalate_intercepted_exec() -> anyhow::Result<()> { + let (session, turn_context) = make_session_and_context().await; + let requested_permissions = AdditionalPermissionProfile { + file_system: Some(FileSystemPermissions::from_read_write_roots( + /*read*/ None, + Some(vec![ + AbsolutePathBuf::from_absolute_path("/tmp/output").unwrap(), + ]), + )), + ..Default::default() + }; + let workdir = test_sandbox_cwd(); + let permission_profile = effective_permission_profile( + &PermissionProfile::workspace_write(), + Some(&requested_permissions), + ); + let provider = CoreShellActionProvider { + policy: Arc::new(RwLock::new(codex_execpolicy::Policy::empty())), + session: Arc::new(session), + turn: Arc::new(turn_context), + call_id: "preapproved-additional-permissions".to_string(), + tool_name: GuardianCommandSource::Shell, + approval_policy: AskForApproval::OnRequest, + permission_profile: permission_profile.clone(), + file_system_sandbox_policy: read_only_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::WithAdditionalPermissions, + approval_sandbox_permissions: SandboxPermissions::UseDefault, + prompt_permissions: Some(requested_permissions), + stopwatch: codex_shell_escalation::Stopwatch::new(Duration::from_secs(1)), + }; + + let action = codex_shell_escalation::EscalationPolicy::determine_action( + &provider, + &AbsolutePathBuf::from_absolute_path("/usr/bin/printf")?, + &["printf".to_string(), "hello".to_string()], + &workdir, + ) + .await?; + + let expected = codex_shell_escalation::EscalationDecision::Escalate( + EscalationExecution::Permissions(EscalationPermissions::ResolvedPermissionProfile( + ResolvedPermissionProfile { permission_profile }, + )), + ); + assert_eq!( + action, expected, + "preapproved with_additional_permissions should escalate through the resolved permission profile" + ); + + Ok(()) +} + #[tokio::test(flavor = "current_thread")] async fn execve_permission_request_hook_short_circuits_prompt() -> anyhow::Result<()> { let (session, mut turn_context) = make_session_and_context().await; diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index bf04a6fe3..250965b67 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -265,17 +265,15 @@ impl<'a> ToolRuntime for UnifiedExecRunt let base_command = &req.command; let session_shell = ctx.session.user_shell(); let (file_system_sandbox_policy, _) = attempt.permissions.to_runtime_permissions(); - let sandbox_permissions = sandbox_permissions_preserving_denied_reads( + let launch_sandbox_permissions = sandbox_permissions_preserving_denied_reads( req.sandbox_permissions, &file_system_sandbox_policy, ); - let req = &UnifiedExecRequest { - sandbox_permissions, - ..req.clone() - }; - let managed_network = - managed_network_for_sandbox_permissions(req.network.as_ref(), req.sandbox_permissions); - let mut env = exec_env_for_sandbox_permissions(&req.env, req.sandbox_permissions); + let managed_network = managed_network_for_sandbox_permissions( + req.network.as_ref(), + launch_sandbox_permissions, + ); + let mut env = exec_env_for_sandbox_permissions(&req.env, launch_sandbox_permissions); if let Some(network) = managed_network { network.apply_to_env(&mut env); } @@ -412,6 +410,7 @@ mod tests { use crate::exec::DEFAULT_EXEC_COMMAND_TIMEOUT_MS; use crate::tools::sandboxing::ToolRuntime; use codex_exec_server::Environment; + use codex_tools::ZshForkConfig; use std::time::Duration; use tempfile::tempdir; @@ -473,4 +472,107 @@ mod tests { assert_eq!(runtime.sandbox_cwd(&request), Some(&sandbox_cwd)); } + + #[tokio::test] + async fn zsh_fork_first_attempt_preserves_parent_sandbox_override() { + let manager = UnifiedExecProcessManager::default(); + let request = test_request( + SandboxPermissions::RequireEscalated, + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: None, + }, + ); + let direct_runtime = UnifiedExecRuntime::new(&manager, UnifiedExecShellMode::Direct); + let zsh_fork_runtime = UnifiedExecRuntime::new(&manager, zsh_fork_mode()); + + assert_eq!( + direct_runtime.sandbox_permissions(&request), + SandboxPermissions::RequireEscalated, + "direct unified exec should preserve a parent require_escalated request" + ); + assert_eq!( + zsh_fork_runtime.sandbox_permissions(&request), + SandboxPermissions::RequireEscalated, + "zsh-fork unified exec should preserve the same parent require_escalated request" + ); + } + + #[tokio::test] + async fn zsh_fork_first_attempt_preserves_additional_permissions_request() { + let manager = UnifiedExecProcessManager::default(); + let request = test_request( + SandboxPermissions::WithAdditionalPermissions, + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: None, + }, + ); + let zsh_fork_runtime = UnifiedExecRuntime::new(&manager, zsh_fork_mode()); + + assert_eq!( + zsh_fork_runtime.sandbox_permissions(&request), + SandboxPermissions::WithAdditionalPermissions, + "zsh-fork unified exec should keep bounded additional-permissions requests sandboxed" + ); + } + + #[tokio::test] + async fn zsh_fork_execpolicy_allow_preserves_parent_sandbox_override() { + let manager = UnifiedExecProcessManager::default(); + let request = test_request( + SandboxPermissions::UseDefault, + ExecApprovalRequirement::Skip { + bypass_sandbox: true, + proposed_execpolicy_amendment: None, + }, + ); + let runtime = UnifiedExecRuntime::new(&manager, zsh_fork_mode()); + + assert_eq!( + runtime.exec_approval_requirement(&request), + Some(ExecApprovalRequirement::Skip { + bypass_sandbox: true, + proposed_execpolicy_amendment: None, + }), + "zsh-fork unified exec should preserve exec-policy allow decisions that bypass the sandbox" + ); + } + + fn test_request( + sandbox_permissions: SandboxPermissions, + exec_approval_requirement: ExecApprovalRequirement, + ) -> UnifiedExecRequest { + let cwd = AbsolutePathBuf::try_from(std::env::current_dir().unwrap()) + .expect("current dir is absolute"); + UnifiedExecRequest { + command: vec!["zsh".to_string(), "-c".to_string(), "echo hi".to_string()], + shell_type: ShellType::Zsh, + hook_command: "echo hi".to_string(), + process_id: 1000, + cwd: cwd.clone(), + sandbox_cwd: cwd, + environment: Arc::new(Environment::default_for_tests()), + env: HashMap::new(), + exec_server_env_config: None, + explicit_env_overrides: HashMap::new(), + network: None, + tty: false, + sandbox_permissions, + additional_permissions: None, + #[cfg(unix)] + additional_permissions_preapproved: false, + justification: None, + exec_approval_requirement, + } + } + + fn zsh_fork_mode() -> UnifiedExecShellMode { + let cwd = std::env::current_dir().expect("read current dir"); + UnifiedExecShellMode::ZshFork(ZshForkConfig { + shell_zsh_path: AbsolutePathBuf::try_from(cwd.join("zsh")).expect("absolute zsh path"), + main_execve_wrapper_exe: AbsolutePathBuf::try_from(cwd.join("execve-wrapper")) + .expect("absolute wrapper path"), + }) + } } diff --git a/codex-rs/core/tests/common/zsh_fork.rs b/codex-rs/core/tests/common/zsh_fork.rs index e58ebd81d..5abfc753b 100644 --- a/codex-rs/core/tests/common/zsh_fork.rs +++ b/codex-rs/core/tests/common/zsh_fork.rs @@ -94,6 +94,33 @@ where builder.build(server).await } +pub async fn build_unified_exec_zsh_fork_test( + server: &wiremock::MockServer, + runtime: ZshForkRuntime, + approval_policy: AskForApproval, + permission_profile: PermissionProfile, + pre_build_hook: F, +) -> Result +where + F: FnOnce(&Path) + Send + 'static, +{ + let mut builder = test_codex() + .with_pre_build_hook(pre_build_hook) + .with_config(move |config| { + runtime.apply_to_config(config, approval_policy, permission_profile); + config.use_experimental_unified_exec_tool = true; + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::UnifiedExecZshFork) + .expect("test config should allow feature update"); + }); + builder.build(server).await +} + fn find_test_zsh_path() -> Result> { let repo_root = codex_utils_cargo_bin::repo_root()?; let dotslash_zsh = repo_root.join("codex-rs/app-server/tests/suite/zsh"); diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index ce853e810..11246d7cc 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -115,6 +115,8 @@ mod tools; mod truncation; mod turn_state; mod unified_exec; +#[cfg(unix)] +mod unified_exec_zsh_fork_approvals; mod unstable_features_warning; mod user_notification; mod user_shell_cmd; diff --git a/codex-rs/core/tests/suite/unified_exec_zsh_fork_approvals.rs b/codex-rs/core/tests/suite/unified_exec_zsh_fork_approvals.rs new file mode 100644 index 000000000..96ad8ee59 --- /dev/null +++ b/codex-rs/core/tests/suite/unified_exec_zsh_fork_approvals.rs @@ -0,0 +1,567 @@ +use anyhow::Context; +use anyhow::Result; +use codex_config::permissions_toml::FilesystemPermissionToml; +use codex_config::permissions_toml::PermissionProfileToml; +use codex_config::types::ApprovalsReviewer; +use codex_core::sandboxing::SandboxPermissions; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; +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::FileSystemSpecialPath; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecApprovalRequestEvent; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::ThreadSettingsOverrides; +use codex_protocol::user_input::UserInput; +use core_test_support::responses::ResponseMock; +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_response_created; +use core_test_support::responses::mount_sse_once; +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::turn_permission_fields; +use core_test_support::wait_for_event; +use core_test_support::wait_for_event_with_timeout; +use core_test_support::zsh_fork::build_unified_exec_zsh_fork_test; +use core_test_support::zsh_fork::restrictive_workspace_write_profile; +use core_test_support::zsh_fork::zsh_fork_runtime; +use pretty_assertions::assert_eq; +use regex_lite::Regex; +use serde_json::Value; +use serde_json::json; +use std::fs; +use std::path::Path; +use std::time::Duration; +use toml_edit::Key as TomlKey; +use wiremock::MockServer; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unified_exec_zsh_fork_parent_approval_preserves_denied_reads() -> Result<()> { + skip_if_no_network!(Ok(())); + + let denied_dir = tempfile::tempdir_in(std::env::current_dir()?)?; + let denied_path = denied_dir.path().join("secret.env"); + let secret = "unified-exec-zsh-fork-denied-read-secret"; + fs::write(&denied_path, format!("{secret}\n"))?; + let permission_profile = denied_read_permission_profile(&denied_path)?; + assert!( + permission_profile + .file_system_sandbox_policy() + .has_denied_read_restrictions(), + "test must exercise a permission profile with denied reads" + ); + + let approval_policy = AskForApproval::OnRequest; + let command = format!("cat {denied_path:?}"); + let Some((server, test)) = build_unified_exec_zsh_fork_test_or_skip( + "unified-exec zsh-fork denied-read approval test", + approval_policy, + permission_profile, + move |_home| {}, + ) + .await? + else { + return Ok(()); + }; + + let call_id = "uexec-zsh-fork-parent-approval-denied-read"; + let results = mount_unified_exec_command( + &server, + "uexec-zsh-fork-denied-read", + call_id, + &command, + "attempt a denied read for the test", + ) + .await?; + submit_turn_with_session_permissions( + &test, + "run approved unified exec denied read through zsh fork", + approval_policy, + ) + .await?; + approve_expected_exec(&test, &command).await?; + wait_for_completion_without_approval(&test).await; + + let result = command_result(&results, call_id); + assert_ne!( + result.exit_code.unwrap_or(0), + 0, + "denied-read command should stay sandboxed after parent approval" + ); + assert!( + !result.stdout.contains(secret), + "denied-read command unexpectedly printed the secret: {}", + result.stdout + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unified_exec_zsh_fork_parent_approval_escalates_intercepted_exec() -> Result<()> { + skip_if_no_network!(Ok(())); + + let approval_policy = AskForApproval::OnRequest; + let permission_profile = restrictive_workspace_write_profile(); + let outside_dir = tempfile::tempdir_in(std::env::current_dir()?)?; + let outside_path = outside_dir + .path() + .join("unified-exec-zsh-fork-parent-approval.txt"); + let command = format!("printf hi > {outside_path:?}"); + + let outside_path_for_hook = outside_path.clone(); + let Some((server, test)) = build_unified_exec_zsh_fork_test_or_skip( + "unified-exec zsh-fork parent approval test", + approval_policy, + permission_profile, + move |_home| { + let _ = fs::remove_file(&outside_path_for_hook); + }, + ) + .await? + else { + return Ok(()); + }; + + let call_id = "uexec-zsh-fork-parent-approval"; + let results = mount_unified_exec_command( + &server, + "uexec-zsh-fork-parent-approval", + call_id, + &command, + "write outside the workspace for the test", + ) + .await?; + submit_turn_with_session_permissions( + &test, + "run approved unified exec through zsh fork", + approval_policy, + ) + .await?; + approve_expected_exec(&test, &command).await?; + wait_for_completion_without_approval(&test).await; + + let result = command_result(&results, call_id); + assert_eq!( + result.exit_code.unwrap_or(0), + 0, + "approved unified exec zsh-fork command should complete: {}", + result.stdout + ); + let contents = fs::read_to_string(&outside_path) + .with_context(|| format!("read {}", outside_path.display()))?; + assert_eq!( + contents, "hi", + "approved parent sandbox override should allow zsh-fork shell redirection to write outside the workspace" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unified_exec_zsh_fork_parent_approval_keeps_explicit_prompt_rule() -> Result<()> { + skip_if_no_network!(Ok(())); + + let approval_policy = AskForApproval::OnRequest; + let permission_profile = restrictive_workspace_write_profile(); + let outside_dir = tempfile::tempdir_in(std::env::current_dir()?)?; + let outside_path = outside_dir + .path() + .join("unified-exec-zsh-fork-explicit-prompt-rule.txt"); + let command = format!("touch {outside_path:?}"); + let rules = r#"prefix_rule(pattern=["touch"], decision="prompt")"#.to_string(); + + let outside_path_for_hook = outside_path.clone(); + let Some((server, test)) = build_unified_exec_zsh_fork_test_or_skip( + "unified-exec zsh-fork prompt rule approval test", + approval_policy, + permission_profile, + move |home| { + let _ = fs::remove_file(&outside_path_for_hook); + let rules_dir = home.join("rules"); + fs::create_dir_all(&rules_dir).unwrap(); + fs::write(rules_dir.join("default.rules"), &rules).unwrap(); + }, + ) + .await? + else { + return Ok(()); + }; + + let call_id = "uexec-zsh-fork-parent-approval-explicit-prompt-rule"; + let results = mount_unified_exec_command( + &server, + "uexec-zsh-fork-prompt-rule", + call_id, + &command, + "write outside the workspace for the test", + ) + .await?; + submit_turn_with_session_permissions( + &test, + "run approved unified exec prompt rule through zsh fork", + approval_policy, + ) + .await?; + approve_expected_exec(&test, &command).await?; + + let approval_event = wait_for_event_with_timeout( + &test.codex, + |event| { + matches!( + event, + EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) + ) + }, + Duration::from_secs(10), + ) + .await; + let EventMsg::ExecApprovalRequest(inner_approval) = approval_event else { + panic!("expected explicit prompt rule approval before completion"); + }; + assert!( + inner_approval + .command + .iter() + .any(|arg| arg.ends_with("/touch")) + && inner_approval + .command + .iter() + .any(|arg| arg == outside_path.to_string_lossy().as_ref()), + "expected explicit prompt rule approval for intercepted touch, got: {:?}", + inner_approval.command + ); + + approve_exec(&test, inner_approval.effective_approval_id()).await?; + wait_for_completion(&test).await; + + let result = command_result(&results, call_id); + assert_eq!( + result.exit_code.unwrap_or(0), + 0, + "approved unified exec zsh-fork prompt-rule command should complete: {}", + result.stdout + ); + assert!( + outside_path.exists(), + "approved intercepted touch should create the out-of-workspace file" + ); + + Ok(()) +} + +struct CommandResult { + exit_code: Option, + stdout: String, +} + +async fn build_unified_exec_zsh_fork_test_or_skip( + test_name: &str, + approval_policy: AskForApproval, + permission_profile: PermissionProfile, + pre_build_hook: F, +) -> Result> +where + F: FnOnce(&Path) + Send + 'static, +{ + let Some(runtime) = zsh_fork_runtime(test_name)? else { + return Ok(None); + }; + + let server = start_mock_server().await; + let test = build_unified_exec_zsh_fork_test( + &server, + runtime, + approval_policy, + permission_profile, + pre_build_hook, + ) + .await?; + Ok(Some((server, test))) +} + +fn denied_read_permission_profile(denied_path: &Path) -> Result { + let denied_path_key = TomlKey::new(denied_path.to_string_lossy().into_owned()); + permission_profile_from_toml(&format!( + r#" +[filesystem] +"/" = "read" +":project_roots" = "write" +{denied_path_key} = "deny" + +[network] +enabled = false +"# + )) +} + +fn permission_profile_from_toml(profile: &str) -> Result { + let profile = toml::from_str::(profile) + .context("test permission profile should deserialize")?; + let filesystem = profile + .filesystem + .as_ref() + .context("test permission profile should include filesystem entries")?; + let entries = filesystem + .entries + .iter() + .map(|(path, permission)| { + let FilesystemPermissionToml::Access(access) = permission else { + anyhow::bail!("unexpected scoped filesystem permission in test profile: {path}"); + }; + let path = match path.as_str() { + "/" => FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + ":project_roots" => FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + }, + _ if *access == FileSystemAccessMode::Deny => FileSystemPath::GlobPattern { + pattern: path.clone(), + }, + _ => anyhow::bail!("unexpected filesystem entry in test profile: {path}"), + }; + Ok(FileSystemSandboxEntry { + path, + access: *access, + }) + }) + .collect::>>()?; + let mut file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(entries); + file_system_sandbox_policy.glob_scan_max_depth = filesystem.glob_scan_max_depth; + let network_sandbox_policy = match profile.network.as_ref().and_then(|network| network.enabled) + { + Some(true) => NetworkSandboxPolicy::Enabled, + Some(false) | None => NetworkSandboxPolicy::Restricted, + }; + + Ok(PermissionProfile::from_runtime_permissions( + &file_system_sandbox_policy, + network_sandbox_policy, + )) +} + +async fn mount_unified_exec_command( + server: &MockServer, + response_prefix: &str, + call_id: &str, + command: &str, + justification: &str, +) -> Result { + let first_response_id = format!("resp-{response_prefix}-1"); + let second_response_id = format!("resp-{response_prefix}-2"); + let message_id = format!("msg-{response_prefix}-1"); + let event = exec_command_event( + call_id, + command, + Some(30_000), + SandboxPermissions::RequireEscalated, + justification, + )?; + let _ = mount_sse_once( + server, + sse(vec![ + ev_response_created(&first_response_id), + event, + ev_completed(&first_response_id), + ]), + ) + .await; + let results = mount_sse_once( + server, + sse(vec![ + ev_assistant_message(&message_id, "done"), + ev_completed(&second_response_id), + ]), + ) + .await; + Ok(results) +} + +async fn submit_turn_with_session_permissions( + test: &TestCodex, + prompt: &str, + approval_policy: AskForApproval, +) -> Result<()> { + let session_model = test.session_configured.model.clone(); + let (sandbox_policy, permission_profile) = turn_permission_fields( + test.session_configured.permission_profile.clone(), + test.cwd.path(), + ); + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: prompt.into(), + text_elements: Vec::new(), + }], + environments: None, + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: ThreadSettingsOverrides { + cwd: Some(test.config.cwd.clone()), + approval_policy: Some(approval_policy), + approvals_reviewer: Some(ApprovalsReviewer::User), + sandbox_policy: Some(sandbox_policy), + permission_profile, + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model: session_model, + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }, + }) + .await?; + + Ok(()) +} + +async fn approve_expected_exec(test: &TestCodex, expected_command: &str) -> Result<()> { + let approval = expect_exec_approval(test, expected_command).await; + approve_exec(test, approval.effective_approval_id()).await +} + +async fn approve_exec(test: &TestCodex, approval_id: String) -> Result<()> { + test.codex + .submit(Op::ExecApproval { + id: approval_id, + turn_id: None, + decision: ReviewDecision::Approved, + }) + .await?; + Ok(()) +} + +fn command_result(results: &ResponseMock, call_id: &str) -> CommandResult { + parse_result(&results.single_request().function_call_output(call_id)) +} + +fn exec_command_event( + call_id: &str, + cmd: &str, + yield_time_ms: Option, + sandbox_permissions: SandboxPermissions, + justification: &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); + args["justification"] = json!(justification); + } + let args_str = serde_json::to_string(&args)?; + Ok(ev_function_call(call_id, "exec_command", &args_str)) +} + +fn parse_result(item: &Value) -> CommandResult { + let Some(output_str) = item.get("output").and_then(Value::as_str) else { + return CommandResult { + exit_code: None, + stdout: String::new(), + }; + }; + 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(_) => parsed_regex_result(r"(?s)^Exit code:\s*(-?\d+).*?Output:\n(.*)$", output_str) + .or_else(|| { + parsed_regex_result( + r"(?s)^.*?Process exited with code (\d+)\n.*?Output:\n(.*)$", + output_str, + ) + }) + .unwrap_or_else(|| CommandResult { + exit_code: None, + stdout: output_str.to_string(), + }), + } +} + +fn parsed_regex_result(pattern: &str, output_str: &str) -> Option { + let regex = Regex::new(pattern).ok()?; + let captures = regex.captures(output_str)?; + let exit_code = captures.get(1)?.as_str().parse::().ok()?; + let output = captures.get(2)?.as_str(); + Some(CommandResult { + exit_code: Some(exit_code), + stdout: output.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 request should be for the parent unified-exec command" + ); + approval + } + EventMsg::TurnComplete(_) => panic!("expected 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; +}