permissions: expose reviewer override grant API

This commit is contained in:
Michael Bolin
2026-06-11 11:50:00 -04:00
parent b4445f2758
commit 332641acef
10 changed files with 125 additions and 20 deletions
@@ -885,6 +885,7 @@ fn sample_effective_permissions_approval_response(
permissions,
scope,
strict_auto_review: false,
approvals_reviewer: None,
}
}
@@ -58,6 +58,15 @@
},
"type": "object"
},
"ApprovalsReviewer": {
"description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.",
"enum": [
"user",
"auto_review",
"guardian_subagent"
],
"type": "string"
},
"FileSystemAccessMode": {
"enum": [
"read",
@@ -288,6 +297,17 @@
}
},
"properties": {
"approvalsReviewer": {
"anyOf": [
{
"$ref": "#/definitions/ApprovalsReviewer"
},
{
"type": "null"
}
],
"description": "Override who reviews later approval prompts for the rest of the current turn."
},
"permissions": {
"$ref": "#/definitions/GrantedPermissionProfile"
},
@@ -3875,6 +3875,17 @@
"PermissionsRequestApprovalResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"approvalsReviewer": {
"anyOf": [
{
"$ref": "#/definitions/v2/ApprovalsReviewer"
},
{
"type": "null"
}
],
"description": "Override who reviews later approval prompts for the rest of the current turn."
},
"permissions": {
"$ref": "#/definitions/GrantedPermissionProfile"
},
@@ -1,6 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ApprovalsReviewer } from "./ApprovalsReviewer";
import type { GrantedPermissionProfile } from "./GrantedPermissionProfile";
import type { PermissionGrantScope } from "./PermissionGrantScope";
@@ -8,4 +9,8 @@ export type PermissionsRequestApprovalResponse = { permissions: GrantedPermissio
/**
* Review every subsequent command in this turn before normal sandboxed execution.
*/
strictAutoReview?: boolean, };
strictAutoReview?: boolean,
/**
* Override who reviews later approval prompts for the rest of the current turn.
*/
approvalsReviewer?: ApprovalsReviewer, };
@@ -1,3 +1,4 @@
use super::shared::ApprovalsReviewer;
use super::shared::v2_enum_from_core;
use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment;
use codex_protocol::approvals::NetworkApprovalContext as CoreNetworkApprovalContext;
@@ -693,4 +694,8 @@ pub struct PermissionsRequestApprovalResponse {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub strict_auto_review: Option<bool>,
/// Override who reviews later approval prompts for the rest of the current turn.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub approvals_reviewer: Option<ApprovalsReviewer>,
}
@@ -708,6 +708,7 @@ fn permissions_request_approval_response_defaults_scope_to_turn() {
assert_eq!(response.scope, PermissionGrantScope::Turn);
assert_eq!(response.strict_auto_review, None);
assert_eq!(response.approvals_reviewer, None);
}
#[test]
@@ -721,6 +722,20 @@ fn permissions_request_approval_response_accepts_strict_auto_review() {
assert_eq!(response.strict_auto_review, Some(true));
}
#[test]
fn permissions_request_approval_response_accepts_approvals_reviewer() {
let response = serde_json::from_value::<PermissionsRequestApprovalResponse>(json!({
"permissions": {},
"approvalsReviewer": "auto_review",
}))
.expect("response should deserialize");
assert_eq!(
response.approvals_reviewer,
Some(ApprovalsReviewer::AutoReview)
);
}
#[test]
fn permission_profile_selection_uses_id_string() {
let start: ThreadStartParams = serde_json::from_value(json!({
+2 -1
View File
@@ -1458,13 +1458,14 @@ The built-in `request_permissions` tool sends an `item/permissions/requestApprov
}
```
The client responds with `result.permissions`, which should be the granted subset of the requested permission profile. It may also set `result.scope` to `"session"` to make the grant persist for later turns in the same session; omitted or `"turn"` keeps the existing turn-scoped behavior:
The client responds with `result.permissions`, which should be the granted subset of the requested permission profile. It may also set `result.scope` to `"session"` to make the grant persist for later turns in the same session; omitted or `"turn"` keeps the existing turn-scoped behavior. `approvalsReviewer` can be set to `"auto_review"` or `"user"` to choose who reviews later approval prompts for the rest of the current turn. This reviewer override is turn-scoped even when `scope` is `"session"` for the permission grant. Legacy clients may still send `strictAutoReview: true`, which is treated as `approvalsReviewer: "auto_review"` for compatibility:
```json
{
"id": 61,
"result": {
"scope": "session",
"approvalsReviewer": "auto_review",
"permissions": {
"fileSystem": {
"write": ["/Users/me/project"]
@@ -89,6 +89,7 @@ use codex_core::ThreadManager;
use codex_core::review_format::format_review_findings_block;
use codex_core::review_prompts;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ApprovalsReviewer as CoreApprovalsReviewer;
use codex_protocol::items::parse_hook_prompt_message;
use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile;
use codex_protocol::plan_tool::UpdatePlanArgs;
@@ -1849,6 +1850,7 @@ fn request_permissions_response_from_client_result(
permissions: Default::default(),
scope: CorePermissionGrantScope::Turn,
strict_auto_review: false,
approvals_reviewer: None,
});
}
Err(err) => {
@@ -1857,6 +1859,7 @@ fn request_permissions_response_from_client_result(
permissions: Default::default(),
scope: CorePermissionGrantScope::Turn,
strict_auto_review: false,
approvals_reviewer: None,
});
}
};
@@ -1868,22 +1871,14 @@ fn request_permissions_response_from_client_result(
permissions: V2GrantedPermissionProfile::default(),
scope: codex_app_server_protocol::PermissionGrantScope::Turn,
strict_auto_review: None,
approvals_reviewer: None,
}
});
let strict_auto_review = response.strict_auto_review.unwrap_or(false);
if strict_auto_review
&& matches!(
response.scope,
codex_app_server_protocol::PermissionGrantScope::Session
)
{
error!("strict auto review is only supported for turn-scoped permission grants");
return Some(CoreRequestPermissionsResponse {
permissions: Default::default(),
scope: CorePermissionGrantScope::Turn,
strict_auto_review: false,
});
}
let approvals_reviewer = response
.approvals_reviewer
.map(codex_app_server_protocol::ApprovalsReviewer::to_core)
.or_else(|| strict_auto_review.then_some(CoreApprovalsReviewer::AutoReview));
let granted_permissions: CoreAdditionalPermissionProfile = response.permissions.into();
let permissions = if granted_permissions.is_empty() {
CoreRequestPermissionProfile::default()
@@ -1894,6 +1889,7 @@ fn request_permissions_response_from_client_result(
permissions,
scope: response.scope.to_core(),
strict_auto_review,
approvals_reviewer,
})
}
@@ -3001,6 +2997,7 @@ mod tests {
permissions: expected_permissions,
scope: CorePermissionGrantScope::Turn,
strict_auto_review: false,
approvals_reviewer: None,
}
);
}
@@ -3024,14 +3021,20 @@ mod tests {
permissions: CoreRequestPermissionProfile::default(),
scope: CorePermissionGrantScope::Session,
strict_auto_review: false,
approvals_reviewer: None,
}
);
}
#[test]
fn request_permissions_response_rejects_session_scoped_strict_auto_review() {
fn request_permissions_response_preserves_session_scope_with_auto_review_override() {
let response = request_permissions_response_from_client_result(
CoreRequestPermissionProfile::default(),
CoreRequestPermissionProfile {
network: Some(codex_protocol::models::NetworkPermissions {
enabled: Some(true),
}),
..Default::default()
},
Ok(Ok(serde_json::json!({
"scope": "session",
"strictAutoReview": true,
@@ -3048,9 +3051,15 @@ mod tests {
assert_eq!(
response,
CoreRequestPermissionsResponse {
permissions: CoreRequestPermissionProfile::default(),
scope: CorePermissionGrantScope::Turn,
strict_auto_review: false,
permissions: CoreRequestPermissionProfile {
network: Some(codex_protocol::models::NetworkPermissions {
enabled: Some(true),
}),
..CoreRequestPermissionProfile::default()
},
scope: CorePermissionGrantScope::Session,
strict_auto_review: true,
approvals_reviewer: Some(CoreApprovalsReviewer::AutoReview),
}
);
}
@@ -3078,6 +3087,29 @@ mod tests {
assert_eq!(response.scope, CorePermissionGrantScope::Turn);
assert!(response.strict_auto_review);
assert_eq!(
response.approvals_reviewer,
Some(CoreApprovalsReviewer::AutoReview)
);
}
#[test]
fn request_permissions_response_preserves_explicit_approvals_reviewer() {
let response = request_permissions_response_from_client_result(
CoreRequestPermissionProfile::default(),
Ok(Ok(serde_json::json!({
"approvalsReviewer": "user",
"strictAutoReview": true,
"permissions": {},
}))),
std::env::current_dir().expect("current dir").as_path(),
)
.expect("response should be accepted");
assert_eq!(
response.approvals_reviewer,
Some(CoreApprovalsReviewer::User)
);
}
#[test]
@@ -121,6 +121,7 @@ async fn request_permissions_round_trip() -> Result<()> {
},
scope: PermissionGrantScope::Turn,
strict_auto_review: None,
approvals_reviewer: None,
})?,
)
.await?;
@@ -1,3 +1,4 @@
use crate::config_types::ApprovalsReviewer;
use crate::models::AdditionalPermissionProfile;
use crate::models::FileSystemPermissions;
use crate::models::NetworkPermissions;
@@ -69,6 +70,19 @@ pub struct RequestPermissionsResponse {
/// Review every subsequent command in this turn before normal sandboxed execution.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub strict_auto_review: bool,
/// Override who reviews later approval prompts for the rest of the current turn.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub approvals_reviewer: Option<ApprovalsReviewer>,
}
impl RequestPermissionsResponse {
pub fn approvals_reviewer_override(&self) -> Option<ApprovalsReviewer> {
self.approvals_reviewer.or_else(|| {
self.strict_auto_review
.then_some(ApprovalsReviewer::AutoReview)
})
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]