Skip to content

Commit 1a8d930

Browse files
authored
core: adopt host_executable() rules in zsh-fork (#13046)
## Why [#12964](#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
1 parent 8fa7928 commit 1a8d930

File tree

6 files changed

+515
-143
lines changed

6 files changed

+515
-143
lines changed

codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs

Lines changed: 109 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ use crate::tools::sandboxing::SandboxablePreference;
1616
use crate::tools::sandboxing::ToolCtx;
1717
use crate::tools::sandboxing::ToolError;
1818
use codex_execpolicy::Decision;
19+
use codex_execpolicy::Evaluation;
20+
use codex_execpolicy::MatchOptions;
1921
use codex_execpolicy::Policy;
2022
use codex_execpolicy::RuleMatch;
2123
use codex_protocol::config_types::WindowsSandboxLevel;
@@ -431,6 +433,12 @@ impl CoreShellActionProvider {
431433
}
432434
}
433435

436+
// Shell-wrapper parsing is weaker than direct exec interception because it can
437+
// only see the script text, not the final resolved executable path. Keep it
438+
// disabled by default so path-sensitive rules rely on the later authoritative
439+
// execve interception.
440+
const ENABLE_INTERCEPTED_EXEC_POLICY_SHELL_WRAPPER_PARSING: bool = false;
441+
434442
#[async_trait::async_trait]
435443
impl EscalationPolicy for CoreShellActionProvider {
436444
async fn determine_action(
@@ -493,29 +501,18 @@ impl EscalationPolicy for CoreShellActionProvider {
493501
.await;
494502
}
495503

496-
let command = join_program_and_argv(program, argv);
497-
let (commands, used_complex_parsing) =
498-
if let Some(commands) = parse_shell_lc_plain_commands(&command) {
499-
(commands, false)
500-
} else if let Some(single_command) = parse_shell_lc_single_command_prefix(&command) {
501-
(vec![single_command], true)
502-
} else {
503-
(vec![command.clone()], false)
504-
};
505-
506-
let fallback = |cmd: &[String]| {
507-
crate::exec_policy::render_decision_for_unmatched_command(
504+
let evaluation = {
505+
let policy = self.policy.read().await;
506+
evaluate_intercepted_exec_policy(
507+
&policy,
508+
program,
509+
argv,
508510
self.approval_policy,
509511
&self.sandbox_policy,
510-
cmd,
511512
self.sandbox_permissions,
512-
used_complex_parsing,
513+
ENABLE_INTERCEPTED_EXEC_POLICY_SHELL_WRAPPER_PARSING,
513514
)
514515
};
515-
let evaluation = {
516-
let policy = self.policy.read().await;
517-
policy.check_multiple(commands.iter(), &fallback)
518-
};
519516
// When true, means the Evaluation was due to *.rules, not the
520517
// fallback function.
521518
let decision_driven_by_policy =
@@ -528,16 +525,20 @@ impl EscalationPolicy for CoreShellActionProvider {
528525
} else {
529526
DecisionSource::UnmatchedCommandFallback
530527
};
531-
let escalation_execution = Self::shell_request_escalation_execution(
532-
self.sandbox_permissions,
533-
&self.sandbox_policy,
534-
self.prompt_permissions.as_ref(),
535-
self.turn
536-
.config
537-
.permissions
538-
.macos_seatbelt_profile_extensions
539-
.as_ref(),
540-
);
528+
let escalation_execution = match decision_source {
529+
DecisionSource::PrefixRule => EscalationExecution::Unsandboxed,
530+
DecisionSource::UnmatchedCommandFallback => Self::shell_request_escalation_execution(
531+
self.sandbox_permissions,
532+
&self.sandbox_policy,
533+
self.prompt_permissions.as_ref(),
534+
self.turn
535+
.config
536+
.permissions
537+
.macos_seatbelt_profile_extensions
538+
.as_ref(),
539+
),
540+
DecisionSource::SkillScript { .. } => unreachable!("handled above"),
541+
};
541542
self.process_decision(
542543
evaluation.decision,
543544
needs_escalation,
@@ -552,6 +553,86 @@ impl EscalationPolicy for CoreShellActionProvider {
552553
}
553554
}
554555

556+
fn evaluate_intercepted_exec_policy(
557+
policy: &Policy,
558+
program: &AbsolutePathBuf,
559+
argv: &[String],
560+
approval_policy: AskForApproval,
561+
sandbox_policy: &SandboxPolicy,
562+
sandbox_permissions: SandboxPermissions,
563+
enable_intercepted_exec_policy_shell_wrapper_parsing: bool,
564+
) -> Evaluation {
565+
let CandidateCommands {
566+
commands,
567+
used_complex_parsing,
568+
} = if enable_intercepted_exec_policy_shell_wrapper_parsing {
569+
// In this codepath, the first argument in `commands` could be a bare
570+
// name like `find` instead of an absolute path like `/usr/bin/find`.
571+
// It could also be a shell built-in like `echo`.
572+
commands_for_intercepted_exec_policy(program, argv)
573+
} else {
574+
// In this codepath, `commands` has a single entry where the program
575+
// is always an absolute path.
576+
CandidateCommands {
577+
commands: vec![join_program_and_argv(program, argv)],
578+
used_complex_parsing: false,
579+
}
580+
};
581+
582+
let fallback = |cmd: &[String]| {
583+
crate::exec_policy::render_decision_for_unmatched_command(
584+
approval_policy,
585+
sandbox_policy,
586+
cmd,
587+
sandbox_permissions,
588+
used_complex_parsing,
589+
)
590+
};
591+
592+
policy.check_multiple_with_options(
593+
commands.iter(),
594+
&fallback,
595+
&MatchOptions {
596+
resolve_host_executables: true,
597+
},
598+
)
599+
}
600+
601+
struct CandidateCommands {
602+
commands: Vec<Vec<String>>,
603+
used_complex_parsing: bool,
604+
}
605+
606+
fn commands_for_intercepted_exec_policy(
607+
program: &AbsolutePathBuf,
608+
argv: &[String],
609+
) -> CandidateCommands {
610+
if let [_, flag, script] = argv {
611+
let shell_command = [
612+
program.to_string_lossy().to_string(),
613+
flag.clone(),
614+
script.clone(),
615+
];
616+
if let Some(commands) = parse_shell_lc_plain_commands(&shell_command) {
617+
return CandidateCommands {
618+
commands,
619+
used_complex_parsing: false,
620+
};
621+
}
622+
if let Some(single_command) = parse_shell_lc_single_command_prefix(&shell_command) {
623+
return CandidateCommands {
624+
commands: vec![single_command],
625+
used_complex_parsing: true,
626+
};
627+
}
628+
}
629+
630+
CandidateCommands {
631+
commands: vec![join_program_and_argv(program, argv)],
632+
used_complex_parsing: false,
633+
}
634+
}
635+
555636
struct CoreShellCommandExecutor {
556637
command: Vec<String>,
557638
cwd: PathBuf,

0 commit comments

Comments
 (0)