diff --git a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json index 11cce8c88..e066108e6 100644 --- a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json +++ b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json @@ -311,6 +311,13 @@ } ], "default": "turn" + }, + "strictAutoReview": { + "description": "Review every subsequent command in this turn before normal sandboxed execution.", + "type": [ + "boolean", + "null" + ] } }, "required": [ 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 c463df699..e16d84cad 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 @@ -3501,6 +3501,13 @@ } ], "default": "turn" + }, + "strictAutoReview": { + "description": "Review every subsequent command in this turn before normal sandboxed execution.", + "type": [ + "boolean", + "null" + ] } }, "required": [ 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 6561417b4..f42b39560 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalResponse.ts @@ -4,4 +4,8 @@ import type { GrantedPermissionProfile } from "./GrantedPermissionProfile"; import type { PermissionGrantScope } from "./PermissionGrantScope"; -export type PermissionsRequestApprovalResponse = { permissions: GrantedPermissionProfile, scope: PermissionGrantScope, }; +export type PermissionsRequestApprovalResponse = { permissions: GrantedPermissionProfile, scope: PermissionGrantScope, +/** + * Review every subsequent command in this turn before normal sandboxed execution. + */ +strictAutoReview?: boolean, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 934fbc951..1f23c7b53 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -7052,6 +7052,10 @@ pub struct PermissionsRequestApprovalResponse { pub permissions: GrantedPermissionProfile, #[serde(default)] pub scope: PermissionGrantScope, + /// Review every subsequent command in this turn before normal sandboxed execution. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub strict_auto_review: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -7801,6 +7805,18 @@ mod tests { .expect("response should deserialize"); assert_eq!(response.scope, PermissionGrantScope::Turn); + assert_eq!(response.strict_auto_review, None); + } + + #[test] + fn permissions_request_approval_response_accepts_strict_auto_review() { + let response = serde_json::from_value::(json!({ + "permissions": {}, + "strictAutoReview": true, + })) + .expect("response should deserialize"); + + assert_eq!(response.strict_auto_review, Some(true)); } #[test] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 4730d3167..240a070ed 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -961,6 +961,7 @@ pub(crate) async fn apply_bespoke_event_handling( let empty = CoreRequestPermissionsResponse { permissions: Default::default(), scope: CorePermissionGrantScope::Turn, + strict_auto_review: false, }; if let Err(err) = conversation .submit(Op::RequestPermissionsResponse { @@ -2669,6 +2670,7 @@ fn request_permissions_response_from_client_result( return Some(CoreRequestPermissionsResponse { permissions: Default::default(), scope: CorePermissionGrantScope::Turn, + strict_auto_review: false, }); } Err(err) => { @@ -2676,6 +2678,7 @@ fn request_permissions_response_from_client_result( return Some(CoreRequestPermissionsResponse { permissions: Default::default(), scope: CorePermissionGrantScope::Turn, + strict_auto_review: false, }); } }; @@ -2686,8 +2689,23 @@ fn request_permissions_response_from_client_result( PermissionsRequestApprovalResponse { permissions: V2GrantedPermissionProfile::default(), scope: codex_app_server_protocol::PermissionGrantScope::Turn, + strict_auto_review: 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 granted_permissions: CorePermissionProfile = response.permissions.into(); let permissions = if granted_permissions.is_empty() { CoreRequestPermissionProfile::default() @@ -2697,6 +2715,7 @@ fn request_permissions_response_from_client_result( Some(CoreRequestPermissionsResponse { permissions, scope: response.scope.to_core(), + strict_auto_review, }) } @@ -3858,6 +3877,7 @@ mod tests { CoreRequestPermissionsResponse { permissions: expected_permissions, scope: CorePermissionGrantScope::Turn, + strict_auto_review: false, } ); } @@ -3880,10 +3900,63 @@ mod tests { CoreRequestPermissionsResponse { permissions: CoreRequestPermissionProfile::default(), scope: CorePermissionGrantScope::Session, + strict_auto_review: false, } ); } + #[test] + fn request_permissions_response_rejects_session_scoped_strict_auto_review() { + let response = request_permissions_response_from_client_result( + CoreRequestPermissionProfile::default(), + Ok(Ok(serde_json::json!({ + "scope": "session", + "strictAutoReview": true, + "permissions": { + "network": { + "enabled": true, + }, + }, + }))), + std::env::current_dir().expect("current dir").as_path(), + ) + .expect("response should be accepted"); + + assert_eq!( + response, + CoreRequestPermissionsResponse { + permissions: CoreRequestPermissionProfile::default(), + scope: CorePermissionGrantScope::Turn, + strict_auto_review: false, + } + ); + } + + #[test] + fn request_permissions_response_preserves_turn_scoped_strict_auto_review() { + let response = request_permissions_response_from_client_result( + CoreRequestPermissionProfile { + network: Some(codex_protocol::models::NetworkPermissions { + enabled: Some(true), + }), + ..Default::default() + }, + Ok(Ok(serde_json::json!({ + "strictAutoReview": true, + "permissions": { + "network": { + "enabled": true, + }, + }, + }))), + std::env::current_dir().expect("current dir").as_path(), + ) + .expect("response should be accepted"); + + assert_eq!(response.scope, CorePermissionGrantScope::Turn); + assert!(response.strict_auto_review); + } + #[test] fn request_permissions_response_accepts_explicit_child_grant_for_requested_cwd_scope() { let temp_dir = TempDir::new().expect("temp dir"); 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 4f7c398f9..4a4eb9340 100644 --- a/codex-rs/app-server/tests/suite/v2/request_permissions.rs +++ b/codex-rs/app-server/tests/suite/v2/request_permissions.rs @@ -99,6 +99,7 @@ async fn request_permissions_round_trip() -> Result<()> { }), }, scope: PermissionGrantScope::Turn, + strict_auto_review: None, })?, ) .await?; diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 9fc64f214..631c83974 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -804,6 +804,7 @@ where let empty = RequestPermissionsResponse { permissions: Default::default(), scope: PermissionGrantScope::Turn, + strict_auto_review: false, }; parent_session .notify_request_permissions_response(call_id, empty.clone()) @@ -813,6 +814,7 @@ where response = fut => response.unwrap_or_else(|| RequestPermissionsResponse { permissions: Default::default(), scope: PermissionGrantScope::Turn, + strict_auto_review: false, }), } } diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index 3ce70c513..5f3428356 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -179,6 +179,7 @@ async fn handle_request_permissions_uses_tool_call_id_for_round_trip() { ..RequestPermissionProfile::default() }, scope: PermissionGrantScope::Turn, + strict_auto_review: false, }; let delegated_cwd = parent_ctx.cwd.join("delegated-cwd"); let cancel_token = CancellationToken::new(); diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index b9c711697..d740ee742 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1893,6 +1893,7 @@ impl Session { return Some(RequestPermissionsResponse { permissions: RequestPermissionProfile::default(), scope: PermissionGrantScope::Turn, + strict_auto_review: false, }); } AskForApproval::Granular(granular_config) @@ -1901,6 +1902,7 @@ impl Session { return Some(RequestPermissionsResponse { permissions: RequestPermissionProfile::default(), scope: PermissionGrantScope::Turn, + strict_auto_review: false, }); } AskForApproval::OnFailure @@ -1944,11 +1946,13 @@ impl Session { RequestPermissionsResponse { permissions: requested_permissions.clone(), scope: PermissionGrantScope::Turn, + strict_auto_review: false, } } ReviewDecision::ApprovedForSession => RequestPermissionsResponse { permissions: requested_permissions.clone(), scope: PermissionGrantScope::Session, + strict_auto_review: false, }, ReviewDecision::NetworkPolicyAmendment { network_policy_amendment, @@ -1956,16 +1960,19 @@ impl Session { NetworkPolicyRuleAction::Allow => RequestPermissionsResponse { permissions: requested_permissions.clone(), scope: PermissionGrantScope::Turn, + strict_auto_review: false, }, NetworkPolicyRuleAction::Deny => RequestPermissionsResponse { permissions: RequestPermissionProfile::default(), scope: PermissionGrantScope::Turn, + strict_auto_review: false, }, }, ReviewDecision::Abort | ReviewDecision::Denied | ReviewDecision::TimedOut => { RequestPermissionsResponse { permissions: RequestPermissionProfile::default(), scope: PermissionGrantScope::Turn, + strict_auto_review: false, } } }; @@ -2137,6 +2144,14 @@ impl Session { response: RequestPermissionsResponse, cwd: &Path, ) -> RequestPermissionsResponse { + if response.strict_auto_review && matches!(response.scope, PermissionGrantScope::Session) { + return RequestPermissionsResponse { + permissions: RequestPermissionProfile::default(), + scope: PermissionGrantScope::Turn, + strict_auto_review: false, + }; + } + if response.permissions.is_empty() { return response; } @@ -2149,6 +2164,7 @@ impl Session { ) .into(), scope: response.scope, + strict_auto_review: response.strict_auto_review, } } @@ -2164,7 +2180,11 @@ impl Session { PermissionGrantScope::Turn => { if let Some(turn_state) = originating_turn_state { let mut ts = turn_state.lock().await; - ts.record_granted_permissions(response.permissions.clone().into()); + let permissions: PermissionProfile = response.permissions.clone().into(); + ts.record_granted_permissions(permissions); + if response.strict_auto_review { + ts.enable_strict_auto_review(); + } } } PermissionGrantScope::Session => { @@ -2185,6 +2205,19 @@ impl Session { ts.granted_permissions() } + #[expect( + clippy::await_holding_invalid_type, + reason = "active turn reads must stay consistent with the matching turn state" + )] + pub(crate) async fn strict_auto_review_enabled_for_turn(&self) -> bool { + let active = self.active_turn.lock().await; + let Some(active) = active.as_ref() else { + return false; + }; + let ts = active.turn_state.lock().await; + ts.strict_auto_review_enabled() + } + pub(crate) async fn granted_session_permissions(&self) -> Option { let state = self.state.lock().await; state.granted_permissions() diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 4d88fa923..015b144d0 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3352,6 +3352,7 @@ async fn notify_request_permissions_response_ignores_unmatched_call_id() { ..RequestPermissionProfile::default() }, scope: PermissionGrantScope::Turn, + strict_auto_review: false, }, ) .await; @@ -3381,6 +3382,7 @@ async fn record_granted_request_permissions_for_turn_uses_originating_turn() { &codex_protocol::request_permissions::RequestPermissionsResponse { permissions: requested_permissions.clone(), scope: PermissionGrantScope::Turn, + strict_auto_review: false, }, Some(&originating_turn_state), ) @@ -3394,6 +3396,67 @@ async fn record_granted_request_permissions_for_turn_uses_originating_turn() { assert_eq!(session.granted_turn_permissions().await, None); } +#[tokio::test] +async fn enable_strict_auto_review_for_turn_uses_originating_turn() { + let (session, _turn_context) = make_session_and_context().await; + let originating_active_turn = ActiveTurn::default(); + let originating_turn_state = Arc::clone(&originating_active_turn.turn_state); + *session.active_turn.lock().await = Some(originating_active_turn); + + let requested_permissions = RequestPermissionProfile { + network: Some(codex_protocol::models::NetworkPermissions { + enabled: Some(true), + }), + ..RequestPermissionProfile::default() + }; + session + .record_granted_request_permissions_for_turn( + &codex_protocol::request_permissions::RequestPermissionsResponse { + permissions: requested_permissions.clone(), + scope: PermissionGrantScope::Turn, + strict_auto_review: true, + }, + Some(&originating_turn_state), + ) + .await; + + assert!( + originating_turn_state + .lock() + .await + .strict_auto_review_enabled() + ); +} + +#[test] +fn strict_auto_review_session_scope_grants_no_permissions() { + let requested_permissions = RequestPermissionProfile { + network: Some(codex_protocol::models::NetworkPermissions { + enabled: Some(true), + }), + ..RequestPermissionProfile::default() + }; + + let response = Session::normalize_request_permissions_response( + requested_permissions.clone(), + codex_protocol::request_permissions::RequestPermissionsResponse { + permissions: requested_permissions, + scope: PermissionGrantScope::Session, + strict_auto_review: true, + }, + std::path::Path::new("/tmp"), + ); + + assert_eq!( + response, + codex_protocol::request_permissions::RequestPermissionsResponse { + permissions: RequestPermissionProfile::default(), + scope: PermissionGrantScope::Turn, + strict_auto_review: false, + } + ); +} + #[tokio::test] async fn request_permissions_emits_event_when_granular_policy_allows_requests() { let (session, mut turn_context, rx) = make_session_and_context_with_rx().await; @@ -3421,6 +3484,7 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests() ..RequestPermissionProfile::default() }, scope: PermissionGrantScope::Turn, + strict_auto_review: false, }; let handle = tokio::spawn({ @@ -3536,6 +3600,7 @@ async fn request_permissions_response_materializes_session_cwd_grants_before_rec codex_protocol::request_permissions::RequestPermissionsResponse { permissions: request.permissions, scope: PermissionGrantScope::Session, + strict_auto_review: false, }, ) .await; @@ -3550,6 +3615,7 @@ async fn request_permissions_response_materializes_session_cwd_grants_before_rec let expected_response = codex_protocol::request_permissions::RequestPermissionsResponse { permissions: expected_permissions.clone(), scope: PermissionGrantScope::Session, + strict_auto_review: false, }; let response = tokio::time::timeout(StdDuration::from_secs(1), handle) @@ -3606,6 +3672,7 @@ async fn request_permissions_is_auto_denied_when_granular_policy_blocks_tool_req codex_protocol::request_permissions::RequestPermissionsResponse { permissions: RequestPermissionProfile::default(), scope: PermissionGrantScope::Turn, + strict_auto_review: false, } ) ); diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 5057c43ce..22db9f393 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -131,6 +131,7 @@ async fn request_permissions_routes_to_guardian_when_reviewer_is_enabled() { Some(RequestPermissionsResponse { permissions: requested_permissions.clone(), scope: PermissionGrantScope::Turn, + strict_auto_review: false, }) ); assert_eq!( @@ -379,6 +380,119 @@ async fn guardian_allows_shell_additional_permissions_requests_past_policy_valid assert!(exec_output.output.contains("hi")); } +#[tokio::test] +async fn strict_auto_review_turn_grant_forces_guardian_for_shell_policy_skip() { + let server = start_mock_server().await; + let guardian_request_log = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-guardian"), + ev_assistant_message( + "msg-guardian", + &serde_json::json!({ + "risk_level": "low", + "user_authorization": "high", + "outcome": "allow", + "rationale": "The command stays within the strict turn permission grant.", + }) + .to_string(), + ), + ev_completed("resp-guardian"), + ]), + ) + .await; + + let (mut session, mut turn_context_raw) = make_session_and_context().await; + let active_turn = crate::state::ActiveTurn::default(); + let originating_turn_state = Arc::clone(&active_turn.turn_state); + *session.active_turn.lock().await = Some(active_turn); + session + .record_granted_request_permissions_for_turn( + &RequestPermissionsResponse { + permissions: RequestPermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(true), + }), + ..Default::default() + }, + scope: PermissionGrantScope::Turn, + strict_auto_review: true, + }, + Some(&originating_turn_state), + ) + .await; + + turn_context_raw + .approval_policy + .set(AskForApproval::OnFailure) + .expect("test setup should allow updating approval policy"); + turn_context_raw + .sandbox_policy + .set(SandboxPolicy::DangerFullAccess) + .expect("test setup should allow updating sandbox policy"); + turn_context_raw.file_system_sandbox_policy = + FileSystemSandboxPolicy::from(turn_context_raw.sandbox_policy.get()); + turn_context_raw.network_sandbox_policy = + NetworkSandboxPolicy::from(turn_context_raw.sandbox_policy.get()); + let mut config = (*turn_context_raw.config).clone(); + config.approvals_reviewer = ApprovalsReviewer::User; + config.model_provider.base_url = Some(format!("{}/v1", server.uri())); + let config = Arc::new(config); + let models_manager = Arc::new(crate::test_support::models_manager_with_provider( + config.codex_home.to_path_buf(), + Arc::clone(&session.services.auth_manager), + config.model_provider.clone(), + )); + session.services.models_manager = models_manager; + turn_context_raw.config = Arc::clone(&config); + turn_context_raw.provider = create_model_provider( + config.model_provider.clone(), + turn_context_raw.auth_manager.clone(), + ); + let session = Arc::new(session); + let turn_context = Arc::new(turn_context_raw); + + let handler = ShellHandler; + let command = if cfg!(windows) { + vec![ + "cmd.exe".to_string(), + "/Q".to_string(), + "/D".to_string(), + "/C".to_string(), + "echo hi".to_string(), + ] + } else { + vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "echo hi".to_string(), + ] + }; + let resp = handler + .handle(ToolInvocation { + session: Arc::clone(&session), + turn: Arc::clone(&turn_context), + cancellation_token: CancellationToken::new(), + tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())), + call_id: "strict-shell-call".to_string(), + tool_name: codex_tools::ToolName::plain("shell"), + payload: ToolPayload::Function { + arguments: serde_json::json!({ + "command": command, + "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), + "timeout_ms": 1_000_u64, + }) + .to_string(), + }, + }) + .await; + + let output = expect_text_output(&resp.expect("expected Ok result")); + assert!(output.contains("hi")); + let guardian_request = guardian_request_log.single_request(); + assert!(guardian_request.body_contains_text("echo hi")); +} + #[tokio::test] async fn guardian_allows_unified_exec_additional_permissions_requests_past_policy_validation() { let (mut session, mut turn_context_raw) = make_session_and_context().await; diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index 903318c2a..5e1526ad5 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -106,6 +106,7 @@ pub(crate) struct TurnState { pending_input: Vec, mailbox_delivery_phase: MailboxDeliveryPhase, granted_permissions: Option, + strict_auto_review_enabled: bool, pub(crate) tool_calls: u64, pub(crate) has_memory_citation: bool, pub(crate) token_usage_at_turn_start: TokenUsage, @@ -254,6 +255,14 @@ impl TurnState { pub(crate) fn granted_permissions(&self) -> Option { self.granted_permissions.clone() } + + pub(crate) fn enable_strict_auto_review(&mut self) { + self.strict_auto_review_enabled = true; + } + + pub(crate) fn strict_auto_review_enabled(&self) -> bool { + self.strict_auto_review_enabled + } } impl ActiveTurn { diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index c1b960bc8..2e0f8072a 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -116,7 +116,8 @@ impl ToolOrchestrator { let otel = turn_ctx.session_telemetry.clone(); let otel_tn = &tool_ctx.tool_name; let otel_ci = &tool_ctx.call_id; - let use_guardian = routes_approval_to_guardian(turn_ctx); + let strict_auto_review = tool_ctx.session.strict_auto_review_enabled_for_turn().await; + let use_guardian = routes_approval_to_guardian(turn_ctx) || strict_auto_review; // 1) Approval let mut already_approved = false; @@ -126,12 +127,37 @@ impl ToolOrchestrator { }); match requirement { ExecApprovalRequirement::Skip { .. } => { - otel.tool_decision( - otel_tn, - otel_ci, - &ReviewDecision::Approved, - ToolDecisionSource::Config, - ); + if strict_auto_review { + let guardian_review_id = Some(new_guardian_review_id()); + let approval_ctx = ApprovalCtx { + session: &tool_ctx.session, + turn: &tool_ctx.turn, + call_id: &tool_ctx.call_id, + guardian_review_id: guardian_review_id.clone(), + retry_reason: None, + network_approval_context: None, + }; + let decision = Self::request_approval( + tool, + req, + tool_ctx.call_id.as_str(), + approval_ctx, + tool_ctx, + /*evaluate_permission_request_hooks*/ false, + &otel, + ) + .await?; + Self::reject_if_not_approved(tool_ctx, guardian_review_id.as_deref(), decision) + .await?; + already_approved = true; + } else { + otel.tool_decision( + otel_tn, + otel_ci, + &ReviewDecision::Approved, + ToolDecisionSource::Config, + ); + } } ExecApprovalRequirement::Forbidden { reason } => { return Err(ToolError::Rejected(reason)); @@ -152,35 +178,13 @@ impl ToolOrchestrator { tool_ctx.call_id.as_str(), approval_ctx, tool_ctx, - use_guardian, + /*evaluate_permission_request_hooks*/ !strict_auto_review, &otel, ) .await?; - match decision { - ReviewDecision::Denied | ReviewDecision::Abort => { - let reason = if let Some(review_id) = guardian_review_id.as_deref() { - guardian_rejection_message(tool_ctx.session.as_ref(), review_id).await - } else { - "rejected by user".to_string() - }; - return Err(ToolError::Rejected(reason)); - } - ReviewDecision::TimedOut => { - return Err(ToolError::Rejected(guardian_timeout_message())); - } - ReviewDecision::Approved - | ReviewDecision::ApprovedExecpolicyAmendment { .. } - | ReviewDecision::ApprovedForSession => {} - ReviewDecision::NetworkPolicyAmendment { - network_policy_amendment, - } => match network_policy_amendment.action { - NetworkPolicyRuleAction::Allow => {} - NetworkPolicyRuleAction::Deny => { - return Err(ToolError::Rejected("rejected by user".to_string())); - } - }, - } + Self::reject_if_not_approved(tool_ctx, guardian_review_id.as_deref(), decision) + .await?; already_approved = true; } } @@ -287,9 +291,10 @@ impl ToolOrchestrator { build_denial_reason_from_output(output.as_ref()) }; - // Ask for approval before retrying with the escalated sandbox. - let bypass_retry_approval = tool - .should_bypass_approval(approval_policy, already_approved) + // Strict auto-review approval covers the sandboxed attempt only; + // retrying without the sandbox requires a fresh guardian review. + let bypass_retry_approval = !strict_auto_review + && tool.should_bypass_approval(approval_policy, already_approved) && network_approval_context.is_none(); if !bypass_retry_approval { let guardian_review_id = use_guardian.then(new_guardian_review_id); @@ -309,36 +314,13 @@ impl ToolOrchestrator { &permission_request_run_id, approval_ctx, tool_ctx, - use_guardian, + /*evaluate_permission_request_hooks*/ !strict_auto_review, &otel, ) .await?; - match decision { - ReviewDecision::Denied | ReviewDecision::Abort => { - let reason = if let Some(review_id) = guardian_review_id.as_deref() { - guardian_rejection_message(tool_ctx.session.as_ref(), review_id) - .await - } else { - "rejected by user".to_string() - }; - return Err(ToolError::Rejected(reason)); - } - ReviewDecision::TimedOut => { - return Err(ToolError::Rejected(guardian_timeout_message())); - } - ReviewDecision::Approved - | ReviewDecision::ApprovedExecpolicyAmendment { .. } - | ReviewDecision::ApprovedForSession => {} - ReviewDecision::NetworkPolicyAmendment { - network_policy_amendment, - } => match network_policy_amendment.action { - NetworkPolicyRuleAction::Allow => {} - NetworkPolicyRuleAction::Deny => { - return Err(ToolError::Rejected("rejected by user".to_string())); - } - }, - } + Self::reject_if_not_approved(tool_ctx, guardian_review_id.as_deref(), decision) + .await?; } let escalated_attempt = SandboxAttempt { @@ -385,13 +367,15 @@ impl ToolOrchestrator { permission_request_run_id: &str, approval_ctx: ApprovalCtx<'_>, tool_ctx: &ToolCtx, - use_guardian: bool, + evaluate_permission_request_hooks: bool, otel: &codex_otel::SessionTelemetry, ) -> Result where T: ToolRuntime, { - if let Some(permission_request) = tool.permission_request_payload(req) { + if evaluate_permission_request_hooks + && let Some(permission_request) = tool.permission_request_payload(req) + { match run_permission_request_hooks( approval_ctx.session, approval_ctx.turn, @@ -424,12 +408,12 @@ impl ToolOrchestrator { } } - let decision = tool.start_approval_async(req, approval_ctx).await; - let otel_source = if use_guardian { + let otel_source = if approval_ctx.guardian_review_id.is_some() { ToolDecisionSource::AutomatedReviewer } else { ToolDecisionSource::User }; + let decision = tool.start_approval_async(req, approval_ctx).await; otel.tool_decision( &tool_ctx.tool_name, &tool_ctx.call_id, @@ -438,6 +422,35 @@ impl ToolOrchestrator { ); Ok(decision) } + + async fn reject_if_not_approved( + tool_ctx: &ToolCtx, + guardian_review_id: Option<&str>, + decision: ReviewDecision, + ) -> Result<(), ToolError> { + match decision { + ReviewDecision::Denied | ReviewDecision::Abort => { + let reason = if let Some(review_id) = guardian_review_id { + guardian_rejection_message(tool_ctx.session.as_ref(), review_id).await + } else { + "rejected by user".to_string() + }; + Err(ToolError::Rejected(reason)) + } + ReviewDecision::TimedOut => Err(ToolError::Rejected(guardian_timeout_message())), + ReviewDecision::Approved + | ReviewDecision::ApprovedExecpolicyAmendment { .. } + | ReviewDecision::ApprovedForSession => Ok(()), + ReviewDecision::NetworkPolicyAmendment { + network_policy_amendment, + } => match network_policy_amendment.action { + NetworkPolicyRuleAction::Allow => Ok(()), + NetworkPolicyRuleAction::Deny => { + Err(ToolError::Rejected("rejected by user".to_string())) + } + }, + } + } } fn build_denial_reason_from_output(_output: &ExecToolCallOutput) -> String { diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index afc8751fa..5de3235f8 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -139,14 +139,14 @@ impl Approvable for ApplyPatchRuntime { let changes = req.changes.clone(); let guardian_review_id = ctx.guardian_review_id.clone(); Box::pin(async move { - if req.permissions_preapproved && retry_reason.is_none() { - return ReviewDecision::Approved; - } if let Some(review_id) = guardian_review_id { let action = ApplyPatchRuntime::build_guardian_review_request(req, ctx.call_id); return review_approval_request(session, turn, review_id, action, retry_reason) .await; } + if req.permissions_preapproved && retry_reason.is_none() { + return ReviewDecision::Approved; + } if let Some(reason) = retry_reason { let rx_approve = session .request_patch_approval( diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 866df1386..0b9dbf584 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -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?; diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index d31b060c3..567af8294 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -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, ) -> 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(()) diff --git a/codex-rs/protocol/src/request_permissions.rs b/codex-rs/protocol/src/request_permissions.rs index 473b8d973..0649bf288 100644 --- a/codex-rs/protocol/src/request_permissions.rs +++ b/codex-rs/protocol/src/request_permissions.rs @@ -58,6 +58,9 @@ pub struct RequestPermissionsResponse { pub permissions: RequestPermissionProfile, #[serde(default)] pub scope: PermissionGrantScope, + /// 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, } #[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 91701cd13..3149dd9f5 100644 --- a/codex-rs/tui/src/app/app_server_requests.rs +++ b/codex-rs/tui/src/app/app_server_requests.rs @@ -183,6 +183,7 @@ impl PendingAppServerRequests { response.permissions.clone(), ), scope: response.scope.into(), + strict_auto_review: response.strict_auto_review.then_some(true), }) .map_err(|err| { format!("failed to serialize permissions approval response: {err}") @@ -543,6 +544,7 @@ mod tests { )), }, scope: codex_protocol::request_permissions::PermissionGrantScope::Session, + strict_auto_review: false, }, }) .expect("permissions response should serialize") @@ -564,6 +566,7 @@ mod tests { }), }, scope: PermissionGrantScope::Session, + strict_auto_review: None, } ); diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 5212d8195..1f7e30c37 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -278,8 +278,8 @@ impl ApprovalOverlay { permissions, .. }, - ApprovalDecision::Review(decision), - ) => self.handle_permissions_decision(call_id, permissions, decision.clone()), + ApprovalDecision::Permissions(decision), + ) => self.handle_permissions_decision(call_id, permissions, *decision), (ApprovalRequest::ApplyPatch { id, .. }, ApprovalDecision::Review(decision)) => { self.handle_patch_decision(id, decision.clone()); } @@ -322,27 +322,31 @@ impl ApprovalOverlay { &self, call_id: &str, permissions: &RequestPermissionProfile, - decision: ReviewDecision, + decision: PermissionsDecision, ) { let Some(request) = self.current_request.as_ref() else { return; }; let granted_permissions = match decision { - ReviewDecision::Approved | ReviewDecision::ApprovedForSession => permissions.clone(), - ReviewDecision::Denied | ReviewDecision::TimedOut | ReviewDecision::Abort => { - Default::default() - } - ReviewDecision::ApprovedExecpolicyAmendment { .. } - | ReviewDecision::NetworkPolicyAmendment { .. } => Default::default(), + PermissionsDecision::GrantForTurn + | PermissionsDecision::GrantForTurnWithStrictAutoReview + | PermissionsDecision::GrantForSession => permissions.clone(), + PermissionsDecision::Deny => Default::default(), }; - let scope = if matches!(decision, ReviewDecision::ApprovedForSession) { + let scope = if matches!(decision, PermissionsDecision::GrantForSession) { PermissionGrantScope::Session } else { PermissionGrantScope::Turn }; + let strict_auto_review = matches!( + decision, + PermissionsDecision::GrantForTurnWithStrictAutoReview + ); if request.thread_label().is_none() { let message = if granted_permissions.is_empty() { "You did not grant additional permissions" + } else if strict_auto_review { + "You granted additional permissions with strict auto review" } else if matches!(scope, PermissionGrantScope::Session) { "You granted additional permissions for this session" } else { @@ -359,6 +363,7 @@ impl ApprovalOverlay { codex_protocol::request_permissions::RequestPermissionsResponse { permissions: granted_permissions, scope, + strict_auto_review, }, ); } @@ -482,7 +487,11 @@ impl BottomPaneView for ApprovalOverlay { permissions, .. } => { - self.handle_permissions_decision(call_id, permissions, ReviewDecision::Abort); + self.handle_permissions_decision( + call_id, + permissions, + PermissionsDecision::Deny, + ); } ApprovalRequest::ApplyPatch { id, .. } => { self.handle_patch_decision(id, ReviewDecision::Abort); @@ -679,9 +688,18 @@ fn build_header(request: &ApprovalRequest) -> Box { #[derive(Clone)] enum ApprovalDecision { Review(ReviewDecision), + Permissions(PermissionsDecision), McpElicitation(ElicitationAction), } +#[derive(Clone, Copy)] +enum PermissionsDecision { + GrantForTurn, + GrantForTurnWithStrictAutoReview, + GrantForSession, + Deny, +} + #[derive(Clone)] struct ApprovalOption { label: String, @@ -901,20 +919,28 @@ fn patch_options() -> Vec { fn permissions_options() -> Vec { vec![ ApprovalOption { - label: "Yes, grant these permissions".to_string(), - decision: ApprovalDecision::Review(ReviewDecision::Approved), + label: "Yes, grant these permissions for this turn".to_string(), + decision: ApprovalDecision::Permissions(PermissionsDecision::GrantForTurn), display_shortcut: None, additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], }, + ApprovalOption { + label: "Yes, grant for this turn with strict auto review".to_string(), + decision: ApprovalDecision::Permissions( + PermissionsDecision::GrantForTurnWithStrictAutoReview, + ), + display_shortcut: None, + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('r'))], + }, ApprovalOption { label: "Yes, grant these permissions for this session".to_string(), - decision: ApprovalDecision::Review(ReviewDecision::ApprovedForSession), + decision: ApprovalDecision::Permissions(PermissionsDecision::GrantForSession), display_shortcut: None, additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))], }, ApprovalOption { label: "No, continue without permissions".to_string(), - decision: ApprovalDecision::Review(ReviewDecision::Denied), + decision: ApprovalDecision::Permissions(PermissionsDecision::Deny), display_shortcut: None, additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], }, @@ -1347,7 +1373,8 @@ mod tests { assert_eq!( labels, vec![ - "Yes, grant these permissions".to_string(), + "Yes, grant these permissions for this turn".to_string(), + "Yes, grant for this turn with strict auto review".to_string(), "Yes, grant these permissions for this session".to_string(), "No, continue without permissions".to_string(), ] @@ -1410,6 +1437,34 @@ mod tests { ); } + #[test] + fn permissions_strict_auto_review_shortcut_submits_turn_scope_with_strict_review() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let mut view = + ApprovalOverlay::new(make_permissions_request(), tx, Features::with_defaults()); + + view.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)); + + let mut saw_op = false; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::SubmitThreadOp { + op: Op::RequestPermissionsResponse { response, .. }, + .. + } = ev + { + assert_eq!(response.scope, PermissionGrantScope::Turn); + assert!(response.strict_auto_review); + saw_op = true; + break; + } + } + assert!( + saw_op, + "expected permission approval decision to emit a strict auto review response" + ); + } + #[test] fn additional_permissions_prompt_shows_permission_rule_line() { let (tx, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_permissions_prompt.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_permissions_prompt.snap index e7a21c42c..391102e74 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_permissions_prompt.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__approval_overlay__tests__approval_overlay_permissions_prompt.snap @@ -9,8 +9,9 @@ expression: "normalize_snapshot_paths(render_overlay_lines(&view, 120))" Permission rule: network; read `/tmp/readme.txt`; write `/tmp/out.txt` -› 1. Yes, grant these permissions (y) - 2. Yes, grant these permissions for this session (a) - 3. No, continue without permissions (n) +› 1. Yes, grant these permissions for this turn (y) + 2. Yes, grant for this turn with strict auto review (r) + 3. Yes, grant these permissions for this session (a) + 4. No, continue without permissions (n) Press enter to confirm or esc to cancel