core: adopt host_executable() rules in zsh-fork (#13046)

## Why

[#12964](https://github.com/openai/codex/pull/12964) added
`host_executable()` support to `codex-execpolicy`, but the zsh-fork
interception path in `unix_escalation.rs` was still evaluating commands
with the default exact-token matcher.

That meant an intercepted absolute executable such as `/usr/bin/git
status` could still miss basename rules like `prefix_rule(pattern =
["git", "status"])`, even when the policy also defined a matching
`host_executable(name = "git", ...)` entry.

This PR adopts the new matching behavior in the zsh-fork runtime only.
That keeps the rollout intentionally narrow: zsh-fork already requires
explicit user opt-in, so it is a safer first caller to exercise the new
`host_executable()` scheme before expanding it to other execpolicy call
sites.

It also brings zsh-fork back in line with the current `prefix_rule()`
execution model. Until prefix rules can carry their own permission
profiles, a matched `prefix_rule()` is expected to rerun the intercepted
command unsandboxed on `allow`, or after the user accepts `prompt`,
instead of merely continuing inside the inherited shell sandbox.

## What Changed

- added `evaluate_intercepted_exec_policy()` in
`core/src/tools/runtimes/shell/unix_escalation.rs` to centralize
execpolicy evaluation for intercepted commands
- switched intercepted direct execs in the zsh-fork path to
`check_multiple_with_options(...)` with `MatchOptions {
resolve_host_executables: true }`
- added `commands_for_intercepted_exec_policy()` so zsh-fork policy
evaluation works from intercepted `(program, argv)` data instead of
reconstructing a synthetic command before matching
- left shell-wrapper parsing intentionally disabled by default behind
`ENABLE_INTERCEPTED_EXEC_POLICY_SHELL_WRAPPER_PARSING`, so
path-sensitive matching relies on later direct exec interception rather
than shell-script parsing
- made matched `prefix_rule()` decisions rerun intercepted commands with
`EscalationExecution::Unsandboxed`, while unmatched-command fallback
keeps the existing sandbox-preserving behavior
- extracted the zsh-fork test harness into
`core/tests/common/zsh_fork.rs` so both the skill-focused and
approval-focused integration suites can exercise the same runtime setup
- limited this change to the intercepted zsh-fork path rather than
changing every execpolicy caller at once
- added runtime coverage in
`core/src/tools/runtimes/shell/unix_escalation_tests.rs` for allowed and
disallowed `host_executable()` mappings and the wrapper-parsing modes
- added integration coverage in `core/tests/suite/approvals.rs` to
verify a saved `prefix_rule(pattern=["touch"], decision="allow")` reruns
under zsh-fork outside a restrictive `WorkspaceWrite` sandbox

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/13046).
* #13065
* __->__ #13046
This commit is contained in:
Michael Bolin
2026-02-27 17:41:23 -08:00
committed by GitHub
parent 8fa792868c
commit 1a8d930267
6 changed files with 517 additions and 145 deletions

View File

@@ -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<Vec<String>>,
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<String>,
cwd: PathBuf,

View File

@@ -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() {

View File

@@ -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() {

View File

@@ -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<Option<ZshForkRuntime>> {
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<F>(
server: &wiremock::MockServer,
runtime: ZshForkRuntime,
approval_policy: AskForApproval,
sandbox_policy: SandboxPolicy,
pre_build_hook: F,
) -> Result<TestCodex>
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<Option<PathBuf>> {
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,
}
}

View File

@@ -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<()> {

View File

@@ -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<Option<PathBuf>> {
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<Option<ZshForkRuntime>> {
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<F>(
server: &wiremock::MockServer,
runtime: ZshForkRuntime,
approval_policy: AskForApproval,
sandbox_policy: SandboxPolicy,
pre_build_hook: F,
) -> Result<TestCodex>
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()