mirror of
https://github.com/openai/codex.git
synced 2026-06-01 19:02:59 +00:00
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:
@@ -28,12 +28,22 @@ use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
use crate::tools::runtimes::shell::ShellRequest;
|
||||
use crate::tools::runtimes::shell::ShellRuntime;
|
||||
use crate::tools::runtimes::shell::ShellRuntimeBackend;
|
||||
use crate::tools::sandboxing::ToolCtx;
|
||||
use crate::tools::spec::ShellCommandBackendConfig;
|
||||
use codex_protocol::models::AdditionalPermissions;
|
||||
|
||||
pub struct ShellHandler;
|
||||
|
||||
pub struct ShellCommandHandler;
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum ShellCommandBackend {
|
||||
Classic,
|
||||
ZshFork,
|
||||
}
|
||||
|
||||
pub struct ShellCommandHandler {
|
||||
backend: ShellCommandBackend,
|
||||
}
|
||||
|
||||
struct RunExecLikeArgs {
|
||||
tool_name: String,
|
||||
@@ -45,6 +55,7 @@ struct RunExecLikeArgs {
|
||||
tracker: crate::tools::context::SharedTurnDiffTracker,
|
||||
call_id: String,
|
||||
freeform: bool,
|
||||
shell_runtime_backend: ShellRuntimeBackend,
|
||||
}
|
||||
|
||||
impl ShellHandler {
|
||||
@@ -68,6 +79,13 @@ impl ShellHandler {
|
||||
}
|
||||
|
||||
impl ShellCommandHandler {
|
||||
fn shell_runtime_backend(&self) -> ShellRuntimeBackend {
|
||||
match self.backend {
|
||||
ShellCommandBackend::Classic => ShellRuntimeBackend::ShellCommandClassic,
|
||||
ShellCommandBackend::ZshFork => ShellRuntimeBackend::ShellCommandZshFork,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_use_login_shell(
|
||||
login: Option<bool>,
|
||||
allow_login_shell: bool,
|
||||
@@ -110,6 +128,16 @@ impl ShellCommandHandler {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ShellCommandBackendConfig> for ShellCommandHandler {
|
||||
fn from(config: ShellCommandBackendConfig) -> Self {
|
||||
let backend = match config {
|
||||
ShellCommandBackendConfig::Classic => ShellCommandBackend::Classic,
|
||||
ShellCommandBackendConfig::ZshFork => ShellCommandBackend::ZshFork,
|
||||
};
|
||||
Self { backend }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for ShellHandler {
|
||||
fn kind(&self) -> ToolKind {
|
||||
@@ -161,6 +189,7 @@ impl ToolHandler for ShellHandler {
|
||||
tracker,
|
||||
call_id,
|
||||
freeform: false,
|
||||
shell_runtime_backend: ShellRuntimeBackend::Generic,
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -177,6 +206,7 @@ impl ToolHandler for ShellHandler {
|
||||
tracker,
|
||||
call_id,
|
||||
freeform: false,
|
||||
shell_runtime_backend: ShellRuntimeBackend::Generic,
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -260,6 +290,7 @@ impl ToolHandler for ShellCommandHandler {
|
||||
tracker,
|
||||
call_id,
|
||||
freeform: true,
|
||||
shell_runtime_backend: self.shell_runtime_backend(),
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -277,6 +308,7 @@ impl ShellHandler {
|
||||
tracker,
|
||||
call_id,
|
||||
freeform,
|
||||
shell_runtime_backend,
|
||||
} = args;
|
||||
|
||||
let mut exec_params = exec_params;
|
||||
@@ -368,7 +400,15 @@ impl ShellHandler {
|
||||
exec_approval_requirement,
|
||||
};
|
||||
let mut orchestrator = ToolOrchestrator::new();
|
||||
let mut runtime = ShellRuntime::new();
|
||||
let mut runtime = {
|
||||
use ShellRuntimeBackend::*;
|
||||
match shell_runtime_backend {
|
||||
Generic => ShellRuntime::new(),
|
||||
backend @ (ShellCommandClassic | ShellCommandZshFork) => {
|
||||
ShellRuntime::for_shell_command(backend)
|
||||
}
|
||||
}
|
||||
};
|
||||
let tool_ctx = ToolCtx {
|
||||
session: session.clone(),
|
||||
turn: turn.clone(),
|
||||
|
||||
@@ -4,6 +4,9 @@ Runtime: shell
|
||||
Executes shell requests under the orchestrator: asks for approval when needed,
|
||||
builds a CommandSpec, and runs it under the current SandboxAttempt.
|
||||
*/
|
||||
#[cfg(unix)]
|
||||
mod unix_escalation;
|
||||
|
||||
use crate::command_canonicalization::canonicalize_command_for_approval;
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::features::Feature;
|
||||
@@ -27,11 +30,11 @@ use crate::tools::sandboxing::ToolError;
|
||||
use crate::tools::sandboxing::ToolRuntime;
|
||||
use crate::tools::sandboxing::sandbox_override_for_first_attempt;
|
||||
use crate::tools::sandboxing::with_cached_approval;
|
||||
use crate::zsh_exec_bridge::ZSH_EXEC_BRIDGE_WRAPPER_SOCKET_ENV_VAR;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_protocol::models::AdditionalPermissions;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use futures::future::BoxFuture;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -39,8 +42,8 @@ pub struct ShellRequest {
|
||||
pub command: Vec<String>,
|
||||
pub cwd: PathBuf,
|
||||
pub timeout_ms: Option<u64>,
|
||||
pub env: std::collections::HashMap<String, String>,
|
||||
pub explicit_env_overrides: std::collections::HashMap<String, String>,
|
||||
pub env: HashMap<String, String>,
|
||||
pub explicit_env_overrides: HashMap<String, String>,
|
||||
pub network: Option<NetworkProxy>,
|
||||
pub sandbox_permissions: SandboxPermissions,
|
||||
pub additional_permissions: Option<AdditionalPermissions>,
|
||||
@@ -48,8 +51,38 @@ pub struct ShellRequest {
|
||||
pub exec_approval_requirement: ExecApprovalRequirement,
|
||||
}
|
||||
|
||||
/// Selects `ShellRuntime` behavior for different callers.
|
||||
///
|
||||
/// Note: `Generic` is not the same as `ShellCommandClassic`.
|
||||
/// `Generic` means "no `shell_command`-specific backend behavior" (used by the
|
||||
/// generic `shell` tool path). The `ShellCommand*` variants are only for the
|
||||
/// `shell_command` tool family.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub(crate) enum ShellRuntimeBackend {
|
||||
/// Tool-agnostic/default runtime path.
|
||||
///
|
||||
/// Uses the normal `ShellRuntime` execution flow without enabling any
|
||||
/// `shell_command`-specific backend selection.
|
||||
#[default]
|
||||
Generic,
|
||||
/// Legacy backend for the `shell_command` tool.
|
||||
///
|
||||
/// Keeps `shell_command` on the standard shell runtime flow without the
|
||||
/// zsh-fork shell-escalation adapter.
|
||||
ShellCommandClassic,
|
||||
/// zsh-fork backend for the `shell_command` tool.
|
||||
///
|
||||
/// On Unix, attempts to run via the zsh-fork + `codex-shell-escalation`
|
||||
/// adapter, with fallback to the standard shell runtime flow if
|
||||
/// prerequisites are not met.
|
||||
ShellCommandZshFork,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ShellRuntime;
|
||||
pub struct ShellRuntime {
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
backend: ShellRuntimeBackend,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, Clone, Debug, Eq, PartialEq, Hash)]
|
||||
pub(crate) struct ApprovalKey {
|
||||
@@ -61,7 +94,13 @@ pub(crate) struct ApprovalKey {
|
||||
|
||||
impl ShellRuntime {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
Self {
|
||||
backend: ShellRuntimeBackend::Generic,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn for_shell_command(backend: ShellRuntimeBackend) -> Self {
|
||||
Self { backend }
|
||||
}
|
||||
|
||||
fn stdout_stream(ctx: &ToolCtx) -> Option<crate::exec::StdoutStream> {
|
||||
@@ -159,10 +198,9 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
|
||||
attempt: &SandboxAttempt<'_>,
|
||||
ctx: &ToolCtx,
|
||||
) -> Result<ExecToolCallOutput, ToolError> {
|
||||
let base_command = &req.command;
|
||||
let session_shell = ctx.session.user_shell();
|
||||
let command = maybe_wrap_shell_lc_with_snapshot(
|
||||
base_command,
|
||||
&req.command,
|
||||
session_shell.as_ref(),
|
||||
&req.cwd,
|
||||
&req.explicit_env_overrides,
|
||||
@@ -175,35 +213,16 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
|
||||
command
|
||||
};
|
||||
|
||||
if ctx.session.features().enabled(Feature::ShellZshFork) {
|
||||
let wrapper_socket_path = ctx
|
||||
.session
|
||||
.services
|
||||
.zsh_exec_bridge
|
||||
.next_wrapper_socket_path();
|
||||
let mut zsh_fork_env = req.env.clone();
|
||||
zsh_fork_env.insert(
|
||||
ZSH_EXEC_BRIDGE_WRAPPER_SOCKET_ENV_VAR.to_string(),
|
||||
wrapper_socket_path.to_string_lossy().to_string(),
|
||||
);
|
||||
let spec = build_command_spec(
|
||||
&command,
|
||||
&req.cwd,
|
||||
&zsh_fork_env,
|
||||
req.timeout_ms.into(),
|
||||
req.sandbox_permissions,
|
||||
req.additional_permissions.clone(),
|
||||
req.justification.clone(),
|
||||
)?;
|
||||
let env = attempt
|
||||
.env_for(spec, req.network.as_ref())
|
||||
.map_err(|err| ToolError::Codex(err.into()))?;
|
||||
return ctx
|
||||
.session
|
||||
.services
|
||||
.zsh_exec_bridge
|
||||
.execute_shell_request(&env, &ctx.session, &ctx.turn, &ctx.call_id)
|
||||
.await;
|
||||
#[cfg(unix)]
|
||||
if self.backend == ShellRuntimeBackend::ShellCommandZshFork {
|
||||
match unix_escalation::try_run_zsh_fork(req, attempt, ctx, &command).await? {
|
||||
Some(out) => return Ok(out),
|
||||
None => {
|
||||
tracing::warn!(
|
||||
"ZshFork backend specified, but conditions for using it were not met, falling back to normal execution",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let spec = build_command_spec(
|
||||
|
||||
463
codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs
Normal file
463
codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs
Normal file
@@ -0,0 +1,463 @@
|
||||
use super::ShellRequest;
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::SandboxErr;
|
||||
use crate::exec::ExecExpiration;
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::exec::SandboxType;
|
||||
use crate::exec::is_likely_sandbox_denied;
|
||||
use crate::features::Feature;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::shell::ShellType;
|
||||
use crate::tools::runtimes::build_command_spec;
|
||||
use crate::tools::sandboxing::SandboxAttempt;
|
||||
use crate::tools::sandboxing::ToolCtx;
|
||||
use crate::tools::sandboxing::ToolError;
|
||||
use codex_execpolicy::Decision;
|
||||
use codex_execpolicy::Policy;
|
||||
use codex_execpolicy::RuleMatch;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::NetworkPolicyRuleAction;
|
||||
use codex_protocol::protocol::RejectConfig;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
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::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;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(super) async fn try_run_zsh_fork(
|
||||
req: &ShellRequest,
|
||||
attempt: &SandboxAttempt<'_>,
|
||||
ctx: &ToolCtx,
|
||||
command: &[String],
|
||||
) -> Result<Option<ExecToolCallOutput>, ToolError> {
|
||||
let Some(shell_zsh_path) = ctx.session.services.shell_zsh_path.as_ref() else {
|
||||
tracing::warn!("ZshFork backend specified, but shell_zsh_path is not configured.");
|
||||
return Ok(None);
|
||||
};
|
||||
if !ctx.session.features().enabled(Feature::ShellZshFork) {
|
||||
tracing::warn!("ZshFork backend specified, but ShellZshFork feature is not enabled.");
|
||||
return Ok(None);
|
||||
}
|
||||
if !matches!(ctx.session.user_shell().shell_type, ShellType::Zsh) {
|
||||
tracing::warn!("ZshFork backend specified, but user shell is not Zsh.");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let spec = build_command_spec(
|
||||
command,
|
||||
&req.cwd,
|
||||
&req.env,
|
||||
req.timeout_ms.into(),
|
||||
req.sandbox_permissions,
|
||||
req.additional_permissions.clone(),
|
||||
req.justification.clone(),
|
||||
)?;
|
||||
let sandbox_exec_request = attempt
|
||||
.env_for(spec, req.network.as_ref())
|
||||
.map_err(|err| ToolError::Codex(err.into()))?;
|
||||
// Keep env/network/sandbox metadata from `attempt.env_for()`, but build the
|
||||
// script from the original shell argv. `attempt.env_for()` may wrap the
|
||||
// command with `sandbox-exec` on macOS, and passing those wrapper flags
|
||||
// (`-p`, `-D...`) through zsh breaks the zsh-fork path before subcommand
|
||||
// approval runs.
|
||||
let crate::sandboxing::ExecRequest {
|
||||
command: _sandbox_command,
|
||||
cwd: _sandbox_cwd,
|
||||
env: sandbox_env,
|
||||
network: sandbox_network,
|
||||
expiration: _sandbox_expiration,
|
||||
sandbox,
|
||||
windows_sandbox_level,
|
||||
sandbox_permissions,
|
||||
sandbox_policy,
|
||||
justification,
|
||||
arg0,
|
||||
} = sandbox_exec_request;
|
||||
let ParsedShellCommand { script, login } = extract_shell_script(command)?;
|
||||
let effective_timeout = Duration::from_millis(
|
||||
req.timeout_ms
|
||||
.unwrap_or(crate::exec::DEFAULT_EXEC_COMMAND_TIMEOUT_MS),
|
||||
);
|
||||
let exec_policy = Arc::new(RwLock::new(
|
||||
ctx.session.services.exec_policy.current().as_ref().clone(),
|
||||
));
|
||||
let command_executor = CoreShellCommandExecutor {
|
||||
sandbox_policy,
|
||||
sandbox,
|
||||
env: sandbox_env,
|
||||
network: sandbox_network,
|
||||
windows_sandbox_level,
|
||||
sandbox_permissions,
|
||||
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()))?;
|
||||
|
||||
map_exec_result(attempt.sandbox, exec_result).map(Some)
|
||||
}
|
||||
|
||||
struct CoreShellActionProvider {
|
||||
policy: Arc<RwLock<Policy>>,
|
||||
session: Arc<crate::codex::Session>,
|
||||
turn: Arc<crate::codex::TurnContext>,
|
||||
call_id: String,
|
||||
approval_policy: AskForApproval,
|
||||
sandbox_policy: SandboxPolicy,
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
}
|
||||
|
||||
impl CoreShellActionProvider {
|
||||
fn decision_driven_by_policy(matched_rules: &[RuleMatch], decision: Decision) -> bool {
|
||||
matched_rules.iter().any(|rule_match| {
|
||||
!matches!(rule_match, RuleMatch::HeuristicsRuleMatch { .. })
|
||||
&& rule_match.decision() == decision
|
||||
})
|
||||
}
|
||||
|
||||
async fn prompt(
|
||||
&self,
|
||||
command: &[String],
|
||||
workdir: &Path,
|
||||
stopwatch: &Stopwatch,
|
||||
) -> anyhow::Result<ReviewDecision> {
|
||||
let command = command.to_vec();
|
||||
let workdir = workdir.to_path_buf();
|
||||
let session = self.session.clone();
|
||||
let turn = self.turn.clone();
|
||||
let call_id = self.call_id.clone();
|
||||
let approval_id = Some(Uuid::new_v4().to_string());
|
||||
Ok(stopwatch
|
||||
.pause_for(async move {
|
||||
session
|
||||
.request_command_approval(
|
||||
&turn,
|
||||
call_id,
|
||||
approval_id,
|
||||
command,
|
||||
workdir,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
})
|
||||
.await)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ShellActionProvider 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())
|
||||
.collect::<Vec<_>>();
|
||||
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)
|
||||
};
|
||||
// When true, means the Evaluation was due to *.rules, not the
|
||||
// fallback function.
|
||||
let decision_driven_by_policy =
|
||||
Self::decision_driven_by_policy(&evaluation.matched_rules, evaluation.decision);
|
||||
let needs_escalation =
|
||||
self.sandbox_permissions.requires_escalated_permissions() || decision_driven_by_policy;
|
||||
|
||||
Ok(match evaluation.decision {
|
||||
Decision::Forbidden => EscalateAction::Deny {
|
||||
reason: Some("Execution forbidden by policy".to_string()),
|
||||
},
|
||||
Decision::Prompt => {
|
||||
if matches!(
|
||||
self.approval_policy,
|
||||
AskForApproval::Never
|
||||
| AskForApproval::Reject(RejectConfig { rules: true, .. })
|
||||
) {
|
||||
EscalateAction::Deny {
|
||||
reason: Some("Execution forbidden by policy".to_string()),
|
||||
}
|
||||
} else {
|
||||
match self.prompt(&command, workdir, stopwatch).await? {
|
||||
ReviewDecision::Approved
|
||||
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
|
||||
| ReviewDecision::ApprovedForSession => {
|
||||
if needs_escalation {
|
||||
EscalateAction::Escalate
|
||||
} else {
|
||||
EscalateAction::Run
|
||||
}
|
||||
}
|
||||
ReviewDecision::NetworkPolicyAmendment {
|
||||
network_policy_amendment,
|
||||
} => match network_policy_amendment.action {
|
||||
NetworkPolicyRuleAction::Allow => {
|
||||
if needs_escalation {
|
||||
EscalateAction::Escalate
|
||||
} else {
|
||||
EscalateAction::Run
|
||||
}
|
||||
}
|
||||
NetworkPolicyRuleAction::Deny => EscalateAction::Deny {
|
||||
reason: Some("User denied execution".to_string()),
|
||||
},
|
||||
},
|
||||
ReviewDecision::Denied => EscalateAction::Deny {
|
||||
reason: Some("User denied execution".to_string()),
|
||||
},
|
||||
ReviewDecision::Abort => EscalateAction::Deny {
|
||||
reason: Some("User cancelled execution".to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
Decision::Allow => EscalateAction::Escalate,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct CoreShellCommandExecutor {
|
||||
sandbox_policy: SandboxPolicy,
|
||||
sandbox: SandboxType,
|
||||
env: HashMap<String, String>,
|
||||
network: Option<codex_network_proxy::NetworkProxy>,
|
||||
windows_sandbox_level: WindowsSandboxLevel,
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
justification: Option<String>,
|
||||
arg0: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ShellCommandExecutor for CoreShellCommandExecutor {
|
||||
async fn run(
|
||||
&self,
|
||||
command: Vec<String>,
|
||||
cwd: PathBuf,
|
||||
env: HashMap<String, String>,
|
||||
cancel_rx: CancellationToken,
|
||||
) -> anyhow::Result<ExecResult> {
|
||||
let mut exec_env = self.env.clone();
|
||||
for var in ["CODEX_ESCALATE_SOCKET", "EXEC_WRAPPER", "BASH_EXEC_WRAPPER"] {
|
||||
if let Some(value) = env.get(var) {
|
||||
exec_env.insert(var.to_string(), value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let result = crate::sandboxing::execute_env(
|
||||
crate::sandboxing::ExecRequest {
|
||||
command,
|
||||
cwd,
|
||||
env: exec_env,
|
||||
network: self.network.clone(),
|
||||
expiration: ExecExpiration::Cancellation(cancel_rx),
|
||||
sandbox: self.sandbox,
|
||||
windows_sandbox_level: self.windows_sandbox_level,
|
||||
sandbox_permissions: self.sandbox_permissions,
|
||||
sandbox_policy: self.sandbox_policy.clone(),
|
||||
justification: self.justification.clone(),
|
||||
arg0: self.arg0.clone(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(ExecResult {
|
||||
exit_code: result.exit_code,
|
||||
stdout: result.stdout.text,
|
||||
stderr: result.stderr.text,
|
||||
output: result.aggregated_output.text,
|
||||
duration: result.duration,
|
||||
timed_out: result.timed_out,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(mbolin): This should be passed down from codex-arg0 like codex_linux_sandbox_exe.
|
||||
fn shell_execve_wrapper() -> anyhow::Result<PathBuf> {
|
||||
const EXECVE_WRAPPER: &str = "codex-execve-wrapper";
|
||||
|
||||
if let Some(path) = std::env::var_os("PATH") {
|
||||
for dir in std::env::split_paths(&path) {
|
||||
let candidate = dir.join(EXECVE_WRAPPER);
|
||||
if candidate.is_file() {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let exe = std::env::current_exe()?;
|
||||
let sibling = exe
|
||||
.parent()
|
||||
.map(|parent| parent.join(EXECVE_WRAPPER))
|
||||
.ok_or_else(|| anyhow::anyhow!("failed to determine codex-execve-wrapper path"))?;
|
||||
if sibling.is_file() {
|
||||
return Ok(sibling);
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!(
|
||||
"failed to locate {EXECVE_WRAPPER} in PATH or next to current executable ({})",
|
||||
exe.display()
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
struct ParsedShellCommand {
|
||||
script: String,
|
||||
login: bool,
|
||||
}
|
||||
|
||||
fn extract_shell_script(command: &[String]) -> Result<ParsedShellCommand, ToolError> {
|
||||
match command {
|
||||
[_, flag, script, ..] if flag == "-c" => Ok(ParsedShellCommand {
|
||||
script: script.clone(),
|
||||
login: false,
|
||||
}),
|
||||
[_, flag, script, ..] if flag == "-lc" => Ok(ParsedShellCommand {
|
||||
script: script.clone(),
|
||||
login: true,
|
||||
}),
|
||||
_ => Err(ToolError::Rejected(
|
||||
"unexpected shell command format for zsh-fork execution".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_exec_result(
|
||||
sandbox: SandboxType,
|
||||
result: ExecResult,
|
||||
) -> Result<ExecToolCallOutput, ToolError> {
|
||||
let output = ExecToolCallOutput {
|
||||
exit_code: result.exit_code,
|
||||
stdout: crate::exec::StreamOutput::new(result.stdout.clone()),
|
||||
stderr: crate::exec::StreamOutput::new(result.stderr.clone()),
|
||||
aggregated_output: crate::exec::StreamOutput::new(result.output.clone()),
|
||||
duration: result.duration,
|
||||
timed_out: result.timed_out,
|
||||
};
|
||||
|
||||
if result.timed_out {
|
||||
return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Timeout {
|
||||
output: Box::new(output),
|
||||
})));
|
||||
}
|
||||
|
||||
if is_likely_sandbox_denied(sandbox, &output) {
|
||||
return Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied {
|
||||
output: Box::new(output),
|
||||
network_policy_decision: None,
|
||||
})));
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ParsedShellCommand;
|
||||
use super::extract_shell_script;
|
||||
use super::map_exec_result;
|
||||
use crate::exec::SandboxType;
|
||||
use codex_shell_escalation::ExecResult;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn extract_shell_script_preserves_login_flag() {
|
||||
assert_eq!(
|
||||
extract_shell_script(&["/bin/zsh".into(), "-lc".into(), "echo hi".into()]).unwrap(),
|
||||
ParsedShellCommand {
|
||||
script: "echo hi".to_string(),
|
||||
login: true,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
extract_shell_script(&["/bin/zsh".into(), "-c".into(), "echo hi".into()]).unwrap(),
|
||||
ParsedShellCommand {
|
||||
script: "echo hi".to_string(),
|
||||
login: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_exec_result_preserves_stdout_and_stderr() {
|
||||
let out = map_exec_result(
|
||||
SandboxType::None,
|
||||
ExecResult {
|
||||
exit_code: 0,
|
||||
stdout: "out".to_string(),
|
||||
stderr: "err".to_string(),
|
||||
output: "outerr".to_string(),
|
||||
duration: Duration::from_millis(1),
|
||||
timed_out: false,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.stdout.text, "out");
|
||||
assert_eq!(out.stderr.text, "err");
|
||||
assert_eq!(out.aggregated_output.text, "outerr");
|
||||
}
|
||||
}
|
||||
@@ -32,9 +32,16 @@ use std::collections::HashMap;
|
||||
const SEARCH_TOOL_BM25_DESCRIPTION_TEMPLATE: &str =
|
||||
include_str!("../../templates/search_tool/tool_description.md");
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
pub enum ShellCommandBackendConfig {
|
||||
Classic,
|
||||
ZshFork,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ToolsConfig {
|
||||
pub shell_type: ConfigShellToolType,
|
||||
shell_command_backend: ShellCommandBackendConfig,
|
||||
pub allow_login_shell: bool,
|
||||
pub apply_patch_tool_type: Option<ApplyPatchToolType>,
|
||||
pub web_search_mode: Option<WebSearchMode>,
|
||||
@@ -69,6 +76,12 @@ impl ToolsConfig {
|
||||
let include_collaboration_modes_tools = true;
|
||||
let include_search_tool = features.enabled(Feature::Apps);
|
||||
let request_permission_enabled = features.enabled(Feature::RequestPermissions);
|
||||
let shell_command_backend =
|
||||
if features.enabled(Feature::ShellTool) && features.enabled(Feature::ShellZshFork) {
|
||||
ShellCommandBackendConfig::ZshFork
|
||||
} else {
|
||||
ShellCommandBackendConfig::Classic
|
||||
};
|
||||
|
||||
let shell_type = if !features.enabled(Feature::ShellTool) {
|
||||
ConfigShellToolType::Disabled
|
||||
@@ -99,6 +112,7 @@ impl ToolsConfig {
|
||||
|
||||
Self {
|
||||
shell_type,
|
||||
shell_command_backend,
|
||||
allow_login_shell: true,
|
||||
apply_patch_tool_type,
|
||||
web_search_mode: *web_search_mode,
|
||||
@@ -1500,7 +1514,7 @@ pub(crate) fn build_specs(
|
||||
let view_image_handler = Arc::new(ViewImageHandler);
|
||||
let mcp_handler = Arc::new(McpHandler);
|
||||
let mcp_resource_handler = Arc::new(McpResourceHandler);
|
||||
let shell_command_handler = Arc::new(ShellCommandHandler);
|
||||
let shell_command_handler = Arc::new(ShellCommandHandler::from(config.shell_command_backend));
|
||||
let request_user_input_handler = Arc::new(RequestUserInputHandler);
|
||||
let search_tool_handler = Arc::new(SearchToolBm25Handler);
|
||||
let js_repl_handler = Arc::new(JsReplHandler);
|
||||
@@ -2326,6 +2340,10 @@ mod tests {
|
||||
});
|
||||
|
||||
assert_eq!(tools_config.shell_type, ConfigShellToolType::ShellCommand);
|
||||
assert_eq!(
|
||||
tools_config.shell_command_backend,
|
||||
ShellCommandBackendConfig::ZshFork
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user