fix: make EscalateServer public and remove shell escalation wrappers (#12724)

## Why

`codex-shell-escalation` exposed a `codex-core`-specific adapter layer
(`ShellActionProvider`, `ShellPolicyFactory`, and `run_escalate_server`)
that existed only to bridge `codex-core` to `EscalateServer`. That
indirection increased API surface and obscured crate ownership without
adding behavior.

This change moves orchestration into `codex-core` so boundaries are
clearer: `codex-shell-escalation` provides reusable escalation
primitives, and `codex-core` provides shell-tool policy decisions.

Admittedly, @pakrym rightfully requested this sort of cleanup as part of
https://github.com/openai/codex/pull/12649, though this avoids moving
all of `codex-shell-escalation` into `codex-core`.

## What changed

- Made `EscalateServer` public and exported it from `shell-escalation`.
- Removed the adapter layer from `shell-escalation`:
  - deleted `shell-escalation/src/unix/core_shell_escalation.rs`
- removed exports for `ShellActionProvider`, `ShellPolicyFactory`,
`EscalationPolicyFactory`, and `run_escalate_server`
- Updated `core/src/tools/runtimes/shell/unix_escalation.rs` to:
  - create `Stopwatch`/cancellation in `codex-core`
  - instantiate `EscalateServer` directly
  - implement `EscalationPolicy` directly on `CoreShellActionProvider`

Net effect: same escalation flow with fewer wrappers and a smaller
public API.

## Verification

- Manually reviewed the old vs. new escalation call flow to confirm
timeout/cancellation behavior and approval policy decisions are
preserved while removing wrapper types.
This commit is contained in:
Michael Bolin
2026-02-24 16:20:08 -08:00
committed by GitHub
Unverified
parent 8da40c9251
commit 3d356723c4
7 changed files with 47 additions and 157 deletions
@@ -24,13 +24,12 @@ use codex_protocol::protocol::SandboxPolicy;
use codex_shell_command::bash::parse_shell_lc_plain_commands;
use codex_shell_command::bash::parse_shell_lc_single_command_prefix;
use codex_shell_escalation::EscalateAction;
use codex_shell_escalation::EscalateServer;
use codex_shell_escalation::EscalationPolicy;
use codex_shell_escalation::ExecParams;
use codex_shell_escalation::ExecResult;
use codex_shell_escalation::ShellActionProvider;
use codex_shell_escalation::ShellCommandExecutor;
use codex_shell_escalation::ShellPolicyFactory;
use codex_shell_escalation::Stopwatch;
use codex_shell_escalation::run_escalate_server;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
@@ -107,30 +106,39 @@ pub(super) async fn try_run_zsh_fork(
justification,
arg0,
};
let exec_result = run_escalate_server(
ExecParams {
command: script,
workdir: req.cwd.to_string_lossy().to_string(),
timeout_ms: Some(effective_timeout.as_millis() as u64),
login: Some(login),
},
shell_zsh_path.clone(),
shell_execve_wrapper().map_err(|err| ToolError::Rejected(format!("{err}")))?,
exec_policy.clone(),
ShellPolicyFactory::new(CoreShellActionProvider {
policy: Arc::clone(&exec_policy),
session: Arc::clone(&ctx.session),
turn: Arc::clone(&ctx.turn),
call_id: ctx.call_id.clone(),
approval_policy: ctx.turn.approval_policy.value(),
sandbox_policy: attempt.policy.clone(),
sandbox_permissions: req.sandbox_permissions,
}),
effective_timeout,
&command_executor,
)
.await
.map_err(|err| ToolError::Rejected(err.to_string()))?;
let exec_params = ExecParams {
command: script,
workdir: req.cwd.to_string_lossy().to_string(),
timeout_ms: Some(effective_timeout.as_millis() as u64),
login: Some(login),
};
let execve_wrapper =
shell_execve_wrapper().map_err(|err| ToolError::Rejected(format!("{err}")))?;
// Note that Stopwatch starts immediately upon creation, so currently we try
// to minimize the time between creating the Stopwatch and starting the
// escalation server.
let stopwatch = Stopwatch::new(effective_timeout);
let cancel_token = stopwatch.cancellation_token();
let escalation_policy = CoreShellActionProvider {
policy: Arc::clone(&exec_policy),
session: Arc::clone(&ctx.session),
turn: Arc::clone(&ctx.turn),
call_id: ctx.call_id.clone(),
approval_policy: ctx.turn.approval_policy.value(),
sandbox_policy: attempt.policy.clone(),
sandbox_permissions: req.sandbox_permissions,
stopwatch: stopwatch.clone(),
};
let escalate_server =
EscalateServer::new(shell_zsh_path.clone(), execve_wrapper, escalation_policy);
let exec_result = escalate_server
.exec(exec_params, cancel_token, &command_executor)
.await
.map_err(|err| ToolError::Rejected(err.to_string()))?;
map_exec_result(attempt.sandbox, exec_result).map(Some)
}
@@ -143,6 +151,7 @@ struct CoreShellActionProvider {
approval_policy: AskForApproval,
sandbox_policy: SandboxPolicy,
sandbox_permissions: SandboxPermissions,
stopwatch: Stopwatch,
}
impl CoreShellActionProvider {
@@ -186,13 +195,12 @@ impl CoreShellActionProvider {
}
#[async_trait::async_trait]
impl ShellActionProvider for CoreShellActionProvider {
impl EscalationPolicy for CoreShellActionProvider {
async fn determine_action(
&self,
file: &Path,
argv: &[String],
workdir: &Path,
stopwatch: &Stopwatch,
) -> anyhow::Result<EscalateAction> {
let command = std::iter::once(file.to_string_lossy().to_string())
.chain(argv.iter().cloned())
@@ -240,7 +248,7 @@ impl ShellActionProvider for CoreShellActionProvider {
reason: Some("Execution forbidden by policy".to_string()),
}
} else {
match self.prompt(&command, workdir, stopwatch).await? {
match self.prompt(&command, workdir, &self.stopwatch).await? {
ReviewDecision::Approved
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
| ReviewDecision::ApprovedForSession => {