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 authored and GitHub committed 2026-02-28 01:41:23 +00:00
1 parent 8fa792868c
commit 1a8d930267
6 files changed
+517 -145

No files matched your search

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