diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index b6af117660..a33dea23d1 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -885,6 +885,7 @@ fn sample_effective_permissions_approval_response( permissions, scope, strict_auto_review: false, + approvals_reviewer: None, } } diff --git a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json index 5cce2cdc5a..b2d8542723 100644 --- a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json +++ b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json @@ -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" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index fcf014d26f..323c89d7bc 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -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" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalResponse.ts index f42b39560c..bb97f80009 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalResponse.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs index 45fc5c7e14..760a444e9e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs @@ -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, + /// 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, } diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index c570b87ce4..5a0ed6b4d1 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -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::(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!({ diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 0532ffa706..d144a7ce33 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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"] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 1f47bc4dd8..37a12ab974 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -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] diff --git a/codex-rs/app-server/tests/suite/v2/request_permissions.rs b/codex-rs/app-server/tests/suite/v2/request_permissions.rs index bea78901f5..8089ace5a4 100644 --- a/codex-rs/app-server/tests/suite/v2/request_permissions.rs +++ b/codex-rs/app-server/tests/suite/v2/request_permissions.rs @@ -121,6 +121,7 @@ async fn request_permissions_round_trip() -> Result<()> { }, scope: PermissionGrantScope::Turn, strict_auto_review: None, + approvals_reviewer: None, })?, ) .await?; diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 5b68ecf917..6a7e3831e3 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -823,6 +823,7 @@ where permissions: Default::default(), scope: PermissionGrantScope::Turn, strict_auto_review: false, + approvals_reviewer: None, }; parent_session .notify_request_permissions_response(call_id, empty.clone()) @@ -833,6 +834,7 @@ where permissions: Default::default(), scope: PermissionGrantScope::Turn, strict_auto_review: false, + approvals_reviewer: None, }), } } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index ddb31a9a52..3426aa0775 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2151,6 +2151,7 @@ impl Session { permissions: RequestPermissionProfile::default(), scope: PermissionGrantScope::Turn, strict_auto_review: false, + approvals_reviewer: None, }); } AskForApproval::Granular(granular_config) @@ -2160,6 +2161,7 @@ impl Session { permissions: RequestPermissionProfile::default(), scope: PermissionGrantScope::Turn, strict_auto_review: false, + approvals_reviewer: None, }); } AskForApproval::OnFailure @@ -2204,12 +2206,14 @@ impl Session { permissions: requested_permissions.clone(), scope: PermissionGrantScope::Turn, strict_auto_review: false, + approvals_reviewer: None, } } ReviewDecision::ApprovedForSession => RequestPermissionsResponse { permissions: requested_permissions.clone(), scope: PermissionGrantScope::Session, strict_auto_review: false, + approvals_reviewer: None, }, ReviewDecision::NetworkPolicyAmendment { network_policy_amendment, @@ -2218,11 +2222,13 @@ impl Session { permissions: requested_permissions.clone(), scope: PermissionGrantScope::Turn, strict_auto_review: false, + approvals_reviewer: None, }, NetworkPolicyRuleAction::Deny => RequestPermissionsResponse { permissions: RequestPermissionProfile::default(), scope: PermissionGrantScope::Turn, strict_auto_review: false, + approvals_reviewer: None, }, }, ReviewDecision::Abort | ReviewDecision::Denied | ReviewDecision::TimedOut => { @@ -2230,6 +2236,7 @@ impl Session { permissions: RequestPermissionProfile::default(), scope: PermissionGrantScope::Turn, strict_auto_review: false, + approvals_reviewer: None, } } }; @@ -2314,6 +2321,7 @@ impl Session { permissions: RequestPermissionProfile::default(), scope: PermissionGrantScope::Turn, strict_auto_review: false, + approvals_reviewer: None, }); }; let mut environment = turn_environment.selection(); @@ -2448,6 +2456,7 @@ impl Session { permissions: RequestPermissionProfile::default(), scope: PermissionGrantScope::Turn, strict_auto_review: false, + approvals_reviewer: None, }; } @@ -2464,6 +2473,7 @@ impl Session { .into(), scope: response.scope, strict_auto_review: response.strict_auto_review, + approvals_reviewer: response.approvals_reviewer, } } diff --git a/codex-rs/protocol/src/request_permissions.rs b/codex-rs/protocol/src/request_permissions.rs index 1752363ec5..9aa276e77c 100644 --- a/codex-rs/protocol/src/request_permissions.rs +++ b/codex-rs/protocol/src/request_permissions.rs @@ -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, +} + +impl RequestPermissionsResponse { + pub fn approvals_reviewer_override(&self) -> Option { + self.approvals_reviewer.or_else(|| { + self.strict_auto_review + .then_some(ApprovalsReviewer::AutoReview) + }) + } } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/tui/src/app/app_server_requests.rs b/codex-rs/tui/src/app/app_server_requests.rs index c2580bd075..8d84dce6d5 100644 --- a/codex-rs/tui/src/app/app_server_requests.rs +++ b/codex-rs/tui/src/app/app_server_requests.rs @@ -210,6 +210,7 @@ impl PendingAppServerRequests { ), scope: response.scope.into(), strict_auto_review: response.strict_auto_review.then_some(true), + approvals_reviewer: None, }) .map_err(|err| { format!("failed to serialize permissions approval response: {err}") diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index f318dd2103..d801e3b230 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -431,6 +431,7 @@ impl ApprovalOverlay { permissions: granted_permissions, scope, strict_auto_review, + approvals_reviewer: None, }, ); }