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 ae1df34ec..5288fd8e8 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -16,6 +16,8 @@ use crate::tools::sandboxing::SandboxablePreference; use crate::tools::sandboxing::ToolCtx; use crate::tools::sandboxing::ToolError; use codex_execpolicy::Decision; +use codex_execpolicy::Evaluation; +use codex_execpolicy::MatchOptions; use codex_execpolicy::Policy; use codex_execpolicy::RuleMatch; use codex_protocol::config_types::WindowsSandboxLevel; @@ -431,6 +433,12 @@ impl CoreShellActionProvider { } } +// Shell-wrapper parsing is weaker than direct exec interception because it can +// only see the script text, not the final resolved executable path. Keep it +// disabled by default so path-sensitive rules rely on the later authoritative +// execve interception. +const ENABLE_INTERCEPTED_EXEC_POLICY_SHELL_WRAPPER_PARSING: bool = false; + #[async_trait::async_trait] impl EscalationPolicy for CoreShellActionProvider { async fn determine_action( @@ -493,28 +501,17 @@ impl EscalationPolicy for CoreShellActionProvider { .await; } - let command = join_program_and_argv(program, argv); - let (commands, used_complex_parsing) = - if let Some(commands) = parse_shell_lc_plain_commands(&command) { - (commands, false) - } else if let Some(single_command) = parse_shell_lc_single_command_prefix(&command) { - (vec![single_command], true) - } else { - (vec![command.clone()], false) - }; - - let fallback = |cmd: &[String]| { - crate::exec_policy::render_decision_for_unmatched_command( - self.approval_policy, - &self.sandbox_policy, - cmd, - self.sandbox_permissions, - used_complex_parsing, - ) - }; let evaluation = { let policy = self.policy.read().await; - policy.check_multiple(commands.iter(), &fallback) + evaluate_intercepted_exec_policy( + &policy, + program, + argv, + self.approval_policy, + &self.sandbox_policy, + self.sandbox_permissions, + ENABLE_INTERCEPTED_EXEC_POLICY_SHELL_WRAPPER_PARSING, + ) }; // When true, means the Evaluation was due to *.rules, not the // fallback function. @@ -528,16 +525,20 @@ impl EscalationPolicy for CoreShellActionProvider { } else { DecisionSource::UnmatchedCommandFallback }; - let escalation_execution = Self::shell_request_escalation_execution( - self.sandbox_permissions, - &self.sandbox_policy, - self.prompt_permissions.as_ref(), - self.turn - .config - .permissions - .macos_seatbelt_profile_extensions - .as_ref(), - ); + let escalation_execution = match decision_source { + DecisionSource::PrefixRule => EscalationExecution::Unsandboxed, + DecisionSource::UnmatchedCommandFallback => Self::shell_request_escalation_execution( + self.sandbox_permissions, + &self.sandbox_policy, + self.prompt_permissions.as_ref(), + self.turn + .config + .permissions + .macos_seatbelt_profile_extensions + .as_ref(), + ), + DecisionSource::SkillScript { .. } => unreachable!("handled above"), + }; self.process_decision( evaluation.decision, needs_escalation, @@ -552,6 +553,86 @@ impl EscalationPolicy for CoreShellActionProvider { } } +fn evaluate_intercepted_exec_policy( + policy: &Policy, + program: &AbsolutePathBuf, + argv: &[String], + approval_policy: AskForApproval, + sandbox_policy: &SandboxPolicy, + sandbox_permissions: SandboxPermissions, + enable_intercepted_exec_policy_shell_wrapper_parsing: bool, +) -> Evaluation { + let CandidateCommands { + commands, + used_complex_parsing, + } = if enable_intercepted_exec_policy_shell_wrapper_parsing { + // In this codepath, the first argument in `commands` could be a bare + // name like `find` instead of an absolute path like `/usr/bin/find`. + // It could also be a shell built-in like `echo`. + commands_for_intercepted_exec_policy(program, argv) + } else { + // In this codepath, `commands` has a single entry where the program + // is always an absolute path. + CandidateCommands { + commands: vec![join_program_and_argv(program, argv)], + used_complex_parsing: false, + } + }; + + let fallback = |cmd: &[String]| { + crate::exec_policy::render_decision_for_unmatched_command( + approval_policy, + sandbox_policy, + cmd, + sandbox_permissions, + used_complex_parsing, + ) + }; + + policy.check_multiple_with_options( + commands.iter(), + &fallback, + &MatchOptions { + resolve_host_executables: true, + }, + ) +} + +struct CandidateCommands { + commands: Vec>, + used_complex_parsing: bool, +} + +fn commands_for_intercepted_exec_policy( + program: &AbsolutePathBuf, + argv: &[String], +) -> CandidateCommands { + if let [_, flag, script] = argv { + let shell_command = [ + program.to_string_lossy().to_string(), + flag.clone(), + script.clone(), + ]; + if let Some(commands) = parse_shell_lc_plain_commands(&shell_command) { + return CandidateCommands { + commands, + used_complex_parsing: false, + }; + } + if let Some(single_command) = parse_shell_lc_single_command_prefix(&shell_command) { + return CandidateCommands { + commands: vec![single_command], + used_complex_parsing: true, + }; + } + } + + CandidateCommands { + commands: vec![join_program_and_argv(program, argv)], + used_complex_parsing: false, + } +} + struct CoreShellCommandExecutor { command: Vec, cwd: PathBuf, 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 519608a43..b88f5b054 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 @@ -2,6 +2,8 @@ use super::CoreShellActionProvider; #[cfg(target_os = "macos")] use super::CoreShellCommandExecutor; use super::ParsedShellCommand; +use super::commands_for_intercepted_exec_policy; +use super::evaluate_intercepted_exec_policy; use super::extract_shell_script; use super::join_program_and_argv; use super::map_exec_result; @@ -12,14 +14,16 @@ use crate::config::Permissions; #[cfg(target_os = "macos")] use crate::config::types::ShellEnvironmentPolicy; use crate::exec::SandboxType; -#[cfg(target_os = "macos")] use crate::protocol::AskForApproval; use crate::protocol::ReadOnlyAccess; use crate::protocol::SandboxPolicy; -#[cfg(target_os = "macos")] use crate::sandboxing::SandboxPermissions; #[cfg(target_os = "macos")] use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE; +use codex_execpolicy::Decision; +use codex_execpolicy::Evaluation; +use codex_execpolicy::PolicyParser; +use codex_execpolicy::RuleMatch; #[cfg(target_os = "macos")] use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::FileSystemPermissions; @@ -36,8 +40,25 @@ use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; #[cfg(target_os = "macos")] use std::collections::HashMap; +use std::path::PathBuf; use std::time::Duration; +fn host_absolute_path(segments: &[&str]) -> String { + let mut path = if cfg!(windows) { + PathBuf::from(r"C:\") + } else { + PathBuf::from("/") + }; + for segment in segments { + path.push(segment); + } + path.to_string_lossy().into_owned() +} + +fn starlark_string(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} + #[test] fn extract_shell_script_preserves_login_flag() { assert_eq!( @@ -126,6 +147,24 @@ fn join_program_and_argv_replaces_original_argv_zero() { ); } +#[test] +fn commands_for_intercepted_exec_policy_parses_plain_shell_wrappers() { + let program = AbsolutePathBuf::try_from(host_absolute_path(&["bin", "bash"])).unwrap(); + let candidate_commands = commands_for_intercepted_exec_policy( + &program, + &["not-bash".into(), "-lc".into(), "git status && pwd".into()], + ); + + assert_eq!( + candidate_commands.commands, + vec![ + vec!["git".to_string(), "status".to_string()], + vec!["pwd".to_string()], + ] + ); + assert!(!candidate_commands.used_complex_parsing); +} + #[test] fn map_exec_result_preserves_stdout_and_stderr() { let out = map_exec_result( @@ -203,6 +242,171 @@ fn shell_request_escalation_execution_is_explicit() { ); } +#[test] +fn evaluate_intercepted_exec_policy_uses_wrapper_command_when_shell_wrapper_parsing_disabled() { + let policy_src = r#"prefix_rule(pattern = ["npm", "publish"], decision = "prompt")"#; + let mut parser = PolicyParser::new(); + parser.parse("test.rules", policy_src).unwrap(); + let policy = parser.build(); + let program = AbsolutePathBuf::try_from(host_absolute_path(&["bin", "zsh"])).unwrap(); + + let enable_intercepted_exec_policy_shell_wrapper_parsing = false; + let evaluation = evaluate_intercepted_exec_policy( + &policy, + &program, + &[ + "zsh".to_string(), + "-lc".to_string(), + "npm publish".to_string(), + ], + AskForApproval::OnRequest, + &SandboxPolicy::new_read_only_policy(), + SandboxPermissions::UseDefault, + enable_intercepted_exec_policy_shell_wrapper_parsing, + ); + + assert!( + matches!( + evaluation.matched_rules.as_slice(), + [RuleMatch::HeuristicsRuleMatch { command, decision: Decision::Allow }] + if command == &vec![ + program.to_string_lossy().to_string(), + "-lc".to_string(), + "npm publish".to_string(), + ] + ), + r#"This is allowed because when shell wrapper parsing is disabled, +the policy evaluation does not try to parse the shell command and instead +matches the whole command line with the resolved program path, which in this +case is `/bin/zsh` followed by some arguments. + +Because there is no policy rule for `/bin/zsh` or `zsh`, the decision is to +allow the command and let the sandbox be responsible for enforcing any +restrictions. + +That said, if /bin/zsh is the zsh-fork, then the execve wrapper should +ultimately intercept the `npm publish` command and apply the policy rules to it. +"# + ); +} + +#[test] +fn evaluate_intercepted_exec_policy_matches_inner_shell_commands_when_enabled() { + let policy_src = r#"prefix_rule(pattern = ["npm", "publish"], decision = "prompt")"#; + let mut parser = PolicyParser::new(); + parser.parse("test.rules", policy_src).unwrap(); + let policy = parser.build(); + let program = AbsolutePathBuf::try_from(host_absolute_path(&["bin", "bash"])).unwrap(); + + let enable_intercepted_exec_policy_shell_wrapper_parsing = true; + let evaluation = evaluate_intercepted_exec_policy( + &policy, + &program, + &[ + "bash".to_string(), + "-lc".to_string(), + "npm publish".to_string(), + ], + AskForApproval::OnRequest, + &SandboxPolicy::new_read_only_policy(), + SandboxPermissions::UseDefault, + enable_intercepted_exec_policy_shell_wrapper_parsing, + ); + + assert_eq!( + evaluation, + Evaluation { + decision: Decision::Prompt, + matched_rules: vec![RuleMatch::PrefixRuleMatch { + matched_prefix: vec!["npm".to_string(), "publish".to_string()], + decision: Decision::Prompt, + resolved_program: None, + justification: None, + }], + } + ); +} + +#[test] +fn intercepted_exec_policy_uses_host_executable_mappings() { + let git_path = host_absolute_path(&["usr", "bin", "git"]); + let git_path_literal = starlark_string(&git_path); + let policy_src = format!( + r#" +prefix_rule(pattern = ["git", "status"], decision = "prompt") +host_executable(name = "git", paths = ["{git_path_literal}"]) +"# + ); + let mut parser = PolicyParser::new(); + parser.parse("test.rules", &policy_src).unwrap(); + let policy = parser.build(); + let program = AbsolutePathBuf::try_from(git_path).unwrap(); + + let evaluation = evaluate_intercepted_exec_policy( + &policy, + &program, + &["git".to_string(), "status".to_string()], + AskForApproval::OnRequest, + &SandboxPolicy::new_read_only_policy(), + SandboxPermissions::UseDefault, + false, + ); + + assert_eq!( + evaluation, + Evaluation { + decision: Decision::Prompt, + matched_rules: vec![RuleMatch::PrefixRuleMatch { + matched_prefix: vec!["git".to_string(), "status".to_string()], + decision: Decision::Prompt, + resolved_program: Some(program), + justification: None, + }], + } + ); + assert!(CoreShellActionProvider::decision_driven_by_policy( + &evaluation.matched_rules, + evaluation.decision + )); +} + +#[test] +fn intercepted_exec_policy_rejects_disallowed_host_executable_mapping() { + let allowed_git = host_absolute_path(&["usr", "bin", "git"]); + let other_git = host_absolute_path(&["opt", "homebrew", "bin", "git"]); + let allowed_git_literal = starlark_string(&allowed_git); + let policy_src = format!( + r#" +prefix_rule(pattern = ["git", "status"], decision = "prompt") +host_executable(name = "git", paths = ["{allowed_git_literal}"]) +"# + ); + let mut parser = PolicyParser::new(); + parser.parse("test.rules", &policy_src).unwrap(); + let policy = parser.build(); + let program = AbsolutePathBuf::try_from(other_git.clone()).unwrap(); + + let evaluation = evaluate_intercepted_exec_policy( + &policy, + &program, + &["git".to_string(), "status".to_string()], + AskForApproval::OnRequest, + &SandboxPolicy::new_read_only_policy(), + SandboxPermissions::UseDefault, + false, + ); + + assert!(matches!( + evaluation.matched_rules.as_slice(), + [RuleMatch::HeuristicsRuleMatch { command, .. }] + if command == &vec![other_git, "status".to_string()] + )); + assert!(!CoreShellActionProvider::decision_driven_by_policy( + &evaluation.matched_rules, + evaluation.decision + )); +} + #[cfg(target_os = "macos")] #[tokio::test] async fn prepare_escalated_exec_turn_default_preserves_macos_seatbelt_extensions() { diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index a26cd5ae7..f324d93a1 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -21,6 +21,7 @@ pub mod responses; pub mod streaming_sse; pub mod test_codex; pub mod test_codex_exec; +pub mod zsh_fork; #[ctor] fn enable_deterministic_unified_exec_process_ids_for_tests() { diff --git a/codex-rs/core/tests/common/zsh_fork.rs b/codex-rs/core/tests/common/zsh_fork.rs new file mode 100644 index 000000000..22ae69303 --- /dev/null +++ b/codex-rs/core/tests/common/zsh_fork.rs @@ -0,0 +1,118 @@ +use std::path::Path; +use std::path::PathBuf; + +use anyhow::Result; +use codex_core::config::Config; +use codex_core::config::Constrained; +use codex_core::features::Feature; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; + +use crate::test_codex::TestCodex; +use crate::test_codex::test_codex; + +#[derive(Clone)] +pub struct ZshForkRuntime { + zsh_path: PathBuf, + main_execve_wrapper_exe: PathBuf, +} + +impl ZshForkRuntime { + fn apply_to_config( + &self, + config: &mut Config, + approval_policy: AskForApproval, + sandbox_policy: SandboxPolicy, + ) { + config.features.enable(Feature::ShellTool); + config.features.enable(Feature::ShellZshFork); + config.zsh_path = Some(self.zsh_path.clone()); + config.main_execve_wrapper_exe = Some(self.main_execve_wrapper_exe.clone()); + config.permissions.allow_login_shell = false; + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy); + } +} + +pub fn restrictive_workspace_write_policy() -> SandboxPolicy { + SandboxPolicy::WorkspaceWrite { + writable_roots: Vec::new(), + read_only_access: Default::default(), + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + } +} + +pub fn zsh_fork_runtime(test_name: &str) -> Result> { + let Some(zsh_path) = find_test_zsh_path()? else { + return Ok(None); + }; + if !supports_exec_wrapper_intercept(&zsh_path) { + eprintln!( + "skipping {test_name}: zsh does not support EXEC_WRAPPER intercepts ({})", + zsh_path.display() + ); + return Ok(None); + } + let Ok(main_execve_wrapper_exe) = codex_utils_cargo_bin::cargo_bin("codex-execve-wrapper") + else { + eprintln!("skipping {test_name}: unable to resolve `codex-execve-wrapper` binary"); + return Ok(None); + }; + + Ok(Some(ZshForkRuntime { + zsh_path, + main_execve_wrapper_exe, + })) +} + +pub async fn build_zsh_fork_test( + server: &wiremock::MockServer, + runtime: ZshForkRuntime, + approval_policy: AskForApproval, + sandbox_policy: SandboxPolicy, + 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, sandbox_policy); + }); + 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"); + if !dotslash_zsh.is_file() { + eprintln!( + "skipping zsh-fork test: shared zsh DotSlash file not found at {}", + dotslash_zsh.display() + ); + return Ok(None); + } + + match crate::fetch_dotslash_file(&dotslash_zsh, None) { + Ok(path) => Ok(Some(path)), + Err(error) => { + eprintln!("skipping zsh-fork test: failed to fetch zsh via dotslash: {error:#}"); + Ok(None) + } + } +} + +fn supports_exec_wrapper_intercept(zsh_path: &Path) -> bool { + let status = std::process::Command::new(zsh_path) + .arg("-fc") + .arg("/usr/bin/true") + .env("EXEC_WRAPPER", "/usr/bin/false") + .status(); + match status { + Ok(status) => !status.success(), + Err(_) => false, + } +} diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index a0530809d..64a1a8257 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -35,6 +35,9 @@ use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use core_test_support::wait_for_event_with_timeout; +use core_test_support::zsh_fork::build_zsh_fork_test; +use core_test_support::zsh_fork::restrictive_workspace_write_policy; +use core_test_support::zsh_fork::zsh_fork_runtime; use pretty_assertions::assert_eq; use regex_lite::Regex; use serde_json::Value; @@ -1978,6 +1981,81 @@ async fn approving_execpolicy_amendment_persists_policy_and_skips_future_prompts Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[cfg(unix)] +async fn matched_prefix_rule_runs_unsandboxed_under_zsh_fork() -> Result<()> { + skip_if_no_network!(Ok(())); + + let Some(runtime) = zsh_fork_runtime("zsh-fork prefix rule unsandboxed test")? else { + return Ok(()); + }; + + let approval_policy = AskForApproval::Never; + let sandbox_policy = restrictive_workspace_write_policy(); + let outside_dir = tempfile::tempdir_in(std::env::current_dir()?)?; + let outside_path = outside_dir + .path() + .join("zsh-fork-prefix-rule-unsandboxed.txt"); + let command = format!("touch {outside_path:?}"); + let rules = r#"prefix_rule(pattern=["touch"], decision="allow")"#.to_string(); + + let server = start_mock_server().await; + let outside_path_for_hook = outside_path.clone(); + let test = build_zsh_fork_test( + &server, + runtime, + approval_policy, + sandbox_policy.clone(), + 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?; + + let call_id = "zsh-fork-prefix-rule-unsandboxed"; + let event = shell_event(call_id, &command, 1_000, SandboxPermissions::UseDefault)?; + let _ = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-zsh-fork-prefix-1"), + event, + ev_completed("resp-zsh-fork-prefix-1"), + ]), + ) + .await; + let results = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-zsh-fork-prefix-1", "done"), + ev_completed("resp-zsh-fork-prefix-2"), + ]), + ) + .await; + + submit_turn( + &test, + "run allowed touch under zsh fork", + approval_policy, + sandbox_policy, + ) + .await?; + + wait_for_completion_without_approval(&test).await; + + let result = parse_result(&results.single_request().function_call_output(call_id)); + assert_eq!(result.exit_code.unwrap_or(0), 0); + assert!( + outside_path.exists(), + "expected matched prefix_rule to rerun touch unsandboxed; output: {}", + result.stdout + ); + + Ok(()) +} + #[tokio::test(flavor = "current_thread")] #[cfg(unix)] async fn invalid_requested_prefix_rule_falls_back_for_compound_command() -> Result<()> { diff --git a/codex-rs/core/tests/suite/skill_approval.rs b/codex-rs/core/tests/suite/skill_approval.rs index 846152a9e..84e1d2408 100644 --- a/codex-rs/core/tests/suite/skill_approval.rs +++ b/codex-rs/core/tests/suite/skill_approval.rs @@ -2,8 +2,6 @@ #![cfg(unix)] use anyhow::Result; -use codex_core::config::Config; -use codex_core::features::Feature; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; @@ -18,9 +16,11 @@ use core_test_support::responses::mount_function_call_agent_response; 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::test_codex; use core_test_support::wait_for_event; use core_test_support::wait_for_event_match; +use core_test_support::zsh_fork::build_zsh_fork_test; +use core_test_support::zsh_fork::restrictive_workspace_write_policy; +use core_test_support::zsh_fork::zsh_fork_runtime; use pretty_assertions::assert_eq; use serde_json::json; use std::fs; @@ -117,116 +117,6 @@ description: {name} skill Ok(script_path) } -fn find_test_zsh_path() -> Result> { - use core_test_support::fetch_dotslash_file; - - let repo_root = codex_utils_cargo_bin::repo_root()?; - let dotslash_zsh = repo_root.join("codex-rs/app-server/tests/suite/zsh"); - if !dotslash_zsh.is_file() { - eprintln!( - "skipping zsh-fork skill test: shared zsh DotSlash file not found at {}", - dotslash_zsh.display() - ); - return Ok(None); - } - - match fetch_dotslash_file(&dotslash_zsh, None) { - Ok(path) => Ok(Some(path)), - Err(error) => { - eprintln!("skipping zsh-fork skill test: failed to fetch zsh via dotslash: {error:#}"); - Ok(None) - } - } -} - -fn supports_exec_wrapper_intercept(zsh_path: &Path) -> bool { - let status = std::process::Command::new(zsh_path) - .arg("-fc") - .arg("/usr/bin/true") - .env("EXEC_WRAPPER", "/usr/bin/false") - .status(); - match status { - Ok(status) => !status.success(), - Err(_) => false, - } -} - -#[derive(Clone)] -struct ZshForkRuntime { - zsh_path: PathBuf, - main_execve_wrapper_exe: PathBuf, -} - -impl ZshForkRuntime { - fn apply_to_config( - &self, - config: &mut Config, - approval_policy: AskForApproval, - sandbox_policy: SandboxPolicy, - ) { - use codex_config::Constrained; - - config.features.enable(Feature::ShellTool); - config.features.enable(Feature::ShellZshFork); - config.zsh_path = Some(self.zsh_path.clone()); - config.main_execve_wrapper_exe = Some(self.main_execve_wrapper_exe.clone()); - config.permissions.allow_login_shell = false; - config.permissions.approval_policy = Constrained::allow_any(approval_policy); - config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy); - } -} - -fn restrictive_workspace_write_policy() -> SandboxPolicy { - SandboxPolicy::WorkspaceWrite { - writable_roots: Vec::new(), - read_only_access: Default::default(), - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - } -} - -fn zsh_fork_runtime(test_name: &str) -> Result> { - let Some(zsh_path) = find_test_zsh_path()? else { - return Ok(None); - }; - if !supports_exec_wrapper_intercept(&zsh_path) { - eprintln!( - "skipping {test_name}: zsh does not support EXEC_WRAPPER intercepts ({})", - zsh_path.display() - ); - return Ok(None); - } - let Ok(main_execve_wrapper_exe) = codex_utils_cargo_bin::cargo_bin("codex-execve-wrapper") - else { - eprintln!("skipping {test_name}: unable to resolve `codex-execve-wrapper` binary"); - return Ok(None); - }; - - Ok(Some(ZshForkRuntime { - zsh_path, - main_execve_wrapper_exe, - })) -} - -async fn build_zsh_fork_test( - server: &wiremock::MockServer, - runtime: ZshForkRuntime, - approval_policy: AskForApproval, - sandbox_policy: SandboxPolicy, - 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, sandbox_policy); - }); - builder.build(server).await -} - fn skill_script_command(test: &TestCodex, script_name: &str) -> Result<(String, String)> { let script_path = fs::canonicalize( test.codex_home_path()