feat: run zsh fork shell tool via shell-escalation (#12649)

## Why

This PR switches the `shell_command` zsh-fork path over to
`codex-shell-escalation` so the new shell tool can use the shared
exec-wrapper/escalation protocol instead of the `zsh_exec_bridge`
implementation that was introduced in
https://github.com/openai/codex/pull/12052. `zsh_exec_bridge` relied on
UNIX domain sockets, which is not as tamper-proof as the FD-based
approach in `codex-shell-escalation`.

## What Changed

- Added a Unix zsh-fork runtime adapter in `core`
(`core/src/tools/runtimes/shell/unix_escalation.rs`) that:
- runs zsh-fork commands through
`codex_shell_escalation::run_escalate_server`
  - bridges exec-policy / approval decisions into `ShellActionProvider`
- executes escalated commands via a `ShellCommandExecutor` that calls
`process_exec_tool_call`
- Updated `ShellRuntime` / `ShellCommandHandler` / tool spec wiring to
select a `shell_command` backend (`classic` vs `zsh-fork`) while leaving
the generic `shell` tool path unchanged.
- Removed the `zsh_exec_bridge`-based session service and deleted
`core/src/zsh_exec_bridge/mod.rs`.
- Moved exec-wrapper entrypoint dispatch to `arg0` by handling the
`codex-execve-wrapper` arg0 alias there, and removed the old
`codex_core::maybe_run_zsh_exec_wrapper_mode()` hooks from `cli` and
`app-server` mains.
- Added the needed `codex-shell-escalation` dependencies for `core` and
`arg0`.

## Tests

- `cargo test -p codex-core
shell_zsh_fork_prefers_shell_command_over_unified_exec`
- `cargo test -p codex-app-server turn_start_shell_zsh_fork --
--nocapture`
- verifies zsh-fork command execution and approval flows through the new
backend
- includes subcommand approve/decline coverage using the shared zsh
DotSlash fixture in `app-server/tests/suite/zsh`
- To test manually, I added the following to `~/.codex/config.toml`:

```toml
zsh_path = "/Users/mbolin/code/codex3/codex-rs/app-server/tests/suite/zsh"

[features]
shell_zsh_fork = true
```

Then I ran `just c` to run the dev build of Codex with these changes and
sent it the message:

```
run `echo $0`
```

And it replied with:

```
  echo $0 printed:

  /Users/mbolin/code/codex3/codex-rs/app-server/tests/suite/zsh

  In this tool context, $0 reflects the script path used to invoke the shell, not just zsh.
```

so the tool appears to be wired up correctly.

## Notes

- The zsh subcommand-decline integration test now uses `rm` under a
`WorkspaceWrite` sandbox. The previous `/usr/bin/true` scenario is
auto-allowed by the new `shell-escalation` policy path, which no longer
produces subcommand approval prompts.
This commit is contained in:
Michael Bolin
2026-02-24 10:31:08 -08:00
committed by GitHub
parent 8d3d58f992
commit 3ca0e7673b
24 changed files with 706 additions and 723 deletions

View File

@@ -22,6 +22,6 @@ pub use unix::Stopwatch;
#[cfg(unix)]
pub use unix::main_execve_wrapper;
#[cfg(unix)]
pub use unix::run;
#[cfg(unix)]
pub use unix::run_escalate_server;
#[cfg(unix)]
pub use unix::run_shell_escalation_execve_wrapper;

View File

@@ -42,7 +42,6 @@ impl ShellPolicyFactory {
/// Public only because it is the associated `Policy` type in the public
/// `EscalationPolicyFactory` impl for `ShellPolicyFactory`.
#[doc(hidden)]
pub struct ShellEscalationPolicy {
provider: Arc<dyn ShellActionProvider>,
stopwatch: Stopwatch,

View File

@@ -27,7 +27,10 @@ fn get_escalate_client() -> anyhow::Result<AsyncDatagramSocket> {
Ok(unsafe { AsyncDatagramSocket::from_raw_fd(client_fd) }?)
}
pub async fn run(file: String, argv: Vec<String>) -> anyhow::Result<i32> {
pub async fn run_shell_escalation_execve_wrapper(
file: String,
argv: Vec<String>,
) -> anyhow::Result<i32> {
let handshake_client = get_escalate_client()?;
let (server, client) = AsyncSocket::pair()?;
const HANDSHAKE_MESSAGE: [u8; 1] = [0];

View File

@@ -45,7 +45,7 @@ pub trait ShellCommandExecutor: Send + Sync {
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct ExecParams {
/// The bash string to execute.
/// The the string of Zsh/shell to execute.
pub command: String,
/// The working directory to execute the command in. Must be an absolute path.
pub workdir: String,
@@ -58,20 +58,22 @@ pub struct ExecParams {
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct ExecResult {
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
/// Aggregated stdout+stderr output for compatibility with existing callers.
pub output: String,
pub duration: Duration,
pub timed_out: bool,
}
#[allow(clippy::module_name_repetitions)]
pub struct EscalateServer {
struct EscalateServer {
bash_path: PathBuf,
execve_wrapper: PathBuf,
policy: Arc<dyn EscalationPolicy>,
}
impl EscalateServer {
pub fn new<P>(bash_path: PathBuf, execve_wrapper: PathBuf, policy: P) -> Self
fn new<P>(bash_path: PathBuf, execve_wrapper: PathBuf, policy: P) -> Self
where
P: EscalationPolicy + Send + Sync + 'static,
{
@@ -82,7 +84,7 @@ impl EscalateServer {
}
}
pub async fn exec(
async fn exec(
&self,
params: ExecParams,
cancel_rx: CancellationToken,

View File

@@ -20,6 +20,6 @@ pub async fn main_execve_wrapper() -> anyhow::Result<()> {
.init();
let ExecveWrapperCli { file, argv } = ExecveWrapperCli::parse();
let exit_code = crate::run(file, argv).await?;
let exit_code = crate::run_shell_escalation_execve_wrapper(file, argv).await?;
std::process::exit(exit_code);
}

View File

@@ -64,7 +64,7 @@ pub mod stopwatch;
pub use self::core_shell_escalation::ShellActionProvider;
pub use self::core_shell_escalation::ShellPolicyFactory;
pub use self::escalate_client::run;
pub use self::escalate_client::run_shell_escalation_execve_wrapper;
pub use self::escalate_protocol::EscalateAction;
pub use self::escalate_server::EscalationPolicyFactory;
pub use self::escalate_server::ExecParams;