mirror of
https://github.com/openai/codex.git
synced 2026-04-24 14:45:27 +00:00
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:
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
118
codex-rs/core/tests/common/zsh_fork.rs
Normal file
118
codex-rs/core/tests/common/zsh_fork.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user