feat(request-permissions) approve with strict review (#19050)

## Summary
Allow the user to approve a request_permissions_tool request with the
condition that all commands in the rest of the turn are reviewed by
guardian, regardless of sandbox status.

## Testing
- [x] Added unit tests
- [x] Ran locally
This commit is contained in:
Dylan Hurd
2026-04-22 18:56:32 -07:00
committed by GitHub
parent c6ab601824
commit 5e71da1424
20 changed files with 609 additions and 134 deletions

View File

@@ -483,6 +483,7 @@ async fn request_permissions_tool_is_auto_denied_when_granular_request_permissio
RequestPermissionsResponse {
permissions: RequestPermissionProfile::default(),
scope: PermissionGrantScope::Turn,
strict_auto_review: false,
}
);
@@ -1089,6 +1090,7 @@ async fn request_permissions_grants_apply_to_later_exec_command_calls() -> Resul
response: RequestPermissionsResponse {
permissions: normalized_requested_permissions.clone(),
scope: PermissionGrantScope::Turn,
strict_auto_review: false,
},
})
.await?;
@@ -1203,6 +1205,7 @@ async fn request_permissions_preapprove_explicit_exec_permissions_outside_on_req
response: RequestPermissionsResponse {
permissions: normalized_requested_permissions,
scope: PermissionGrantScope::Turn,
strict_auto_review: false,
},
})
.await?;
@@ -1316,6 +1319,7 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls() -> Resu
response: RequestPermissionsResponse {
permissions: normalized_requested_permissions.clone(),
scope: PermissionGrantScope::Turn,
strict_auto_review: false,
},
})
.await?;
@@ -1425,6 +1429,7 @@ async fn request_permissions_grants_apply_to_later_shell_command_calls_without_i
response: RequestPermissionsResponse {
permissions: normalized_requested_permissions.clone(),
scope: PermissionGrantScope::Turn,
strict_auto_review: false,
},
})
.await?;
@@ -1571,6 +1576,7 @@ async fn partial_request_permissions_grants_do_not_preapprove_new_permissions()
response: RequestPermissionsResponse {
permissions: granted_permissions.clone(),
scope: PermissionGrantScope::Turn,
strict_auto_review: false,
},
})
.await?;
@@ -1692,6 +1698,7 @@ async fn request_permissions_grants_do_not_carry_across_turns() -> Result<()> {
response: RequestPermissionsResponse {
permissions: normalized_requested_permissions,
scope: PermissionGrantScope::Turn,
strict_auto_review: false,
},
})
.await?;
@@ -1809,6 +1816,7 @@ async fn request_permissions_session_grants_carry_across_turns() -> Result<()> {
response: RequestPermissionsResponse {
permissions: normalized_requested_permissions,
scope: PermissionGrantScope::Session,
strict_auto_review: false,
},
})
.await?;

View File

@@ -4,6 +4,7 @@
use anyhow::Result;
use codex_core::config::Constrained;
use codex_features::Feature;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
@@ -134,6 +135,7 @@ async fn submit_turn(
prompt: &str,
approval_policy: AskForApproval,
sandbox_policy: SandboxPolicy,
approvals_reviewer: Option<ApprovalsReviewer>,
) -> Result<()> {
let session_model = test.session_configured.model.clone();
test.codex
@@ -146,7 +148,7 @@ async fn submit_turn(
final_output_json_schema: None,
cwd: test.cwd.path().to_path_buf(),
approval_policy,
approvals_reviewer: None,
approvals_reviewer,
sandbox_policy,
model: session_model,
effort: None,
@@ -159,6 +161,13 @@ async fn submit_turn(
Ok(())
}
async fn wait_for_completion(test: &TestCodex) {
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
}
async fn expect_request_permissions_event(
test: &TestCodex,
expected_call_id: &str,
@@ -248,6 +257,7 @@ async fn approved_folder_write_request_permissions_unblocks_later_exec_without_s
"write outside the workspace",
approval_policy,
sandbox_policy,
/*approvals_reviewer*/ None,
)
.await?;
@@ -262,6 +272,7 @@ async fn approved_folder_write_request_permissions_unblocks_later_exec_without_s
response: RequestPermissionsResponse {
permissions: normalized_requested_permissions,
scope: PermissionGrantScope::Turn,
strict_auto_review: false,
},
})
.await?;
@@ -305,11 +316,17 @@ async fn approved_folder_write_request_permissions_unblocks_later_exec_without_s
#[tokio::test(flavor = "current_thread")]
#[cfg(target_os = "macos")]
async fn approved_folder_write_request_permissions_unblocks_later_apply_patch_without_prompt()
-> Result<()> {
async fn approved_folder_write_request_permissions_unblocks_later_apply_patch() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
apply_patch_after_request_permissions(/*strict_auto_review*/ false).await?;
apply_patch_after_request_permissions(/*strict_auto_review*/ true).await?;
Ok(())
}
async fn apply_patch_after_request_permissions(strict_auto_review: bool) -> Result<()> {
let server = start_mock_server().await;
let approval_policy = AskForApproval::OnRequest;
let sandbox_policy = workspace_write_excluding_tmp();
@@ -330,43 +347,72 @@ async fn approved_folder_write_request_permissions_unblocks_later_apply_patch_wi
let test = builder.build(&server).await?;
let requested_dir = tempfile::tempdir()?;
let requested_file = requested_dir.path().join("allowed-patch.txt");
let requested_file_name = if strict_auto_review {
"strict-allowed-patch.txt"
} else {
"allowed-patch.txt"
};
let patch_content = if strict_auto_review {
"patched-after-strict-review"
} else {
"patched-via-request-permissions"
};
let requested_file = requested_dir.path().join(requested_file_name);
let requested_permissions = requested_directory_write_permissions(requested_dir.path());
let normalized_requested_permissions =
normalized_directory_write_permissions(requested_dir.path())?;
let patch = build_add_file_patch(&requested_file, "patched-via-request-permissions");
let patch = build_add_file_patch(&requested_file, patch_content);
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-request-permissions-patch-1"),
request_permissions_tool_event(
"permissions-call",
"Allow patching outside the workspace",
&requested_permissions,
)?,
ev_completed("resp-request-permissions-patch-1"),
]),
sse(vec![
ev_response_created("resp-request-permissions-patch-2"),
ev_apply_patch_function_call("apply-patch-call", &patch),
ev_completed("resp-request-permissions-patch-2"),
]),
sse(vec![
ev_response_created("resp-request-permissions-patch-3"),
ev_assistant_message("msg-request-permissions-patch-1", "done"),
ev_completed("resp-request-permissions-patch-3"),
]),
],
)
.await;
let response_prefix = if strict_auto_review {
"resp-strict-request-permissions-patch"
} else {
"resp-request-permissions-patch"
};
let mut sse_sequence = vec![
sse(vec![
ev_response_created(&format!("{response_prefix}-1")),
request_permissions_tool_event(
"permissions-call",
"Allow patching outside the workspace",
&requested_permissions,
)?,
ev_completed(&format!("{response_prefix}-1")),
]),
sse(vec![
ev_response_created(&format!("{response_prefix}-2")),
ev_apply_patch_function_call("apply-patch-call", &patch),
ev_completed(&format!("{response_prefix}-2")),
]),
];
if strict_auto_review {
sse_sequence.push(sse(vec![
ev_response_created(&format!("{response_prefix}-guardian")),
ev_assistant_message(
"msg-strict-request-permissions-patch-guardian",
&serde_json::json!({
"risk_level": "low",
"user_authorization": "high",
"outcome": "allow",
"rationale": "The patch stays within the strict turn grant.",
})
.to_string(),
),
ev_completed(&format!("{response_prefix}-guardian")),
]));
}
sse_sequence.push(sse(vec![
ev_response_created(&format!("{response_prefix}-3")),
ev_assistant_message("msg-request-permissions-patch-1", "done"),
ev_completed(&format!("{response_prefix}-3")),
]));
let responses = mount_sse_sequence(&server, sse_sequence).await;
submit_turn(
&test,
"patch outside the workspace",
approval_policy,
sandbox_policy,
strict_auto_review.then_some(ApprovalsReviewer::User),
)
.await?;
@@ -381,26 +427,38 @@ async fn approved_folder_write_request_permissions_unblocks_later_apply_patch_wi
response: RequestPermissionsResponse {
permissions: normalized_requested_permissions,
scope: PermissionGrantScope::Turn,
strict_auto_review,
},
})
.await?;
let event = wait_for_event(&test.codex, |event| {
matches!(
event,
EventMsg::ApplyPatchApprovalRequest(_) | EventMsg::TurnComplete(_)
)
})
.await;
match event {
EventMsg::TurnComplete(_) => {}
EventMsg::ApplyPatchApprovalRequest(approval) => {
panic!(
"unexpected apply_patch approval request after granted permissions: {:?}",
approval.call_id
if strict_auto_review {
wait_for_completion(&test).await;
let guardian_request = responses
.requests()
.into_iter()
.find(|request| request.body_contains_text(requested_file_name))
.expect("expected guardian request for strict apply_patch");
assert!(guardian_request.body_contains_text(requested_file_name));
assert!(guardian_request.body_contains_text(patch_content));
} else {
let event = wait_for_event(&test.codex, |event| {
matches!(
event,
EventMsg::ApplyPatchApprovalRequest(_) | EventMsg::TurnComplete(_)
)
})
.await;
match event {
EventMsg::TurnComplete(_) => {}
EventMsg::ApplyPatchApprovalRequest(approval) => {
panic!(
"unexpected apply_patch approval request after granted permissions: {:?}",
approval.call_id
)
}
other => panic!("unexpected event: {other:?}"),
}
other => panic!("unexpected event: {other:?}"),
}
let patch_output = responses
@@ -415,7 +473,7 @@ async fn approved_folder_write_request_permissions_unblocks_later_apply_patch_wi
);
assert_eq!(
fs::read_to_string(&requested_file)?,
"patched-via-request-permissions\n"
format!("{patch_content}\n")
);
Ok(())