diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index a26cd5ae77..f324d93a1b 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 0000000000..22ae69303e --- /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 a0530809db..64a1a82573 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 ddd18748f9..3cd6f0e4c9 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()