Allow dangerous-command review when sandbox approvals are disabled (#59120)

## Why

Setting granular `sandbox_approval` to `false` also blocked approval requests for dangerous commands such as `rm -f`, even when they requested no additional permissions. Reviewing a dangerous command should remain possible within the existing permission profile.

## What changed

Allow heuristic dangerous-command approval requests with `sandbox_approval = false` when execution stays within current permissions. Do not propose reusable execution-policy amendments for these approvals, since reusable allow rules can bypass the sandbox on later calls.

Keep escalation requests, explicit policy rules, and Windows execution without required sandbox protection subject to their existing approval restrictions. Clarify the meaning of `sandbox_approval` in the protocol and generated schemas.

## Testing

Add coverage for POSIX and Windows approval decisions, approval and denial of deletion commands, preservation of read-only restrictions, and separate dangerous-command approval after turn-scoped or session-scoped write grants.

GitOrigin-RevId: 90adc137c7b7d3f3ee68efb1f6bea531229e7834
This commit is contained in:
jif
2026-11-03 11:14:59 +00:00
committed by copyberry
parent cb2b5857c2
commit 86b2eca691
7 changed files with 270 additions and 58 deletions
+1 -1
View File
@@ -2160,7 +2160,7 @@
"type": "boolean"
},
"sandbox_approval": {
"description": "Whether to allow shell command approval requests, including inline `with_additional_permissions` and `require_escalated` requests.",
"description": "Whether to allow sandbox escalation approval requests, including inline `with_additional_permissions` and `require_escalated` requests. Dangerous commands can still request approval within existing permissions.",
"type": "boolean"
},
"skill_approval": {
+40 -19
View File
@@ -429,20 +429,35 @@ impl ExecPolicyManager {
let prompt_is_rule = evaluation.matched_rules.iter().any(|rule_match| {
is_policy_match(rule_match) && rule_match.decision() == evaluation.decision
});
match prompt_is_rejected_by_policy(approval_policy, prompt_is_rule) {
let dangerous_command = dangerous_command_match_for_heuristics(
&evaluation,
Decision::Prompt,
command_origin,
command_platform,
);
// Reviewing a dangerous command does not grant additional permissions.
// Keep explicit rules, escalation requests, and unavailable Windows
// sandbox protection on their existing approval-policy paths.
let allow_dangerous_command_prompt = matches!(
approval_policy,
AskForApproval::Granular(config) if !config.sandbox_approval
) && !prompt_is_rule
&& dangerous_command.is_some()
&& !sandbox_permissions.requests_sandbox_override()
&& !(command_platform == DangerousCommandPlatform::Windows
&& windows_sandbox_level == WindowsSandboxLevel::Disabled
&& profile_has_managed_filesystem_restrictions(&permission_profile));
let rejection = if allow_dangerous_command_prompt {
None
} else {
prompt_is_rejected_by_policy(approval_policy, prompt_is_rule)
};
match rejection {
Some(reason) if prompt_is_rule => ExecApprovalRequirement::Forbidden {
reason: reason.to_string(),
},
Some(reason) => ExecApprovalRequirement::Forbidden {
reason: derive_rejected_prompt_reason(
reason,
dangerous_command_match_for_heuristics(
&evaluation,
Decision::Prompt,
command_origin,
command_platform,
),
),
reason: derive_rejected_prompt_reason(reason, dangerous_command),
},
None if evaluation.decision == Decision::PromptUser => {
ExecApprovalRequirement::NeedsHumanApproval {
@@ -451,15 +466,21 @@ impl ExecPolicyManager {
}
None => ExecApprovalRequirement::NeedsApproval {
reason: derive_prompt_reason(command, &evaluation),
proposed_execpolicy_amendment: requested_amendment.or_else(|| {
if auto_amendment_allowed {
try_derive_execpolicy_amendment_for_prompt_rules(
&evaluation.matched_rules,
)
} else {
None
}
}),
// A reusable allow rule can bypass the sandbox on later calls.
// This approval only authorizes execution with current permissions.
proposed_execpolicy_amendment: if allow_dangerous_command_prompt {
None
} else {
requested_amendment.or_else(|| {
if auto_amendment_allowed {
try_derive_execpolicy_amendment_for_prompt_rules(
&evaluation.matched_rules,
)
} else {
None
}
})
},
},
}
}
+71 -2
View File
@@ -1418,6 +1418,75 @@ async fn exec_approval_requirement_rejects_unmatched_sandbox_escalation_when_gra
.await;
}
#[test_case::test_case(DangerousCommandPlatform::Posix, WindowsSandboxLevel::RestrictedToken; "posix")]
#[test_case::test_case(DangerousCommandPlatform::Windows, WindowsSandboxLevel::RestrictedToken; "windows")]
#[test_case::test_case(DangerousCommandPlatform::Windows, WindowsSandboxLevel::Disabled; "windows_without_sandbox")]
#[tokio::test]
async fn granular_dangerous_command_approval_does_not_authorize_escalation(
platform: DangerousCommandPlatform,
windows_sandbox_level: WindowsSandboxLevel,
) {
let command = match platform {
DangerousCommandPlatform::Posix => vec!["rm", "-f", "recording.mp4"],
DangerousCommandPlatform::Windows => vec![
"powershell.exe",
"-Command",
"Remove-Item -Force recording.mp4",
],
}
.into_iter()
.map(str::to_owned)
.collect::<Vec<_>>();
for sandbox_permissions in [
SandboxPermissions::UseDefault,
SandboxPermissions::WithAdditionalPermissions,
SandboxPermissions::RequireEscalated,
] {
let requirement = ExecPolicyManager::default()
.create_exec_approval_requirement_for_command_platform(
ExecApprovalRequest {
command: &command,
approval_policy: AskForApproval::Granular(GranularApprovalConfig {
sandbox_approval: false,
rules: false,
skill_approval: false,
request_permissions: true,
mcp_elicitations: true,
}),
permission_profile: PermissionProfile::workspace_write(),
environment_policy: None,
windows_sandbox_level,
sandbox_permissions,
prefix_rule: None,
allow_prefix_rules: AllowPrefixRules::Honor,
},
platform,
)
.await;
let expected = if sandbox_permissions == SandboxPermissions::UseDefault
&& windows_sandbox_level == WindowsSandboxLevel::RestrictedToken
{
ExecApprovalRequirement::NeedsApproval {
reason: None,
proposed_execpolicy_amendment: None,
}
} else {
ExecApprovalRequirement::Forbidden {
reason: match platform {
DangerousCommandPlatform::Posix => {
"rm -f style commands are not permitted. Use a safer approach".to_string()
}
DangerousCommandPlatform::Windows => REJECT_SANDBOX_APPROVAL_REASON.to_string(),
},
}
};
assert_eq!(
requirement, expected,
"{platform:?}: {sandbox_permissions:?}"
);
}
}
#[test]
fn other_danger_preserves_rejected_prompt_reason() {
assert_eq!(
@@ -1497,7 +1566,7 @@ async fn forced_rm_preserves_rule_rejection_when_granular_rules_are_disabled() {
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
command: &command,
approval_policy: AskForApproval::Granular(GranularApprovalConfig {
sandbox_approval: true,
sandbox_approval: false,
rules: false,
skill_approval: true,
request_permissions: true,
@@ -1506,7 +1575,7 @@ async fn forced_rm_preserves_rule_rejection_when_granular_rules_are_disabled() {
permission_profile: PermissionProfile::read_only(),
environment_policy: None,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
sandbox_permissions: SandboxPermissions::RequireEscalated,
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
allow_prefix_rules: AllowPrefixRules::Honor,
})
+57 -35
View File
@@ -37,6 +37,7 @@ use core_test_support::responses::sse_completed;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_target_windows;
use core_test_support::skip_if_wine_exec;
use core_test_support::test_codex::TestCodexHarness;
use core_test_support::test_codex::local_requests;
use core_test_support::test_codex::test_codex;
use core_test_support::test_codex::turn_permission_fields;
@@ -243,7 +244,7 @@ prefix_rule(pattern=["git", "status"], decision="allow")
}
#[tokio::test]
async fn granular_complex_forced_rm_denial_explains_why_the_command_was_rejected() -> Result<()> {
async fn never_complex_forced_rm_denial_explains_why_the_command_was_rejected() -> Result<()> {
skip_if_target_windows!(Ok(()), "uses a POSIX shell command fixture");
let server = start_mock_server().await;
@@ -276,13 +277,7 @@ async fn granular_complex_forced_rm_denial_explains_why_the_command_was_rejected
submit_user_turn(
&test,
"run the forced rm loop",
AskForApproval::Granular(GranularApprovalConfig {
sandbox_approval: false,
rules: true,
skill_approval: true,
request_permissions: true,
mcp_elicitations: true,
}),
AskForApproval::Never,
PermissionProfile::read_only(),
/*collaboration_mode*/ None,
)
@@ -306,21 +301,50 @@ async fn granular_complex_forced_rm_denial_explains_why_the_command_was_rejected
Ok(())
}
#[test_case::test_case(PermissionProfile::workspace_write(), ReviewDecision::Approved, false; "approved_with_write_access")]
#[test_case::test_case(PermissionProfile::read_only(), ReviewDecision::Approved, true; "approval_preserves_read_only_sandbox")]
#[test_case::test_case(PermissionProfile::workspace_write(), ReviewDecision::denied("rejected by user"), true; "user_can_deny")]
#[tokio::test]
async fn granular_complex_forced_rm_requests_approval_when_allowed() -> Result<()> {
async fn granular_dangerous_command_approval_preserves_permissions(
permission_profile: PermissionProfile,
decision: ReviewDecision,
sentinel_survives: bool,
) -> Result<()> {
skip_if_target_windows!(Ok(()), "uses a POSIX shell command fixture");
let server = start_mock_server().await;
let mut builder = test_codex();
let test = builder.build_with_auto_env(&server).await?;
let harness =
TestCodexHarness::with_auto_env_builder(test_codex().with_config(move |config| {
config.permissions.approval_policy =
Constrained::allow_any(AskForApproval::Granular(GranularApprovalConfig {
sandbox_approval: false,
rules: false,
skill_approval: false,
request_permissions: true,
mcp_elicitations: true,
}));
config
.permissions
.set_permission_profile(permission_profile.clone())
.unwrap();
config.approvals_reviewer = ApprovalsReviewer::User;
}))
.await?;
let test = harness.test();
harness
.write_file(
"forced-rm-sentinel",
"must remain until approved with write access",
)
.await?;
let command = "for target in forced-rm-sentinel; do rm -rf \"$target\"; done";
let call_id = "forced-rm-approval";
let args = json!({
"cmd": COMPLEX_FORCED_RM_COMMAND,
"cmd": command,
"yield_time_ms": 1_000,
});
mount_sse_once(
&server,
harness.server(),
sse(vec![
ev_response_created("resp-forced-rm-approval-1"),
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
@@ -328,8 +352,8 @@ async fn granular_complex_forced_rm_requests_approval_when_allowed() -> Result<(
]),
)
.await;
mount_sse_once(
&server,
let results_mock = mount_sse_once(
harness.server(),
sse(vec![
ev_assistant_message("msg-forced-rm-approval-1", "done"),
ev_completed("resp-forced-rm-approval-2"),
@@ -337,20 +361,12 @@ async fn granular_complex_forced_rm_requests_approval_when_allowed() -> Result<(
)
.await;
submit_user_turn(
&test,
"run the forced rm loop",
AskForApproval::Granular(GranularApprovalConfig {
sandbox_approval: true,
rules: true,
skill_approval: true,
request_permissions: true,
mcp_elicitations: true,
}),
PermissionProfile::read_only(),
/*collaboration_mode*/ None,
)
.await?;
test.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "run the forced rm loop".into(),
text_elements: Vec::new(),
}]))
.await?;
let approval_event = wait_for_event(&test.codex, |event| {
matches!(
@@ -362,16 +378,17 @@ async fn granular_complex_forced_rm_requests_approval_when_allowed() -> Result<(
let EventMsg::ExecApprovalRequest(approval) = approval_event else {
panic!("expected forced rm to request approval before turn completion");
};
assert_eq!(
approval.command.last().map(String::as_str),
Some(COMPLEX_FORCED_RM_COMMAND)
);
assert_eq!(approval.command.last().map(String::as_str), Some(command));
assert!(
harness.path_exists("forced-rm-sentinel").await?,
"the command must wait for approval"
);
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: ReviewDecision::denied("rejected by user"),
decision,
})
.await?;
wait_for_event(&test.codex, |event| {
@@ -379,6 +396,11 @@ async fn granular_complex_forced_rm_requests_approval_when_allowed() -> Result<(
})
.await;
assert_eq!(
harness.path_exists("forced-rm-sentinel").await?,
sentinel_survives
);
assert_no_matched_rules_invariant(&results_mock.single_request().function_call_output(call_id));
Ok(())
}
@@ -1479,6 +1479,105 @@ async fn request_permissions_preapprove_explicit_exec_permissions_outside_on_req
Ok(())
}
#[test_case(PermissionGrantScope::Turn; "turn_grant")]
#[test_case(PermissionGrantScope::Session; "session_grant")]
#[tokio::test]
async fn granular_write_grant_still_requires_dangerous_command_approval(
scope: PermissionGrantScope,
) -> Result<()> {
skip_if_target_windows!(Ok(()), "uses a POSIX deletion command");
let harness = TestCodexHarness::with_auto_env_builder(test_codex().with_config(|config| {
config.permissions.approval_policy =
Constrained::allow_any(AskForApproval::Granular(GranularApprovalConfig {
sandbox_approval: false,
rules: false,
skill_approval: false,
request_permissions: true,
mcp_elicitations: true,
}));
config
.permissions
.set_permission_profile(CorePermissionProfile::read_only())
.unwrap();
config.approvals_reviewer = ApprovalsReviewer::User;
config
.features
.enable(Feature::RequestPermissionsTool)
.unwrap();
}))
.await?;
let test = harness.test();
harness
.write_file("recordings/recording.mp4", "recording")
.await?;
let recording = harness.path("recordings/recording.mp4");
let permissions = requested_directory_write_permissions(&harness.path("recordings"));
let command = format!("rm -f {recording:?}");
let _permissions_mock = mount_sse_once(
harness.server(),
sse(vec![
ev_response_created("resp-grant"),
request_permissions_tool_event("grant", "Delete the recording", &permissions)?,
ev_completed("resp-grant"),
]),
)
.await;
let _command_mock = mount_sse_once(
harness.server(),
sse(vec![
ev_response_created("resp-delete"),
exec_command_event("delete", &command)?,
ev_completed("resp-delete"),
]),
)
.await;
let result_mock = mount_sse_once(
harness.server(),
sse(vec![
ev_assistant_message("done", "done"),
ev_completed("resp-done"),
]),
)
.await;
test.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "Delete the recording".into(),
text_elements: Vec::new(),
}]))
.await?;
let granted_permissions = expect_request_permissions_event(test, "grant").await;
assert_eq!(granted_permissions, permissions);
test.codex
.submit(Op::RequestPermissionsResponse {
id: "grant".to_string(),
response: RequestPermissionsResponse {
permissions: granted_permissions,
scope,
strict_auto_review: false,
},
})
.await?;
let approval = expect_exec_approval(test, &command).await;
assert!(
harness.path_exists("recordings/recording.mp4").await?,
"the write grant must not approve deletion"
);
assert_eq!(approval.proposed_execpolicy_amendment, None);
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: ReviewDecision::Approved,
})
.await?;
wait_for_completion(test).await;
let result = parse_result(&result_mock.single_request().function_call_output("delete"));
assert_eq!(result.exit_code, Some(0), "{}", result.stdout);
assert!(!harness.path_exists("recordings/recording.mp4").await?);
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn request_permissions_grants_apply_to_later_exec_command_calls_without_inline_permission_feature()
-> Result<()> {
+2 -1
View File
@@ -1187,8 +1187,9 @@ pub enum AskForApproval {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema, TS)]
pub struct GranularApprovalConfig {
/// Whether to allow shell command approval requests, including inline
/// Whether to allow sandbox escalation approval requests, including inline
/// `with_additional_permissions` and `require_escalated` requests.
/// Dangerous commands can still request approval within existing permissions.
pub sandbox_approval: bool,
/// Whether to allow prompts triggered by execpolicy `prompt` rules.
pub rules: bool,