Decouple request permissions feature and tool (#14426)

This commit is contained in:
Jack Mousseau
2026-03-12 14:47:08 -07:00
committed by GitHub
parent bc48b9289a
commit a314c7d3ae
9 changed files with 482 additions and 35 deletions

View File

@@ -10,6 +10,7 @@ use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ExecApprovalRequestEvent;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RejectConfig;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::request_permissions::PermissionGrantScope;
@@ -395,6 +396,95 @@ async fn with_additional_permissions_requires_approval_under_on_request() -> Res
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn request_permissions_tool_is_auto_denied_when_reject_request_permissions_is_enabled()
-> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
let server = start_mock_server().await;
let approval_policy = AskForApproval::Reject(RejectConfig {
sandbox_approval: false,
rules: false,
skill_approval: false,
request_permissions: true,
mcp_elicitations: false,
});
let sandbox_policy = SandboxPolicy::new_read_only_policy();
let sandbox_policy_for_config = sandbox_policy.clone();
let mut builder = test_codex().with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config);
config
.features
.enable(Feature::RequestPermissionsTool)
.expect("test config should allow feature update");
});
let test = builder.build(&server).await?;
let requested_dir = test.workspace_path("request-permissions-reject");
fs::create_dir_all(&requested_dir)?;
let requested_permissions = requested_directory_write_permissions(&requested_dir);
let call_id = "request_permissions_reject_auto_denied";
let event = request_permissions_tool_event(
call_id,
"Request access through the standalone tool",
&requested_permissions,
)?;
let _ = mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-request-permissions-reject-1"),
event,
ev_completed("resp-request-permissions-reject-1"),
]),
)
.await;
let results = mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-request-permissions-reject-1", "done"),
ev_completed("resp-request-permissions-reject-2"),
]),
)
.await;
submit_turn(
&test,
"request permissions under reject.request_permissions",
approval_policy,
sandbox_policy,
)
.await?;
let event = wait_for_event(&test.codex, |event| {
matches!(
event,
EventMsg::RequestPermissions(_) | EventMsg::TurnComplete(_)
)
})
.await;
assert!(
matches!(event, EventMsg::TurnComplete(_)),
"request_permissions should not emit a prompt when reject.request_permissions is set: {event:?}"
);
let call_output = results.single_request().function_call_output(call_id);
let result: RequestPermissionsResponse =
serde_json::from_str(call_output["output"].as_str().unwrap_or_default())?;
assert_eq!(
result,
RequestPermissionsResponse {
permissions: PermissionProfile::default(),
scope: PermissionGrantScope::Turn,
}
);
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn relative_additional_permissions_resolve_against_tool_workdir() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -1254,6 +1344,118 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls() -> Resu
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn request_permissions_grants_apply_to_later_shell_command_calls_without_inline_permission_feature()
-> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
let server = start_mock_server().await;
let approval_policy = AskForApproval::OnRequest;
let sandbox_policy = workspace_write_excluding_tmp();
let sandbox_policy_for_config = sandbox_policy.clone();
let mut builder = test_codex().with_config(move |config| {
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config);
config
.features
.enable(Feature::RequestPermissionsTool)
.expect("test config should allow feature update");
});
let test = builder.build(&server).await?;
let outside_dir = tempfile::tempdir()?;
let outside_write = outside_dir
.path()
.join("sticky-shell-feature-independent.txt");
let command = format!(
"printf {:?} > {:?} && cat {:?}",
"sticky-shell-feature-independent-ok", outside_write, outside_write
);
let requested_permissions = requested_directory_write_permissions(outside_dir.path());
let normalized_requested_permissions =
normalized_directory_write_permissions(outside_dir.path())?;
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-sticky-shell-independent-1"),
request_permissions_tool_event(
"permissions-call",
"Allow writing outside the workspace",
&requested_permissions,
)?,
ev_completed("resp-sticky-shell-independent-1"),
]),
sse(vec![
ev_response_created("resp-sticky-shell-independent-2"),
shell_command_event("shell-call", &command)?,
ev_completed("resp-sticky-shell-independent-2"),
]),
sse(vec![
ev_response_created("resp-sticky-shell-independent-3"),
ev_assistant_message("msg-sticky-shell-independent-1", "done"),
ev_completed("resp-sticky-shell-independent-3"),
]),
],
)
.await;
submit_turn(
&test,
"write outside the workspace without inline permission feature",
approval_policy,
sandbox_policy,
)
.await?;
let granted_permissions = expect_request_permissions_event(&test, "permissions-call").await;
assert_eq!(
granted_permissions,
normalized_requested_permissions.clone()
);
test.codex
.submit(Op::RequestPermissionsResponse {
id: "permissions-call".to_string(),
response: RequestPermissionsResponse {
permissions: normalized_requested_permissions.clone(),
scope: PermissionGrantScope::Turn,
},
})
.await?;
if let Some(approval) = wait_for_exec_approval_or_completion(&test).await {
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: ReviewDecision::Approved,
})
.await?;
wait_for_completion(&test).await;
}
let shell_output = responses
.function_call_output_text("shell-call")
.map(|output| json!({ "output": output }))
.unwrap_or_else(|| panic!("expected shell-call output"));
let result = parse_result(&shell_output);
assert!(
result.exit_code.is_none_or(|exit_code| exit_code == 0),
"expected success output, got exit_code={:?}, stdout={:?}",
result.exit_code,
result.stdout
);
assert_eq!(result.stdout.trim(), "sticky-shell-feature-independent-ok");
assert_eq!(
fs::read_to_string(&outside_write)?,
"sticky-shell-feature-independent-ok"
);
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn partial_request_permissions_grants_do_not_preapprove_new_permissions() -> Result<()> {
skip_if_no_network!(Ok(()));