diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 69a7cb933..2b19acbdc 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -538,7 +538,7 @@ pub fn render_decision_for_unmatched_command( // In restricted sandboxes (ReadOnly/WorkspaceWrite), do not prompt for // non‑escalated, non‑dangerous commands — let the sandbox enforce // restrictions (e.g., block network/write) without a user prompt. - if sandbox_permissions.requires_additional_permissions() { + if sandbox_permissions.requests_sandbox_override() { Decision::Prompt } else { Decision::Allow @@ -553,7 +553,7 @@ pub fn render_decision_for_unmatched_command( Decision::Allow } SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. } => { - if sandbox_permissions.requires_additional_permissions() { + if sandbox_permissions.requests_sandbox_override() { Decision::Prompt } else { Decision::Allow diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index efc596ea3..cea0b9dd3 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -342,9 +342,7 @@ impl ShellHandler { .map_err(FunctionCallError::RespondToModel)?; // Approval policy guard for explicit escalation in non-OnRequest modes. - if exec_params - .sandbox_permissions - .requires_additional_permissions() + if exec_params.sandbox_permissions.requests_sandbox_override() && !matches!( turn.approval_policy.value(), codex_protocol::protocol::AskForApproval::OnRequest diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index a04c5883c..915c1734c 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -171,7 +171,7 @@ impl ToolHandler for UnifiedExecHandler { let request_permission_enabled = session.features().enabled(Feature::RequestPermissions); - if sandbox_permissions.requires_additional_permissions() + if sandbox_permissions.requests_sandbox_override() && !matches!( context.turn.approval_policy.value(), codex_protocol::protocol::AskForApproval::OnRequest diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 9ca159965..4349ed136 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -239,7 +239,7 @@ fn shell_event_with_prefix_rule( "command": command, "timeout_ms": timeout_ms, }); - if sandbox_permissions.requires_additional_permissions() { + if sandbox_permissions.requests_sandbox_override() { args["sandbox_permissions"] = json!(sandbox_permissions); } if let Some(prefix_rule) = prefix_rule { @@ -262,7 +262,7 @@ fn exec_command_event( if let Some(yield_time_ms) = yield_time_ms { args["yield_time_ms"] = json!(yield_time_ms); } - if sandbox_permissions.requires_additional_permissions() { + if sandbox_permissions.requests_sandbox_override() { args["sandbox_permissions"] = json!(sandbox_permissions); let reason = justification.unwrap_or(DEFAULT_UNIFIED_EXEC_JUSTIFICATION); args["justification"] = json!(reason); diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index ed73837c5..f211e4eff 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -28,18 +28,19 @@ use schemars::JsonSchema; use crate::mcp::CallToolResult; -/// Controls whether a command should use the session sandbox or bypass it. +/// Controls the per-command sandbox override requested by a shell-like tool call. #[derive( Debug, Clone, Copy, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS, )] #[serde(rename_all = "snake_case")] pub enum SandboxPermissions { - /// Run with the configured sandbox + /// Run with the turn's configured sandbox policy unchanged. #[default] UseDefault, - /// Request to run outside the sandbox + /// Request to run outside the sandbox. RequireEscalated, - /// Request to run in the sandbox with additional per-command permissions. + /// Request to stay in the sandbox while widening permissions for this + /// command only. WithAdditionalPermissions, } @@ -49,10 +50,17 @@ impl SandboxPermissions { matches!(self, SandboxPermissions::RequireEscalated) } - /// True if SandboxPermissions requires permissions beyond UseDefault - pub fn requires_additional_permissions(self) -> bool { + /// True if SandboxPermissions requests any explicit per-command override + /// beyond `UseDefault`. + pub fn requests_sandbox_override(self) -> bool { !matches!(self, SandboxPermissions::UseDefault) } + + /// True if SandboxPermissions uses the sandboxed per-command permission + /// widening flow. + pub fn uses_additional_permissions(self) -> bool { + matches!(self, SandboxPermissions::WithAdditionalPermissions) + } } #[derive(Debug, Clone, Default, Eq, Hash, PartialEq, Serialize, Deserialize, JsonSchema, TS)] @@ -1305,6 +1313,41 @@ mod tests { use std::path::PathBuf; use tempfile::tempdir; + #[test] + fn sandbox_permissions_helpers_match_documented_semantics() { + let cases = [ + (SandboxPermissions::UseDefault, false, false, false), + (SandboxPermissions::RequireEscalated, true, true, false), + ( + SandboxPermissions::WithAdditionalPermissions, + false, + true, + true, + ), + ]; + + for ( + sandbox_permissions, + requires_escalated_permissions, + requests_sandbox_override, + uses_additional_permissions, + ) in cases + { + assert_eq!( + sandbox_permissions.requires_escalated_permissions(), + requires_escalated_permissions + ); + assert_eq!( + sandbox_permissions.requests_sandbox_override(), + requests_sandbox_override + ); + assert_eq!( + sandbox_permissions.uses_additional_permissions(), + uses_additional_permissions + ); + } + } + #[test] fn convert_mcp_content_to_items_preserves_data_urls() { let contents = vec![serde_json::json!({