Clarify sandbox permission override helper semantics (#13703)

## Summary
Today `SandboxPermissions::requires_additional_permissions()` does not
actually mean "is `WithAdditionalPermissions`". It returns `true` for
any non-default sandbox override, including `RequireEscalated`. That
broad behavior is relied on in multiple `main` callsites.

The naming is security-sensitive because `SandboxPermissions` is used on
shell-like tool calls to tell the executor how a single command should
relate to the turn sandbox:
- `UseDefault`: run with the turn sandbox unchanged
- `RequireEscalated`: request execution outside the sandbox
- `WithAdditionalPermissions`: stay sandboxed but widen permissions for
that command only

## Problem
The old helper name reads as if it only applies to the
`WithAdditionalPermissions` variant. In practice it means "this command
requested any explicit sandbox override."

That ambiguity made it easy to read production checks incorrectly and
made the guardian change look like a standalone `main` fix when it is
not.

On `main` today:
- `shell` and `unified_exec` intentionally reject any explicit
`sandbox_permissions` request unless approval policy is `OnRequest`
- `exec_policy` intentionally treats any explicit sandbox override as
prompt-worthy in restricted sandboxes
- tests intentionally serialize both `RequireEscalated` and
`WithAdditionalPermissions` as explicit sandbox override requests

So changing those callsites from the broad helper to a narrow
`WithAdditionalPermissions` check would be a behavior change, not a pure
cleanup.

## What This PR Does
- documents `SandboxPermissions` as a per-command sandbox override, not
a generic permissions bag
- adds `requests_sandbox_override()` for the broad meaning: anything
except `UseDefault`
- adds `uses_additional_permissions()` for the narrow meaning: only
`WithAdditionalPermissions`
- keeps `requires_additional_permissions()` as a compatibility alias to
the broad meaning for now
- updates the current broad callsites to use the accurately named broad
helper
- adds unit coverage that locks in the semantics of all three helpers

## What This PR Does Not Do
This PR does not change runtime behavior. That is intentional.

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Charley Cunningham 2026-03-06 09:57:48 -08:00 committed by GitHub
parent c8f4b5bc1e
commit cb1a182bbe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 55 additions and 14 deletions

View file

@ -538,7 +538,7 @@ pub fn render_decision_for_unmatched_command(
// In restricted sandboxes (ReadOnly/WorkspaceWrite), do not prompt for
// nonescalated, nondangerous 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

View file

@ -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

View file

@ -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

View file

@ -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);

View file

@ -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!({