feat(approvals) RejectConfig for request_permissions (#14118)

## Summary
We need to support allowing request_permissions calls when using
`Reject` policy

<img width="1133" height="588" alt="Screenshot 2026-03-09 at 12 06
40 PM"
src="https://github.com/user-attachments/assets/a8df987f-c225-4866-b8ab-5590960daec5"
/>

Note that this is a backwards-incompatible change for Reject policy. I'm
not sure if we need to add a default based on our current use/setup

## Testing
- [x] Added tests
- [x] Tested locally
This commit is contained in:
Dylan Hurd 2026-03-09 18:16:54 -07:00 committed by GitHub
parent c1defcc98c
commit 6da84efed8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 285 additions and 1 deletions

View file

@ -57,6 +57,9 @@
"mcp_elicitations": {
"type": "boolean"
},
"request_permissions": {
"type": "boolean"
},
"rules": {
"type": "boolean"
},
@ -66,6 +69,7 @@
},
"required": [
"mcp_elicitations",
"request_permissions",
"rules",
"sandbox_approval"
],

View file

@ -4683,6 +4683,10 @@
"description": "Reject MCP elicitation prompts.",
"type": "boolean"
},
"request_permissions": {
"description": "Reject approval prompts related to built-in permission requests.",
"type": "boolean"
},
"rules": {
"description": "Reject prompts triggered by execpolicy `prompt` rules.",
"type": "boolean"
@ -4694,6 +4698,7 @@
},
"required": [
"mcp_elicitations",
"request_permissions",
"rules",
"sandbox_approval"
],

View file

@ -6752,6 +6752,10 @@
"description": "Reject MCP elicitation prompts.",
"type": "boolean"
},
"request_permissions": {
"description": "Reject approval prompts related to built-in permission requests.",
"type": "boolean"
},
"rules": {
"description": "Reject prompts triggered by execpolicy `prompt` rules.",
"type": "boolean"
@ -6763,6 +6767,7 @@
},
"required": [
"mcp_elicitations",
"request_permissions",
"rules",
"sandbox_approval"
],
@ -9232,6 +9237,9 @@
"mcp_elicitations": {
"type": "boolean"
},
"request_permissions": {
"type": "boolean"
},
"rules": {
"type": "boolean"
},
@ -9241,6 +9249,7 @@
},
"required": [
"mcp_elicitations",
"request_permissions",
"rules",
"sandbox_approval"
],

View file

@ -731,6 +731,9 @@
"mcp_elicitations": {
"type": "boolean"
},
"request_permissions": {
"type": "boolean"
},
"rules": {
"type": "boolean"
},
@ -740,6 +743,7 @@
},
"required": [
"mcp_elicitations",
"request_permissions",
"rules",
"sandbox_approval"
],

View file

@ -148,6 +148,9 @@
"mcp_elicitations": {
"type": "boolean"
},
"request_permissions": {
"type": "boolean"
},
"rules": {
"type": "boolean"
},
@ -157,6 +160,7 @@
},
"required": [
"mcp_elicitations",
"request_permissions",
"rules",
"sandbox_approval"
],

View file

@ -20,6 +20,9 @@
"mcp_elicitations": {
"type": "boolean"
},
"request_permissions": {
"type": "boolean"
},
"rules": {
"type": "boolean"
},
@ -29,6 +32,7 @@
},
"required": [
"mcp_elicitations",
"request_permissions",
"rules",
"sandbox_approval"
],

View file

@ -20,6 +20,9 @@
"mcp_elicitations": {
"type": "boolean"
},
"request_permissions": {
"type": "boolean"
},
"rules": {
"type": "boolean"
},
@ -29,6 +32,7 @@
},
"required": [
"mcp_elicitations",
"request_permissions",
"rules",
"sandbox_approval"
],

View file

@ -24,6 +24,9 @@
"mcp_elicitations": {
"type": "boolean"
},
"request_permissions": {
"type": "boolean"
},
"rules": {
"type": "boolean"
},
@ -33,6 +36,7 @@
},
"required": [
"mcp_elicitations",
"request_permissions",
"rules",
"sandbox_approval"
],

View file

@ -20,6 +20,9 @@
"mcp_elicitations": {
"type": "boolean"
},
"request_permissions": {
"type": "boolean"
},
"rules": {
"type": "boolean"
},
@ -29,6 +32,7 @@
},
"required": [
"mcp_elicitations",
"request_permissions",
"rules",
"sandbox_approval"
],

View file

@ -24,6 +24,9 @@
"mcp_elicitations": {
"type": "boolean"
},
"request_permissions": {
"type": "boolean"
},
"rules": {
"type": "boolean"
},
@ -33,6 +36,7 @@
},
"required": [
"mcp_elicitations",
"request_permissions",
"rules",
"sandbox_approval"
],

View file

@ -20,6 +20,9 @@
"mcp_elicitations": {
"type": "boolean"
},
"request_permissions": {
"type": "boolean"
},
"rules": {
"type": "boolean"
},
@ -29,6 +32,7 @@
},
"required": [
"mcp_elicitations",
"request_permissions",
"rules",
"sandbox_approval"
],

View file

@ -24,6 +24,9 @@
"mcp_elicitations": {
"type": "boolean"
},
"request_permissions": {
"type": "boolean"
},
"rules": {
"type": "boolean"
},
@ -33,6 +36,7 @@
},
"required": [
"mcp_elicitations",
"request_permissions",
"rules",
"sandbox_approval"
],

View file

@ -24,6 +24,9 @@
"mcp_elicitations": {
"type": "boolean"
},
"request_permissions": {
"type": "boolean"
},
"rules": {
"type": "boolean"
},
@ -33,6 +36,7 @@
},
"required": [
"mcp_elicitations",
"request_permissions",
"rules",
"sandbox_approval"
],

View file

@ -11,6 +11,10 @@ sandbox_approval: boolean,
* Reject prompts triggered by execpolicy `prompt` rules.
*/
rules: boolean,
/**
* Reject approval prompts related to built-in permission requests.
*/
request_permissions: boolean,
/**
* Reject MCP elicitation prompts.
*/

View file

@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type AskForApproval = "untrusted" | "on-failure" | "on-request" | { "reject": { sandbox_approval: boolean, rules: boolean, mcp_elicitations: boolean, } } | "never";
export type AskForApproval = "untrusted" | "on-failure" | "on-request" | { "reject": { sandbox_approval: boolean, rules: boolean, request_permissions: boolean, mcp_elicitations: boolean, } } | "never";

View file

@ -193,6 +193,7 @@ pub enum AskForApproval {
Reject {
sandbox_approval: bool,
rules: bool,
request_permissions: bool,
mcp_elicitations: bool,
},
Never,
@ -207,10 +208,12 @@ impl AskForApproval {
AskForApproval::Reject {
sandbox_approval,
rules,
request_permissions,
mcp_elicitations,
} => CoreAskForApproval::Reject(CoreRejectConfig {
sandbox_approval,
rules,
request_permissions,
mcp_elicitations,
}),
AskForApproval::Never => CoreAskForApproval::Never,
@ -227,6 +230,7 @@ impl From<CoreAskForApproval> for AskForApproval {
CoreAskForApproval::Reject(reject_config) => AskForApproval::Reject {
sandbox_approval: reject_config.sandbox_approval,
rules: reject_config.rules,
request_permissions: reject_config.request_permissions,
mcp_elicitations: reject_config.mcp_elicitations,
},
CoreAskForApproval::Never => AskForApproval::Never,
@ -5832,6 +5836,30 @@ mod tests {
assert_eq!(back_to_v2, v2_policy);
}
#[test]
fn ask_for_approval_reject_round_trips_request_permissions_flag() {
let v2_policy = AskForApproval::Reject {
sandbox_approval: true,
rules: false,
request_permissions: true,
mcp_elicitations: false,
};
let core_policy = v2_policy.to_core();
assert_eq!(
core_policy,
CoreAskForApproval::Reject(CoreRejectConfig {
sandbox_approval: true,
rules: false,
request_permissions: true,
mcp_elicitations: false,
})
);
let back_to_v2 = AskForApproval::from(core_policy);
assert_eq!(back_to_v2, v2_policy);
}
#[test]
fn mcp_server_elicitation_response_round_trips_rmcp_result() {
let rmcp_result = rmcp::model::CreateElicitationResult {

View file

@ -928,6 +928,8 @@ Only the granted subset matters on the wire. Any permissions omitted from `resul
Within the same turn, granted permissions are sticky: later shell-like tool calls can automatically reuse the granted subset without reissuing a separate permission request.
If the session approval policy uses `Reject` with `request_permissions: true`, the server does not send `item/permissions/requestApproval` to the client. Instead, the tool is auto-denied and resolves with an empty granted-permissions payload.
### Dynamic tool calls (experimental)
`dynamicTools` on `thread/start` and the corresponding `item/tool/call` request/response flow are experimental APIs. To enable them, set `initialize.params.capabilities.experimentalApi = true`.

View file

@ -1316,6 +1316,10 @@
"description": "Reject MCP elicitation prompts.",
"type": "boolean"
},
"request_permissions": {
"description": "Reject approval prompts related to built-in permission requests.",
"type": "boolean"
},
"rules": {
"description": "Reject prompts triggered by execpolicy `prompt` rules.",
"type": "boolean"
@ -1327,6 +1331,7 @@
},
"required": [
"mcp_elicitations",
"request_permissions",
"rules",
"sandbox_approval"
],

View file

@ -2831,6 +2831,25 @@ impl Session {
call_id: String,
args: RequestPermissionsArgs,
) -> Option<RequestPermissionsResponse> {
match turn_context.approval_policy.value() {
AskForApproval::Never => {
return Some(RequestPermissionsResponse {
permissions: PermissionProfile::default(),
});
}
AskForApproval::Reject(reject_config)
if reject_config.rejects_request_permissions() =>
{
return Some(RequestPermissionsResponse {
permissions: PermissionProfile::default(),
});
}
AskForApproval::OnFailure
| AskForApproval::OnRequest
| AskForApproval::UnlessTrusted
| AskForApproval::Reject(_) => {}
}
let (tx_response, rx_response) = oneshot::channel();
let prev_entry = {
let mut active = self.active_turn.lock().await;

View file

@ -2165,6 +2165,128 @@ async fn notify_request_permissions_response_ignores_unmatched_call_id() {
assert_eq!(session.granted_turn_permissions().await, None);
}
#[tokio::test]
async fn request_permissions_emits_event_when_reject_policy_allows_requests() {
let (session, mut turn_context, rx) = make_session_and_context_with_rx().await;
*session.active_turn.lock().await = Some(ActiveTurn::default());
Arc::get_mut(&mut turn_context)
.expect("single turn context ref")
.approval_policy
.set(crate::protocol::AskForApproval::Reject(
crate::protocol::RejectConfig {
sandbox_approval: true,
rules: true,
request_permissions: false,
mcp_elicitations: true,
},
))
.expect("test setup should allow updating approval policy");
let session = Arc::new(session);
let turn_context = Arc::new(turn_context);
let call_id = "call-1".to_string();
let expected_response = codex_protocol::request_permissions::RequestPermissionsResponse {
permissions: codex_protocol::models::PermissionProfile {
network: Some(codex_protocol::models::NetworkPermissions {
enabled: Some(true),
}),
..Default::default()
},
};
let handle = tokio::spawn({
let session = Arc::clone(&session);
let turn_context = Arc::clone(&turn_context);
let call_id = call_id.clone();
async move {
session
.request_permissions(
turn_context.as_ref(),
call_id,
codex_protocol::request_permissions::RequestPermissionsArgs {
reason: Some("need network".to_string()),
permissions: codex_protocol::models::PermissionProfile {
network: Some(codex_protocol::models::NetworkPermissions {
enabled: Some(true),
}),
..Default::default()
},
},
)
.await
}
});
let request_event = tokio::time::timeout(StdDuration::from_secs(1), rx.recv())
.await
.expect("request_permissions event timed out")
.expect("request_permissions event missing");
let EventMsg::RequestPermissions(request) = request_event.msg else {
panic!("expected request_permissions event");
};
assert_eq!(request.call_id, call_id);
session
.notify_request_permissions_response(&request.call_id, expected_response.clone())
.await;
let response = tokio::time::timeout(StdDuration::from_secs(1), handle)
.await
.expect("request_permissions future timed out")
.expect("request_permissions join error");
assert_eq!(response, Some(expected_response));
}
#[tokio::test]
async fn request_permissions_returns_empty_grant_when_reject_policy_blocks_requests() {
let (session, mut turn_context, rx) = make_session_and_context_with_rx().await;
*session.active_turn.lock().await = Some(ActiveTurn::default());
Arc::get_mut(&mut turn_context)
.expect("single turn context ref")
.approval_policy
.set(crate::protocol::AskForApproval::Reject(
crate::protocol::RejectConfig {
sandbox_approval: false,
rules: false,
request_permissions: true,
mcp_elicitations: false,
},
))
.expect("test setup should allow updating approval policy");
let response = session
.request_permissions(
&turn_context,
"call-1".to_string(),
codex_protocol::request_permissions::RequestPermissionsArgs {
reason: Some("need network".to_string()),
permissions: codex_protocol::models::PermissionProfile {
network: Some(codex_protocol::models::NetworkPermissions {
enabled: Some(true),
}),
..Default::default()
},
},
)
.await;
assert_eq!(
response,
Some(
codex_protocol::request_permissions::RequestPermissionsResponse {
permissions: codex_protocol::models::PermissionProfile::default(),
}
)
);
assert!(
tokio::time::timeout(StdDuration::from_millis(50), rx.recv())
.await
.is_err(),
"unexpected request_permissions event emitted",
);
}
#[tokio::test]
async fn submit_with_id_captures_current_span_trace_context() {
let (session, _turn_context) = make_session_and_context().await;

View file

@ -1569,6 +1569,7 @@ prefix_rule(pattern=["git"], decision="prompt")
AskForApproval::Reject(RejectConfig {
sandbox_approval: false,
rules: false,
request_permissions: false,
mcp_elicitations: false,
}),
&SandboxPolicy::new_read_only_policy(),
@ -1590,6 +1591,7 @@ prefix_rule(pattern=["git"], decision="prompt")
approval_policy: AskForApproval::Reject(RejectConfig {
sandbox_approval: true,
rules: false,
request_permissions: false,
mcp_elicitations: false,
}),
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
@ -1626,6 +1628,7 @@ prefix_rule(pattern=["git"], decision="prompt")
approval_policy: AskForApproval::Reject(RejectConfig {
sandbox_approval: true,
rules: false,
request_permissions: false,
mcp_elicitations: false,
}),
sandbox_policy: &SandboxPolicy::new_read_only_policy(),
@ -1660,6 +1663,7 @@ prefix_rule(pattern=["git"], decision="prompt")
approval_policy: AskForApproval::Reject(RejectConfig {
sandbox_approval: false,
rules: true,
request_permissions: false,
mcp_elicitations: false,
}),
sandbox_policy: &SandboxPolicy::new_read_only_policy(),

View file

@ -1739,6 +1739,7 @@ mod tests {
RejectConfig {
sandbox_approval: false,
rules: false,
request_permissions: false,
mcp_elicitations: false,
}
)));
@ -1751,6 +1752,7 @@ mod tests {
RejectConfig {
sandbox_approval: false,
rules: false,
request_permissions: false,
mcp_elicitations: true,
}
)));

View file

@ -316,6 +316,7 @@ mod tests {
AskForApproval::Reject(RejectConfig {
sandbox_approval: false,
rules: false,
request_permissions: false,
mcp_elicitations: false,
}),
&policy_workspace_only,
@ -348,6 +349,7 @@ mod tests {
AskForApproval::Reject(RejectConfig {
sandbox_approval: true,
rules: false,
request_permissions: false,
mcp_elicitations: false,
}),
&policy_workspace_only,

View file

@ -218,6 +218,7 @@ mod tests {
!runtime.wants_no_sandbox_approval(AskForApproval::Reject(RejectConfig {
sandbox_approval: true,
rules: false,
request_permissions: false,
mcp_elicitations: false,
}))
);
@ -225,6 +226,7 @@ mod tests {
runtime.wants_no_sandbox_approval(AskForApproval::Reject(RejectConfig {
sandbox_approval: false,
rules: false,
request_permissions: false,
mcp_elicitations: false,
}))
);

View file

@ -398,6 +398,7 @@ mod tests {
let policy = AskForApproval::Reject(RejectConfig {
sandbox_approval: true,
rules: false,
request_permissions: false,
mcp_elicitations: false,
});
@ -417,6 +418,7 @@ mod tests {
let policy = AskForApproval::Reject(RejectConfig {
sandbox_approval: false,
rules: true,
request_permissions: false,
mcp_elicitations: true,
});

View file

@ -288,6 +288,7 @@ async fn shell_zsh_fork_skill_script_reject_policy_with_sandbox_approval_false_s
let approval_policy = AskForApproval::Reject(RejectConfig {
sandbox_approval: false,
rules: true,
request_permissions: false,
mcp_elicitations: false,
});
let server = start_mock_server().await;
@ -380,6 +381,7 @@ async fn shell_zsh_fork_skill_script_reject_policy_with_sandbox_approval_true_sk
let approval_policy = AskForApproval::Reject(RejectConfig {
sandbox_approval: true,
rules: false,
request_permissions: false,
mcp_elicitations: false,
});
let server = start_mock_server().await;

View file

@ -453,12 +453,14 @@ impl DeveloperInstructions {
let on_request_instructions = on_request_instructions();
let sandbox_approval = reject_config.sandbox_approval;
let rules = reject_config.rules;
let request_permissions = reject_config.request_permissions;
let mcp_elicitations = reject_config.mcp_elicitations;
format!(
"{on_request_instructions}\n\n\
Approval policy is `reject`.\n\
- `sandbox_approval`: {sandbox_approval}\n\
- `rules`: {rules}\n\
- `request_permissions`: {request_permissions}\n\
- `mcp_elicitations`: {mcp_elicitations}\n\
When a category is `true`, requests in that category are auto-rejected instead of prompting the user."
)

View file

@ -526,6 +526,8 @@ pub struct RejectConfig {
pub sandbox_approval: bool,
/// Reject prompts triggered by execpolicy `prompt` rules.
pub rules: bool,
/// Reject approval prompts related to built-in permission requests.
pub request_permissions: bool,
/// Reject MCP elicitation prompts.
pub mcp_elicitations: bool,
}
@ -539,6 +541,10 @@ impl RejectConfig {
self.rules
}
pub const fn rejects_request_permissions(self) -> bool {
self.request_permissions
}
pub const fn rejects_mcp_elicitations(self) -> bool {
self.mcp_elicitations
}
@ -3298,6 +3304,7 @@ mod tests {
RejectConfig {
sandbox_approval: false,
rules: false,
request_permissions: false,
mcp_elicitations: true,
}
.rejects_mcp_elicitations()
@ -3306,12 +3313,35 @@ mod tests {
!RejectConfig {
sandbox_approval: false,
rules: false,
request_permissions: false,
mcp_elicitations: false,
}
.rejects_mcp_elicitations()
);
}
#[test]
fn reject_config_request_permissions_flag_is_field_driven() {
assert!(
RejectConfig {
sandbox_approval: false,
rules: false,
request_permissions: true,
mcp_elicitations: false,
}
.rejects_request_permissions()
);
assert!(
!RejectConfig {
sandbox_approval: false,
rules: false,
request_permissions: false,
mcp_elicitations: false,
}
.rejects_request_permissions()
);
}
#[test]
fn workspace_write_restricted_read_access_includes_effective_writable_roots() {
let cwd = if cfg!(windows) {