From ffa0ea303d49087ed9d706e9da6fe8b38f52ddb9 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sun, 12 Apr 2026 21:08:51 -0300 Subject: [PATCH] feat(core): add conversational permission preset foundation Add the opt-in `request_permission_preset` flow across `codex-core`, `codex-protocol`, and `codex-tools`, including preset definitions, request state, tool wiring, and focused regression coverage for preset-driven permission changes. Validate preset responses against pending call ids before applying them, re-resolve accepted presets against the current session, and record turn-local overrides only after `update_settings` succeeds so stale or rejected replies cannot widen permissions. --- codex-rs/Cargo.lock | 2 + codex-rs/core/Cargo.toml | 1 + codex-rs/core/config.schema.json | 6 + codex-rs/core/src/codex.rs | 294 +++++++++++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/codex_delegate_tests.rs | 1 + codex-rs/core/src/codex_tests.rs | 3 + codex-rs/core/src/context_manager/updates.rs | 1 + codex-rs/core/src/state/mod.rs | 1 + codex-rs/core/src/state/turn.rs | 41 ++ .../core/src/tools/handlers/apply_patch.rs | 6 + codex-rs/core/src/tools/handlers/mod.rs | 2 + .../handlers/request_permission_preset.rs | 51 ++ .../src/tools/handlers/request_permissions.rs | 105 ++++- codex-rs/core/src/tools/handlers/shell.rs | 5 + .../core/src/tools/handlers/unified_exec.rs | 6 + codex-rs/core/src/tools/spec.rs | 5 + codex-rs/core/tests/suite/mod.rs | 1 + .../core/tests/suite/permissions_messages.rs | 1 + .../suite/request_permission_preset_tool.rs | 438 ++++++++++++++++++ .../core/tests/suite/request_permissions.rs | 108 +++++ codex-rs/exec/src/lib.rs | 13 + codex-rs/features/src/lib.rs | 12 +- codex-rs/features/src/tests.rs | 15 +- codex-rs/mcp-server/src/codex_tool_runner.rs | 1 + codex-rs/protocol/src/lib.rs | 1 + codex-rs/protocol/src/models.rs | 133 +++++- codex-rs/protocol/src/protocol.rs | 13 + .../protocol/src/request_permission_preset.rs | 91 ++++ codex-rs/protocol/src/request_permissions.rs | 17 + codex-rs/rollout/src/policy.rs | 1 + codex-rs/tools/Cargo.toml | 1 + codex-rs/tools/src/lib.rs | 2 + codex-rs/tools/src/local_tool.rs | 51 +- codex-rs/tools/src/local_tool_tests.rs | 18 + codex-rs/tools/src/tool_config.rs | 7 + codex-rs/tools/src/tool_registry_plan.rs | 21 + .../tools/src/tool_registry_plan_tests.rs | 113 ++++- .../tools/src/tool_registry_plan_types.rs | 1 + codex-rs/tui/src/chatwidget.rs | 1 + codex-rs/utils/approval-presets/src/lib.rs | 76 +++ 41 files changed, 1626 insertions(+), 41 deletions(-) create mode 100644 codex-rs/core/src/tools/handlers/request_permission_preset.rs create mode 100644 codex-rs/core/tests/suite/request_permission_preset_tool.rs create mode 100644 codex-rs/protocol/src/request_permission_preset.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 6c101b940d..d678696061 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1934,6 +1934,7 @@ dependencies = [ "codex-terminal-detection", "codex-tools", "codex-utils-absolute-path", + "codex-utils-approval-presets", "codex-utils-cache", "codex-utils-cargo-bin", "codex-utils-home-dir", @@ -2819,6 +2820,7 @@ dependencies = [ "codex-features", "codex-protocol", "codex-utils-absolute-path", + "codex-utils-approval-presets", "codex-utils-pty", "pretty_assertions", "rmcp", diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 55ce13afdc..5185d67165 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -58,6 +58,7 @@ codex-state = { workspace = true } codex-terminal-detection = { workspace = true } codex-tools = { workspace = true } codex-utils-absolute-path = { workspace = true } +codex-utils-approval-presets = { workspace = true } codex-utils-cache = { workspace = true } codex-utils-image = { workspace = true } codex-utils-home-dir = { workspace = true } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index bd74c46595..cff4f60db0 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -443,6 +443,9 @@ "remote_models": { "type": "boolean" }, + "request_permission_preset_tool": { + "type": "boolean" + }, "request_permissions": { "type": "boolean" }, @@ -2292,6 +2295,9 @@ "remote_models": { "type": "boolean" }, + "request_permission_preset_tool": { + "type": "boolean" + }, "request_permissions": { "type": "boolean" }, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index c19c4b48fd..4a52426fff 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -125,6 +125,11 @@ use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; use codex_protocol::protocol::W3cTraceContext; +use codex_protocol::request_permission_preset::PermissionPresetId; +use codex_protocol::request_permission_preset::RequestPermissionPresetArgs; +use codex_protocol::request_permission_preset::RequestPermissionPresetDecision; +use codex_protocol::request_permission_preset::RequestPermissionPresetEvent; +use codex_protocol::request_permission_preset::RequestPermissionPresetResponse; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::request_permissions::RequestPermissionsArgs; @@ -137,6 +142,8 @@ use codex_rollout::state_db; use codex_shell_command::parse_command::parse_command; use codex_terminal_detection::user_agent; use codex_tools::filter_tool_suggest_discoverable_tools_for_client; +use codex_utils_approval_presets::PermissionPreset; +use codex_utils_approval_presets::find_builtin_permission_preset; use codex_utils_output_truncation::TruncationPolicy; use codex_utils_stream_parser::AssistantTextChunk; use codex_utils_stream_parser::AssistantTextStreamParser; @@ -302,6 +309,7 @@ use crate::state::ActiveTurn; use crate::state::MailboxDeliveryPhase; use crate::state::SessionServices; use crate::state::SessionState; +use crate::state::TurnPermissionPresetOverride; use crate::tasks::GhostSnapshotTask; use crate::tasks::ReviewTask; use crate::tasks::SessionTask; @@ -862,7 +870,7 @@ impl TurnSkillsContext { } /// The context needed for a single turn of the thread. -#[derive(Debug)] +#[derive(Clone, Debug)] pub(crate) struct TurnContext { pub(crate) sub_id: String, pub(crate) trace_id: Option, @@ -910,6 +918,21 @@ pub(crate) struct TurnContext { pub(crate) turn_timing_state: Arc, } impl TurnContext { + pub(crate) fn with_effective_permission_settings( + &self, + settings: EffectivePermissionSettings, + ) -> ConstraintResult { + let mut next = self.clone(); + next.approval_policy.set(settings.approval_policy)?; + let mut config = (*next.config).clone(); + config.approvals_reviewer = settings.approvals_reviewer; + next.config = Arc::new(config); + next.sandbox_policy.set(settings.sandbox_policy)?; + next.file_system_sandbox_policy = settings.file_system_sandbox_policy; + next.network_sandbox_policy = settings.network_sandbox_policy; + Ok(next) + } + pub(crate) fn model_context_window(&self) -> Option { let effective_context_window_percent = self.model_info.effective_context_window_percent; self.model_info.context_window.map(|context_window| { @@ -1286,6 +1309,15 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) app_server_client_version: Option, } +#[derive(Clone)] +pub(crate) struct EffectivePermissionSettings { + pub(crate) approval_policy: AskForApproval, + pub(crate) approvals_reviewer: ApprovalsReviewer, + pub(crate) sandbox_policy: SandboxPolicy, + pub(crate) file_system_sandbox_policy: FileSystemSandboxPolicy, + pub(crate) network_sandbox_policy: NetworkSandboxPolicy, +} + pub(crate) struct AppServerClientMetadata { pub(crate) client_name: Option, pub(crate) client_version: Option, @@ -3247,6 +3279,13 @@ impl Session { rx_approve } + /// Sends a narrow permission grant request to the active client and waits for the user's decision. + /// + /// The returned response contains the permissions the client says the user + /// granted, which may be empty or narrower than the requested profile. A + /// caller that treats the original request as granted would bypass the + /// confirmation contract and could run a later tool with permissions the + /// user declined. pub async fn request_permissions( &self, turn_context: &TurnContext, @@ -3297,11 +3336,229 @@ impl Session { turn_id: turn_context.sub_id.clone(), reason: args.reason, permissions: args.permissions, + suggested_scope: args.scope, }); self.send_event(turn_context, event).await; rx_response.await.ok() } + /// Sends a permission preset request to the active client and waits for the user's decision. + /// + /// Core resolves and validates the preset before emitting the event so the + /// client confirms concrete settings rather than interpreting policy names. + /// The settings are not applied by this function; they become active only + /// when `notify_request_permission_preset_response` receives an accepted + /// response for the pending call id. + pub async fn request_permission_preset( + &self, + turn_context: &TurnContext, + call_id: String, + args: RequestPermissionPresetArgs, + ) -> RequestPermissionPresetResponse { + if let AskForApproval::Granular(granular_config) = turn_context.approval_policy.value() + && !granular_config.allows_request_permissions() + { + return RequestPermissionPresetResponse { + decision: RequestPermissionPresetDecision::Declined, + preset: args.preset, + message: "permission preset prompts are disabled by granular.request_permissions" + .to_string(), + }; + } + + let Some(preset) = resolve_permission_preset(turn_context, args.preset) else { + return RequestPermissionPresetResponse { + decision: RequestPermissionPresetDecision::Declined, + preset: args.preset, + message: "requested permission preset is not available in this session".to_string(), + }; + }; + + if let Err(err) = turn_context.approval_policy.can_set(&preset.approval) { + return RequestPermissionPresetResponse { + decision: RequestPermissionPresetDecision::Declined, + preset: args.preset, + message: err.to_string(), + }; + } + if let Err(err) = turn_context.sandbox_policy.can_set(&preset.sandbox) { + return RequestPermissionPresetResponse { + decision: RequestPermissionPresetDecision::Declined, + preset: args.preset, + message: err.to_string(), + }; + } + + let Some(preset_id) = PermissionPresetId::from_id(preset.id) else { + return RequestPermissionPresetResponse { + decision: RequestPermissionPresetDecision::Declined, + preset: args.preset, + message: "requested permission preset is not supported".to_string(), + }; + }; + + let (tx_response, rx_response) = oneshot::channel(); + let prev_entry = { + let mut active = self.active_turn.lock().await; + match active.as_mut() { + Some(at) => { + let mut ts = at.turn_state.lock().await; + ts.insert_pending_request_permission_preset(call_id.clone(), tx_response) + } + None => None, + } + }; + if prev_entry.is_some() { + warn!("Overwriting existing pending request_permission_preset for call_id: {call_id}"); + } + + let event = EventMsg::RequestPermissionPreset(RequestPermissionPresetEvent { + call_id, + turn_id: turn_context.sub_id.clone(), + preset: preset_id, + label: preset.label.to_string(), + description: preset.description.to_string(), + approval_policy: preset.approval, + approvals_reviewer: preset.approvals_reviewer, + sandbox_policy: preset.sandbox, + reason: args.reason, + }); + self.send_event(turn_context, event).await; + rx_response + .await + .unwrap_or_else(|_| RequestPermissionPresetResponse { + decision: RequestPermissionPresetDecision::Declined, + preset: args.preset, + message: "request_permission_preset was cancelled before receiving a response" + .to_string(), + }) + } + + /// Applies an accepted permission preset response and completes the pending tool call. + /// + /// Responses for unknown call ids are logged and ignored. Accepted + /// responses are re-resolved against the current session before applying so + /// a stale client response cannot select a preset that is no longer + /// available. + pub async fn notify_request_permission_preset_response( + &self, + call_id: &str, + response: RequestPermissionPresetResponse, + ) { + let Some((turn_state, tx_response)) = ({ + let mut active = self.active_turn.lock().await; + match active.as_mut() { + Some(at) => { + let turn_state = at.turn_state.clone(); + let entry = { + let mut ts = turn_state.lock().await; + ts.remove_pending_request_permission_preset(call_id) + }; + entry.map(|tx_response| (turn_state, tx_response)) + } + None => None, + } + }) else { + warn!("No pending request_permission_preset found for call_id: {call_id}"); + return; + }; + + let mut response = response; + if matches!(response.decision, RequestPermissionPresetDecision::Accepted) { + match self + .resolve_permission_preset_for_current_session(response.preset) + .await + { + Some(preset) => { + let preset_override = TurnPermissionPresetOverride { + approval_policy: preset.approval, + approvals_reviewer: preset.approvals_reviewer, + sandbox_policy: preset.sandbox.clone(), + }; + if let Err(err) = self + .update_settings(SessionSettingsUpdate { + approval_policy: Some(preset.approval), + approvals_reviewer: Some(preset.approvals_reviewer), + sandbox_policy: Some(preset.sandbox), + ..Default::default() + }) + .await + { + warn!("failed to apply approved permission preset: {err}"); + response = RequestPermissionPresetResponse { + decision: RequestPermissionPresetDecision::Declined, + preset: response.preset, + message: format!( + "requested permission preset could not be applied: {err}" + ), + }; + } else { + let mut ts = turn_state.lock().await; + ts.record_permission_preset_override(preset_override); + } + } + None => { + response = RequestPermissionPresetResponse { + decision: RequestPermissionPresetDecision::Declined, + preset: response.preset, + message: "requested permission preset is no longer available".to_string(), + }; + } + } + } + + tx_response.send(response).ok(); + } + + async fn resolve_permission_preset_for_current_session( + &self, + preset_id: PermissionPresetId, + ) -> Option { + let features = self.features(); + find_builtin_permission_preset( + preset_id.as_str(), + cfg!(target_os = "windows"), + features.enabled(Feature::GuardianApproval), + ) + } + + pub(crate) async fn effective_permission_settings( + &self, + turn_context: &TurnContext, + ) -> EffectivePermissionSettings { + let preset_override = { + let active = self.active_turn.lock().await; + match active.as_ref() { + Some(at) => { + let ts = at.turn_state.lock().await; + ts.permission_preset_override() + } + None => None, + } + }; + + if let Some(preset_override) = preset_override { + return EffectivePermissionSettings { + approval_policy: preset_override.approval_policy, + approvals_reviewer: preset_override.approvals_reviewer, + file_system_sandbox_policy: FileSystemSandboxPolicy::from_legacy_sandbox_policy( + &preset_override.sandbox_policy, + &turn_context.cwd, + ), + network_sandbox_policy: NetworkSandboxPolicy::from(&preset_override.sandbox_policy), + sandbox_policy: preset_override.sandbox_policy, + }; + } + + EffectivePermissionSettings { + approval_policy: turn_context.approval_policy.value(), + approvals_reviewer: turn_context.config.approvals_reviewer, + sandbox_policy: turn_context.sandbox_policy.get().clone(), + file_system_sandbox_policy: turn_context.file_system_sandbox_policy.clone(), + network_sandbox_policy: turn_context.network_sandbox_policy, + } + } + pub async fn request_user_input( &self, turn_context: &TurnContext, @@ -3438,6 +3695,12 @@ impl Session { } } + /// Records an approved narrow permission grant and completes the pending tool call. + /// + /// Turn-scoped grants are stored on the active turn, while session-scoped + /// grants are stored in session state after the turn lock is released. + /// Unknown call ids are ignored because they may be late replies from a + /// client prompt that was already resolved or abandoned. pub async fn notify_request_permissions_response( &self, call_id: &str, @@ -3748,6 +4011,9 @@ impl Session { turn_context .features .enabled(Feature::RequestPermissionsTool), + turn_context + .features + .enabled(Feature::RequestPermissionPresetTool), ) .into_text(), ); @@ -4730,6 +4996,10 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv handlers::request_permissions_response(&sess, id, response).await; false } + Op::RequestPermissionPresetResponse { id, response } => { + handlers::request_permission_preset_response(&sess, id, response).await; + false + } Op::DynamicToolResponse { id, response } => { handlers::dynamic_tool_response(&sess, id, response).await; false @@ -4861,6 +5131,17 @@ fn submission_dispatch_span(sub: &Submission) -> tracing::Span { dispatch_span } +fn resolve_permission_preset( + turn_context: &TurnContext, + preset_id: PermissionPresetId, +) -> Option { + find_builtin_permission_preset( + preset_id.as_str(), + cfg!(target_os = "windows"), + turn_context.features.enabled(Feature::GuardianApproval), + ) +} + /// Operation handlers mod handlers { use crate::codex::Session; @@ -4903,6 +5184,7 @@ mod handlers { use codex_protocol::protocol::ThreadRolledBackEvent; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::WarningEvent; + use codex_protocol::request_permission_preset::RequestPermissionPresetResponse; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; @@ -5250,6 +5532,15 @@ mod handlers { .await; } + pub async fn request_permission_preset_response( + sess: &Arc, + id: String, + response: RequestPermissionPresetResponse, + ) { + sess.notify_request_permission_preset_response(&id, response) + .await; + } + pub async fn dynamic_tool_response( sess: &Arc, id: String, @@ -7259,6 +7550,7 @@ fn realtime_text_for_event(msg: &EventMsg) -> Option { | EventMsg::ImageGenerationEnd(_) | EventMsg::ExecApprovalRequest(_) | EventMsg::RequestPermissions(_) + | EventMsg::RequestPermissionPreset(_) | EventMsg::RequestUserInput(_) | EventMsg::DynamicToolCallRequest(_) | EventMsg::DynamicToolCallResponse(_) diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 55b3619e11..155c26fa32 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -771,6 +771,7 @@ async fn handle_request_permissions( let args = RequestPermissionsArgs { reason: event.reason, permissions: event.permissions, + scope: event.suggested_scope, }; let response_fut = parent_session.request_permissions(parent_ctx, call_id.clone(), args); let response = diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index 62ee884815..4e67f5e423 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -202,6 +202,7 @@ async fn handle_request_permissions_uses_tool_call_id_for_round_trip() { }), ..RequestPermissionProfile::default() }, + suggested_scope: PermissionGrantScope::Turn, }, &cancel_token, ) diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 3660f43684..d2ed2f7551 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -3022,6 +3022,7 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests() }), ..RequestPermissionProfile::default() }, + scope: PermissionGrantScope::Session, }, ) .await @@ -3036,6 +3037,7 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests() panic!("expected request_permissions event"); }; assert_eq!(request.call_id, call_id); + assert_eq!(request.suggested_scope, PermissionGrantScope::Session); session .notify_request_permissions_response(&request.call_id, expected_response.clone()) @@ -3080,6 +3082,7 @@ async fn request_permissions_is_auto_denied_when_granular_policy_blocks_tool_req }), ..RequestPermissionProfile::default() }, + scope: PermissionGrantScope::Turn, }, ) .await; diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index e92aad3d77..ed0c3e8df3 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -56,6 +56,7 @@ fn build_permissions_update_item( &next.cwd, next.features.enabled(Feature::ExecPermissionApprovals), next.features.enabled(Feature::RequestPermissionsTool), + next.features.enabled(Feature::RequestPermissionPresetTool), )) } diff --git a/codex-rs/core/src/state/mod.rs b/codex-rs/core/src/state/mod.rs index f3ebc7225d..68bd609cc4 100644 --- a/codex-rs/core/src/state/mod.rs +++ b/codex-rs/core/src/state/mod.rs @@ -8,4 +8,5 @@ pub(crate) use turn::ActiveTurn; pub(crate) use turn::MailboxDeliveryPhase; pub(crate) use turn::RunningTask; pub(crate) use turn::TaskKind; +pub(crate) use turn::TurnPermissionPresetOverride; pub(crate) use turn::TurnState; diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index 214fd8be15..00b82ad3d6 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -9,8 +9,11 @@ use tokio::sync::Notify; use tokio_util::sync::CancellationToken; use tokio_util::task::AbortOnDropHandle; +use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::dynamic_tools::DynamicToolResponse; use codex_protocol::models::ResponseInputItem; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::request_permission_preset::RequestPermissionPresetResponse; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_rmcp_client::ElicitationResponse; @@ -21,6 +24,7 @@ use crate::codex::TurnContext; use crate::tasks::AnySessionTask; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::TokenUsage; /// Metadata about the currently running turn. @@ -97,6 +101,8 @@ impl ActiveTurn { #[derive(Default)] pub(crate) struct TurnState { pending_approvals: HashMap>, + pending_request_permission_presets: + HashMap>, pending_request_permissions: HashMap>, pending_user_input: HashMap>, pending_elicitations: HashMap<(String, RequestId), oneshot::Sender>, @@ -104,10 +110,18 @@ pub(crate) struct TurnState { pending_input: Vec, mailbox_delivery_phase: MailboxDeliveryPhase, granted_permissions: Option, + permission_preset_override: Option, pub(crate) tool_calls: u64, pub(crate) token_usage_at_turn_start: TokenUsage, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct TurnPermissionPresetOverride { + pub(crate) approval_policy: AskForApproval, + pub(crate) approvals_reviewer: ApprovalsReviewer, + pub(crate) sandbox_policy: SandboxPolicy, +} + impl TurnState { pub(crate) fn insert_pending_approval( &mut self, @@ -126,6 +140,7 @@ impl TurnState { pub(crate) fn clear_pending(&mut self) { self.pending_approvals.clear(); + self.pending_request_permission_presets.clear(); self.pending_request_permissions.clear(); self.pending_user_input.clear(); self.pending_elicitations.clear(); @@ -133,6 +148,21 @@ impl TurnState { self.pending_input.clear(); } + pub(crate) fn insert_pending_request_permission_preset( + &mut self, + key: String, + tx: oneshot::Sender, + ) -> Option> { + self.pending_request_permission_presets.insert(key, tx) + } + + pub(crate) fn remove_pending_request_permission_preset( + &mut self, + key: &str, + ) -> Option> { + self.pending_request_permission_presets.remove(key) + } + pub(crate) fn insert_pending_request_permissions( &mut self, key: String, @@ -244,6 +274,17 @@ impl TurnState { pub(crate) fn granted_permissions(&self) -> Option { self.granted_permissions.clone() } + + pub(crate) fn record_permission_preset_override( + &mut self, + preset_override: TurnPermissionPresetOverride, + ) { + self.permission_preset_override = Some(preset_override); + } + + pub(crate) fn permission_preset_override(&self) -> Option { + self.permission_preset_override.clone() + } } impl ActiveTurn { diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index 281c3aa6ec..df4e6c5fc8 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -166,6 +166,12 @@ impl ToolHandler for ApplyPatchHandler { } }; + let effective_permissions = session.effective_permission_settings(turn.as_ref()).await; + let turn = Arc::new( + turn.with_effective_permission_settings(effective_permissions) + .map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?, + ); + // Re-parse and verify the patch so we can compute changes and approval. // Avoid building temporary ExecParams/command vectors; derive directly from inputs. let cwd = turn.cwd.clone(); diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 022e02c39e..ee5bdd8f5e 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -9,6 +9,7 @@ pub(crate) mod multi_agents; pub(crate) mod multi_agents_common; pub(crate) mod multi_agents_v2; mod plan; +mod request_permission_preset; mod request_permissions; mod request_user_input; mod shell; @@ -42,6 +43,7 @@ pub use list_dir::ListDirHandler; pub use mcp::McpHandler; pub use mcp_resource::McpResourceHandler; pub use plan::PlanHandler; +pub use request_permission_preset::RequestPermissionPresetHandler; pub use request_permissions::RequestPermissionsHandler; pub use request_user_input::RequestUserInputHandler; pub use shell::ShellCommandHandler; diff --git a/codex-rs/core/src/tools/handlers/request_permission_preset.rs b/codex-rs/core/src/tools/handlers/request_permission_preset.rs new file mode 100644 index 0000000000..f130e53b58 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/request_permission_preset.rs @@ -0,0 +1,51 @@ +use codex_protocol::request_permission_preset::RequestPermissionPresetArgs; + +use crate::function_tool::FunctionCallError; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; +use crate::tools::handlers::parse_arguments; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; + +/// Handles model requests to open the permission preset confirmation UI. +pub struct RequestPermissionPresetHandler; + +impl ToolHandler for RequestPermissionPresetHandler { + type Output = FunctionToolOutput; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + call_id, + payload, + .. + } = invocation; + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "request_permission_preset handler received unsupported payload".to_string(), + )); + } + }; + + let args: RequestPermissionPresetArgs = parse_arguments(&arguments)?; + let response = session + .request_permission_preset(turn.as_ref(), call_id, args) + .await; + let content = serde_json::to_string(&response).map_err(|err| { + FunctionCallError::Fatal(format!( + "failed to serialize request_permission_preset response: {err}" + )) + })?; + + Ok(FunctionToolOutput::from_text(content, Some(true))) + } +} diff --git a/codex-rs/core/src/tools/handlers/request_permissions.rs b/codex-rs/core/src/tools/handlers/request_permissions.rs index 440bb18ca1..cb045d46ae 100644 --- a/codex-rs/core/src/tools/handlers/request_permissions.rs +++ b/codex-rs/core/src/tools/handlers/request_permissions.rs @@ -1,5 +1,9 @@ +use codex_protocol::request_permissions::PermissionGrantScope; +use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::request_permissions::RequestPermissionsArgs; +use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_sandboxing::policy_transforms::normalize_additional_permissions; +use serde::Serialize; use crate::function_tool::FunctionCallError; use crate::tools::context::FunctionToolOutput; @@ -9,8 +13,43 @@ use crate::tools::handlers::parse_arguments_with_base_path; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; +/// Handles model requests to open the narrow permission confirmation UI. pub struct RequestPermissionsHandler; +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum RequestPermissionsToolStatus { + Granted, + Denied, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +struct RequestPermissionsToolOutput { + status: RequestPermissionsToolStatus, + scope: PermissionGrantScope, + permissions: RequestPermissionProfile, + message: &'static str, +} + +/// Builds the model-visible result after the user has resolved the confirmation UI. +fn output_for_response(response: RequestPermissionsResponse) -> RequestPermissionsToolOutput { + if response.permissions.is_empty() { + RequestPermissionsToolOutput { + status: RequestPermissionsToolStatus::Denied, + scope: response.scope, + permissions: response.permissions, + message: "The user has already denied or declined this permission request. Do not say that approval is still pending.", + } + } else { + RequestPermissionsToolOutput { + status: RequestPermissionsToolStatus::Granted, + scope: response.scope, + permissions: response.permissions, + message: "The user has already approved this permission request. These permissions are active now; do not ask the user to approve them again.", + } + } +} + impl ToolHandler for RequestPermissionsHandler { type Output = FunctionToolOutput; @@ -56,7 +95,8 @@ impl ToolHandler for RequestPermissionsHandler { ) })?; - let content = serde_json::to_string(&response).map_err(|err| { + let tool_output = output_for_response(response); + let content = serde_json::to_string(&tool_output).map_err(|err| { FunctionCallError::Fatal(format!( "failed to serialize request_permissions response: {err}" )) @@ -65,3 +105,66 @@ impl ToolHandler for RequestPermissionsHandler { Ok(FunctionToolOutput::from_text(content, Some(true))) } } + +#[cfg(test)] +mod tests { + use codex_protocol::models::NetworkPermissions; + use codex_protocol::request_permissions::PermissionGrantScope; + use codex_protocol::request_permissions::RequestPermissionProfile; + use codex_protocol::request_permissions::RequestPermissionsResponse; + use pretty_assertions::assert_eq; + use serde_json::json; + + use super::output_for_response; + + #[test] + fn request_permissions_tool_output_marks_granted_permissions_as_active() { + let permissions = RequestPermissionProfile { + network: Some(NetworkPermissions { + enabled: Some(true), + }), + file_system: None, + }; + + let output = output_for_response(RequestPermissionsResponse { + permissions, + scope: PermissionGrantScope::Session, + }); + + assert_eq!( + serde_json::to_value(output).expect("serialize tool output"), + json!({ + "status": "granted", + "scope": "session", + "permissions": { + "network": { + "enabled": true, + }, + "file_system": null, + }, + "message": "The user has already approved this permission request. These permissions are active now; do not ask the user to approve them again.", + }) + ); + } + + #[test] + fn request_permissions_tool_output_marks_empty_permissions_as_denied() { + let output = output_for_response(RequestPermissionsResponse { + permissions: RequestPermissionProfile::default(), + scope: PermissionGrantScope::Turn, + }); + + assert_eq!( + serde_json::to_value(output).expect("serialize tool output"), + json!({ + "status": "denied", + "scope": "turn", + "permissions": { + "network": null, + "file_system": null, + }, + "message": "The user has already denied or declined this permission request. Do not say that approval is still pending.", + }) + ); + } +} diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 3ed21bd2ed..5bd20a4227 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -395,6 +395,11 @@ impl ShellHandler { } = args; let mut exec_params = exec_params; + let effective_permissions = session.effective_permission_settings(turn.as_ref()).await; + let turn = Arc::new( + turn.with_effective_permission_settings(effective_permissions) + .map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?, + ); let Some(environment) = turn.environment.as_ref() else { return Err(FunctionCallError::RespondToModel( "shell is unavailable in this session".to_string(), diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index a604e9762c..c40f562d95 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -178,6 +178,12 @@ impl ToolHandler for UnifiedExecHandler { } }; + let effective_permissions = session.effective_permission_settings(turn.as_ref()).await; + let turn = Arc::new( + turn.with_effective_permission_settings(effective_permissions) + .map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?, + ); + let Some(environment) = turn.environment.as_ref() else { return Err(FunctionCallError::RespondToModel( "unified exec is unavailable in this session".to_string(), diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 188be42ba1..aa04212386 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -72,6 +72,7 @@ pub(crate) fn build_specs_with_discoverable_tools( use crate::tools::handlers::McpHandler; use crate::tools::handlers::McpResourceHandler; use crate::tools::handlers::PlanHandler; + use crate::tools::handlers::RequestPermissionPresetHandler; use crate::tools::handlers::RequestPermissionsHandler; use crate::tools::handlers::RequestUserInputHandler; use crate::tools::handlers::ShellCommandHandler; @@ -139,6 +140,7 @@ pub(crate) fn build_specs_with_discoverable_tools( let mcp_resource_handler = Arc::new(McpResourceHandler); let shell_command_handler = Arc::new(ShellCommandHandler::from(config.shell_command_backend)); let request_permissions_handler = Arc::new(RequestPermissionsHandler); + let request_permission_preset_handler = Arc::new(RequestPermissionPresetHandler); let request_user_input_handler = Arc::new(RequestUserInputHandler { default_mode_request_user_input: config.default_mode_request_user_input, }); @@ -209,6 +211,9 @@ pub(crate) fn build_specs_with_discoverable_tools( ToolHandlerKind::RequestPermissions => { builder.register_handler(handler.name, request_permissions_handler.clone()); } + ToolHandlerKind::RequestPermissionPreset => { + builder.register_handler(handler.name, request_permission_preset_handler.clone()); + } ToolHandlerKind::RequestUserInput => { builder.register_handler(handler.name, request_user_input_handler.clone()); } diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index a553cbced5..8ec6c627cb 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -119,6 +119,7 @@ mod realtime_conversation; mod remote_env; mod remote_models; mod request_compression; +mod request_permission_preset_tool; #[cfg(not(target_os = "windows"))] mod request_permissions; #[cfg(not(target_os = "windows"))] diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index ec6b7d67d2..81b273ae67 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -556,6 +556,7 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { test.config.cwd.as_path(), /*exec_permission_approvals_enabled*/ false, /*request_permissions_tool_enabled*/ false, + /*request_permission_preset_tool_enabled*/ false, ) .into_text(); // Normalize line endings to handle Windows vs Unix differences diff --git a/codex-rs/core/tests/suite/request_permission_preset_tool.rs b/codex-rs/core/tests/suite/request_permission_preset_tool.rs new file mode 100644 index 0000000000..724fd0667a --- /dev/null +++ b/codex-rs/core/tests/suite/request_permission_preset_tool.rs @@ -0,0 +1,438 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use anyhow::Result; +use codex_config::RequirementSource; +use codex_core::config::Constrained; +use codex_core::config::ConstraintError; +use codex_features::Feature; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::request_permission_preset::PermissionPresetId; +use codex_protocol::request_permission_preset::RequestPermissionPresetDecision; +use codex_protocol::request_permission_preset::RequestPermissionPresetEvent; +use codex_protocol::request_permission_preset::RequestPermissionPresetResponse; +use codex_protocol::user_input::UserInput; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; + +fn request_permission_preset_tool_event( + call_id: &str, + preset: PermissionPresetId, + reason: &str, +) -> Result { + let args = json!({ + "preset": preset, + "reason": reason, + }); + let args_str = serde_json::to_string(&args)?; + Ok(ev_function_call( + call_id, + "request_permission_preset", + &args_str, + )) +} + +fn workspace_write_without_network() -> SandboxPolicy { + SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: Default::default(), + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + } +} + +async fn submit_turn( + test: &core_test_support::test_codex::TestCodex, + prompt: &str, + approval_policy: AskForApproval, + sandbox_policy: SandboxPolicy, +) -> Result<()> { + test.codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: prompt.into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd.path().to_path_buf(), + approval_policy, + approvals_reviewer: None, + sandbox_policy, + model: test.session_configured.model.clone(), + effort: None, + summary: None, + service_tier: None, + collaboration_mode: None, + personality: None, + }) + .await?; + Ok(()) +} + +async fn expect_request_permission_preset_event( + test: &core_test_support::test_codex::TestCodex, + expected_call_id: &str, +) -> RequestPermissionPresetEvent { + let event = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::RequestPermissionPreset(_) | EventMsg::TurnComplete(_) + ) + }) + .await; + + match event { + EventMsg::RequestPermissionPreset(request) => { + assert_eq!(request.call_id, expected_call_id); + request + } + EventMsg::TurnComplete(_) => panic!("expected request_permission_preset before completion"), + other => panic!("unexpected event: {other:?}"), + } +} + +fn invalid_approval_policy(candidate: AskForApproval) -> ConstraintError { + ConstraintError::InvalidValue { + field_name: "approval_policy", + candidate: format!("{candidate:?}"), + allowed: "[OnRequest]".to_string(), + requirement_source: RequirementSource::Unknown, + } +} + +#[tokio::test(flavor = "current_thread")] +async fn accepted_permission_preset_request_returns_model_output() -> Result<()> { + let server = start_mock_server().await; + let approval_policy = AskForApproval::OnRequest; + let sandbox_policy = workspace_write_without_network(); + let sandbox_policy_for_config = sandbox_policy.clone(); + let mut builder = test_codex().with_config(move |config| { + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); + config + .features + .enable(Feature::RequestPermissionPresetTool) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-request-preset-1"), + request_permission_preset_tool_event( + "preset-call", + PermissionPresetId::FullAccess, + "User asked for full access", + )?, + ev_completed("resp-request-preset-1"), + ]), + sse(vec![ + ev_response_created("resp-request-preset-2"), + ev_assistant_message("msg-request-preset-1", "done"), + ev_completed("resp-request-preset-2"), + ]), + ], + ) + .await; + + submit_turn( + &test, + "switch to full access", + approval_policy, + sandbox_policy, + ) + .await?; + + let request = expect_request_permission_preset_event(&test, "preset-call").await; + assert_eq!(request.call_id, "preset-call"); + assert_eq!(request.preset, PermissionPresetId::FullAccess); + + test.codex + .submit(Op::RequestPermissionPresetResponse { + id: "preset-call".to_string(), + response: RequestPermissionPresetResponse { + decision: RequestPermissionPresetDecision::Accepted, + preset: PermissionPresetId::FullAccess, + message: "Permissions updated to Full access.".to_string(), + }, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let output_text = responses + .function_call_output_text("preset-call") + .expect("expected request_permission_preset output"); + let output: RequestPermissionPresetResponse = serde_json::from_str(&output_text)?; + assert_eq!( + output, + RequestPermissionPresetResponse { + decision: RequestPermissionPresetDecision::Accepted, + preset: PermissionPresetId::FullAccess, + message: "Permissions updated to Full access.".to_string(), + } + ); + + let snapshot = test.codex.config_snapshot().await; + assert_eq!( + ( + snapshot.approval_policy, + snapshot.approvals_reviewer, + snapshot.sandbox_policy, + ), + ( + AskForApproval::Never, + ApprovalsReviewer::User, + SandboxPolicy::DangerFullAccess, + ) + ); + + Ok(()) +} + +#[tokio::test(flavor = "current_thread")] +async fn unknown_permission_preset_response_does_not_mutate_settings() -> Result<()> { + let server = start_mock_server().await; + let approval_policy = AskForApproval::OnRequest; + let sandbox_policy = workspace_write_without_network(); + let sandbox_policy_for_config = sandbox_policy.clone(); + let mut builder = test_codex().with_config(move |config| { + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); + config + .features + .enable(Feature::RequestPermissionPresetTool) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-request-preset-1"), + request_permission_preset_tool_event( + "preset-call", + PermissionPresetId::FullAccess, + "User asked for full access", + )?, + ev_completed("resp-request-preset-1"), + ]), + sse(vec![ + ev_response_created("resp-request-preset-2"), + ev_assistant_message("msg-request-preset-1", "done"), + ev_completed("resp-request-preset-2"), + ]), + ], + ) + .await; + + submit_turn( + &test, + "switch to full access", + approval_policy, + sandbox_policy.clone(), + ) + .await?; + + let request = expect_request_permission_preset_event(&test, "preset-call").await; + assert_eq!(request.preset, PermissionPresetId::FullAccess); + + test.codex + .submit(Op::RequestPermissionPresetResponse { + id: "stale-call".to_string(), + response: RequestPermissionPresetResponse { + decision: RequestPermissionPresetDecision::Accepted, + preset: PermissionPresetId::FullAccess, + message: "stale response".to_string(), + }, + }) + .await?; + + let snapshot = test.codex.config_snapshot().await; + assert_eq!( + ( + snapshot.approval_policy, + snapshot.approvals_reviewer, + snapshot.sandbox_policy, + ), + ( + AskForApproval::OnRequest, + ApprovalsReviewer::User, + sandbox_policy.clone(), + ) + ); + + test.codex + .submit(Op::RequestPermissionPresetResponse { + id: "preset-call".to_string(), + response: RequestPermissionPresetResponse { + decision: RequestPermissionPresetDecision::Accepted, + preset: PermissionPresetId::FullAccess, + message: "Permissions updated to Full access.".to_string(), + }, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let output_text = responses + .function_call_output_text("preset-call") + .expect("expected request_permission_preset output"); + let output: RequestPermissionPresetResponse = serde_json::from_str(&output_text)?; + assert_eq!( + output, + RequestPermissionPresetResponse { + decision: RequestPermissionPresetDecision::Accepted, + preset: PermissionPresetId::FullAccess, + message: "Permissions updated to Full access.".to_string(), + } + ); + + let snapshot = test.codex.config_snapshot().await; + assert_eq!( + ( + snapshot.approval_policy, + snapshot.approvals_reviewer, + snapshot.sandbox_policy, + ), + ( + AskForApproval::Never, + ApprovalsReviewer::User, + SandboxPolicy::DangerFullAccess, + ) + ); + + Ok(()) +} + +#[tokio::test(flavor = "current_thread")] +async fn failed_permission_preset_apply_returns_declined_and_keeps_settings() -> Result<()> { + let server = start_mock_server().await; + let approval_policy = AskForApproval::OnRequest; + let sandbox_policy = workspace_write_without_network(); + let sandbox_policy_for_config = sandbox_policy.clone(); + let allow_full_access = Arc::new(AtomicBool::new(true)); + let allow_full_access_for_config = allow_full_access.clone(); + let mut builder = test_codex().with_config(move |config| { + config.permissions.approval_policy = Constrained::new(approval_policy, move |candidate| { + if *candidate == AskForApproval::OnRequest + || (*candidate == AskForApproval::Never + && allow_full_access_for_config.load(Ordering::SeqCst)) + { + Ok(()) + } else { + Err(invalid_approval_policy(*candidate)) + } + }) + .expect("initial approval policy should satisfy the validator"); + config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); + config + .features + .enable(Feature::RequestPermissionPresetTool) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-request-preset-1"), + request_permission_preset_tool_event( + "preset-call", + PermissionPresetId::FullAccess, + "User asked for full access", + )?, + ev_completed("resp-request-preset-1"), + ]), + sse(vec![ + ev_response_created("resp-request-preset-2"), + ev_assistant_message("msg-request-preset-1", "done"), + ev_completed("resp-request-preset-2"), + ]), + ], + ) + .await; + + submit_turn( + &test, + "switch to full access", + approval_policy, + sandbox_policy.clone(), + ) + .await?; + + let request = expect_request_permission_preset_event(&test, "preset-call").await; + assert_eq!(request.preset, PermissionPresetId::FullAccess); + + allow_full_access.store(false, Ordering::SeqCst); + + test.codex + .submit(Op::RequestPermissionPresetResponse { + id: "preset-call".to_string(), + response: RequestPermissionPresetResponse { + decision: RequestPermissionPresetDecision::Accepted, + preset: PermissionPresetId::FullAccess, + message: "Permissions updated to Full access.".to_string(), + }, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let output_text = responses + .function_call_output_text("preset-call") + .expect("expected request_permission_preset output"); + let output: RequestPermissionPresetResponse = serde_json::from_str(&output_text)?; + assert_eq!(output.decision, RequestPermissionPresetDecision::Declined); + assert_eq!(output.preset, PermissionPresetId::FullAccess); + assert!( + output + .message + .contains("requested permission preset could not be applied"), + "unexpected output message: {}", + output.message + ); + + let snapshot = test.codex.config_snapshot().await; + assert_eq!( + ( + snapshot.approval_policy, + snapshot.approvals_reviewer, + snapshot.sandbox_policy, + ), + ( + AskForApproval::OnRequest, + ApprovalsReviewer::User, + sandbox_policy, + ) + ); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 2cfd1cf6f7..32de8e56ed 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -115,6 +115,21 @@ fn request_permissions_tool_event( Ok(ev_function_call(call_id, "request_permissions", &args_str)) } +fn request_permissions_tool_event_with_scope( + call_id: &str, + reason: &str, + permissions: &RequestPermissionProfile, + scope: PermissionGrantScope, +) -> Result { + let args = json!({ + "reason": reason, + "permissions": permissions, + "scope": scope, + }); + let args_str = serde_json::to_string(&args)?; + Ok(ev_function_call(call_id, "request_permissions", &args_str)) +} + fn shell_command_event(call_id: &str, command: &str) -> Result { let args = json!({ "command": command, @@ -311,6 +326,90 @@ fn normalized_directory_write_permissions(path: &Path) -> Result Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let approval_policy = AskForApproval::OnRequest; + let sandbox_policy = SandboxPolicy::new_read_only_policy(); + let sandbox_policy_for_config = sandbox_policy.clone(); + + let mut builder = test_codex().with_config(move |config| { + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config); + config + .features + .enable(Feature::RequestPermissionsTool) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + + let requested_dir = test.workspace_path("resolved-permissions-output"); + fs::create_dir_all(&requested_dir)?; + let requested_permissions = requested_directory_write_permissions(&requested_dir); + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-resolved-permissions-1"), + request_permissions_tool_event_with_scope( + "permissions-call", + "Allow writing outside the workspace", + &requested_permissions, + PermissionGrantScope::Session, + )?, + ev_completed("resp-resolved-permissions-1"), + ]), + sse(vec![ + ev_response_created("resp-resolved-permissions-2"), + ev_assistant_message("msg-resolved-permissions-1", "done"), + ev_completed("resp-resolved-permissions-2"), + ]), + ], + ) + .await; + + submit_turn( + &test, + "allow writing outside the workspace in this session", + approval_policy, + sandbox_policy, + ) + .await?; + + let granted_permissions = expect_request_permissions_event(&test, "permissions-call").await; + test.codex + .submit(Op::RequestPermissionsResponse { + id: "permissions-call".to_string(), + response: RequestPermissionsResponse { + permissions: granted_permissions.clone(), + scope: PermissionGrantScope::Session, + }, + }) + .await?; + wait_for_completion(&test).await; + + let output = responses + .function_call_output_text("permissions-call") + .expect("request_permissions tool output"); + let output_json: Value = serde_json::from_str(&output)?; + let message = output_json["message"] + .as_str() + .expect("model-facing resolution message"); + assert_eq!(output_json["status"], json!("granted")); + assert_eq!(output_json["scope"], json!("session")); + assert_eq!( + output_json["permissions"], + serde_json::to_value(&granted_permissions)? + ); + assert!(message.contains("already approved")); + assert!(message.contains("active now")); + assert!(message.contains("do not ask")); + + Ok(()) +} + #[tokio::test(flavor = "current_thread")] async fn with_additional_permissions_requires_approval_under_on_request() -> Result<()> { skip_if_no_network!(Ok(())); @@ -475,6 +574,8 @@ async fn request_permissions_tool_is_auto_denied_when_granular_request_permissio ); let call_output = results.single_request().function_call_output(call_id); + let output_json: Value = + serde_json::from_str(call_output["output"].as_str().unwrap_or_default())?; let result: RequestPermissionsResponse = serde_json::from_str(call_output["output"].as_str().unwrap_or_default())?; assert_eq!( @@ -484,6 +585,13 @@ async fn request_permissions_tool_is_auto_denied_when_granular_request_permissio scope: PermissionGrantScope::Turn, } ); + assert_eq!(output_json["status"], json!("denied")); + assert!( + output_json["message"] + .as_str() + .unwrap_or_default() + .contains("already denied") + ); Ok(()) } diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 31099e8d65..5c0797b14d 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -483,6 +483,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result client_name: "codex_exec".to_string(), client_version: env!("CARGO_PKG_VERSION").to_string(), experimental_api: true, + supported_server_requests: Vec::new(), opt_out_notification_methods: Vec::new(), channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }; @@ -1509,6 +1510,18 @@ async fn handle_server_request( ) .await } + ServerRequest::PermissionPresetRequestApproval { request_id, params } => { + reject_server_request( + client, + request_id, + &method, + format!( + "permission preset approval is not supported in exec mode for thread `{}`", + params.thread_id + ), + ) + .await + } }; if let Err(err) = handle_result { diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 10d5fdf884..b28b21103b 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -97,6 +97,8 @@ pub enum Feature { CodexHooks, /// Expose the built-in request_permissions tool. RequestPermissionsTool, + /// Expose the built-in request_permission_preset tool. + RequestPermissionPresetTool, /// Allow the model to request web searches that fetch live content. WebSearchRequest, /// Allow the model to request web searches that fetch cached content. @@ -706,8 +708,14 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::RequestPermissionsTool, key: "request_permissions_tool", - stage: Stage::UnderDevelopment, - default_enabled: false, + stage: Stage::Stable, + default_enabled: true, + }, + FeatureSpec { + id: Feature::RequestPermissionPresetTool, + key: "request_permission_preset_tool", + stage: Stage::Stable, + default_enabled: true, }, FeatureSpec { id: Feature::UseLinuxSandboxBwrap, diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index bc357a7fb4..ac167cfc41 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -107,12 +107,15 @@ fn request_permissions_is_under_development() { } #[test] -fn request_permissions_tool_is_under_development() { - assert_eq!( - Feature::RequestPermissionsTool.stage(), - Stage::UnderDevelopment - ); - assert_eq!(Feature::RequestPermissionsTool.default_enabled(), false); +fn request_permissions_tool_is_stable_and_enabled_by_default() { + assert_eq!(Feature::RequestPermissionsTool.stage(), Stage::Stable); + assert_eq!(Feature::RequestPermissionsTool.default_enabled(), true); +} + +#[test] +fn request_permission_preset_tool_is_stable_and_enabled_by_default() { + assert_eq!(Feature::RequestPermissionPresetTool.stage(), Stage::Stable); + assert_eq!(Feature::RequestPermissionPresetTool.default_enabled(), true); } #[test] diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 91fcc8a278..a34ab033ff 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -374,6 +374,7 @@ async fn run_codex_tool_session_inner( | EventMsg::ExitedReviewMode(_) | EventMsg::RequestUserInput(_) | EventMsg::RequestPermissions(_) + | EventMsg::RequestPermissionPreset(_) | EventMsg::DynamicToolCallRequest(_) | EventMsg::DynamicToolCallResponse(_) | EventMsg::ContextCompacted(_) diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index de580de6f3..4b41bd6db7 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -21,6 +21,7 @@ pub mod parse_command; pub mod permissions; pub mod plan_tool; pub mod protocol; +pub mod request_permission_preset; pub mod request_permissions; pub mod request_user_input; pub mod user_input; diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 0f18b68393..1b8edc7cc7 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -391,6 +391,7 @@ struct PermissionsPromptConfig<'a> { exec_policy: &'a Policy, exec_permission_approvals_enabled: bool, request_permissions_tool_enabled: bool, + request_permission_preset_tool_enabled: bool, } impl DeveloperInstructions { @@ -404,13 +405,17 @@ impl DeveloperInstructions { exec_policy: &Policy, exec_permission_approvals_enabled: bool, request_permissions_tool_enabled: bool, + request_permission_preset_tool_enabled: bool, ) -> DeveloperInstructions { - let with_request_permissions_tool = |text: &str| { + let append_permission_tool_sections = |text: &str| { + let mut sections = vec![text.to_string()]; if request_permissions_tool_enabled { - format!("{text}\n\n{}", request_permissions_tool_prompt_section()) - } else { - text.to_string() + sections.push(request_permissions_tool_prompt_section().to_string()); } + if request_permission_preset_tool_enabled { + sections.push(request_permission_preset_tool_prompt_section().to_string()); + } + sections.join("\n\n") }; let on_request_instructions = || { let on_request_rule = if exec_permission_approvals_enabled { @@ -422,6 +427,9 @@ impl DeveloperInstructions { if request_permissions_tool_enabled { sections.push(request_permissions_tool_prompt_section().to_string()); } + if request_permission_preset_tool_enabled { + sections.push(request_permission_preset_tool_prompt_section().to_string()); + } if let Some(prefixes) = approved_command_prefixes_text(exec_policy) { sections.push(format!( "## Approved command prefixes\nThe following prefix rules have already been approved: {prefixes}" @@ -432,15 +440,18 @@ impl DeveloperInstructions { let text = match approval_policy { AskForApproval::Never => APPROVAL_POLICY_NEVER.to_string(), AskForApproval::UnlessTrusted => { - with_request_permissions_tool(APPROVAL_POLICY_UNLESS_TRUSTED) + append_permission_tool_sections(APPROVAL_POLICY_UNLESS_TRUSTED) + } + AskForApproval::OnFailure => { + append_permission_tool_sections(APPROVAL_POLICY_ON_FAILURE) } - AskForApproval::OnFailure => with_request_permissions_tool(APPROVAL_POLICY_ON_FAILURE), AskForApproval::OnRequest => on_request_instructions(), AskForApproval::Granular(granular_config) => granular_instructions( granular_config, exec_policy, exec_permission_approvals_enabled, request_permissions_tool_enabled, + request_permission_preset_tool_enabled, ), }; @@ -498,6 +509,7 @@ impl DeveloperInstructions { DeveloperInstructions::new(message) } + #[allow(clippy::too_many_arguments)] pub fn from_policy( sandbox_policy: &SandboxPolicy, approval_policy: AskForApproval, @@ -506,6 +518,7 @@ impl DeveloperInstructions { cwd: &Path, exec_permission_approvals_enabled: bool, request_permissions_tool_enabled: bool, + request_permission_preset_tool_enabled: bool, ) -> Self { let network_access = if sandbox_policy.has_full_network_access() { NetworkAccess::Enabled @@ -532,6 +545,7 @@ impl DeveloperInstructions { exec_policy, exec_permission_approvals_enabled, request_permissions_tool_enabled, + request_permission_preset_tool_enabled, }, writable_roots, ) @@ -570,6 +584,7 @@ impl DeveloperInstructions { config.exec_policy, config.exec_permission_approvals_enabled, config.request_permissions_tool_enabled, + config.request_permission_preset_tool_enabled, )) .concat(DeveloperInstructions::from_writable_roots(writable_roots)) .concat(end_tag) @@ -621,7 +636,11 @@ fn granular_prompt_intro_text() -> &'static str { } fn request_permissions_tool_prompt_section() -> &'static str { - "# request_permissions Tool\n\nThe built-in `request_permissions` tool is available in this session. Invoke it when you need to request additional `network` or `file_system` permissions before later shell-like commands need them. Request only the specific permissions required for the task." + "# request_permissions Tool\n\nThe built-in `request_permissions` tool is available in this session. Invoke it immediately when the user asks conversationally to allow specific filesystem or network access, for example \"allow writing to ~/Downloads in this session\" or \"grant access to /tmp/output\". Request only the specific permissions required. Set `scope` to `session` when the user explicitly asks for session-long access; otherwise use `turn`. After this tool returns, the user has already approved or denied the request in the UI. If permissions were granted, treat them as active now; do not say \"once you approve\" or ask the user to approve the same request again. If no permissions were granted, say the request was not approved." +} + +fn request_permission_preset_tool_prompt_section() -> &'static str { + "# request_permission_preset Tool\n\nThe built-in `request_permission_preset` tool is available in this session. Invoke it immediately when the user asks conversationally to change the broad sandboxing, approval, or permission mode, for example \"make this session full access\", \"switch to full access\", \"make the session read-only\", or \"use guardian approvals\". The tool opens the permission-mode picker with the requested preset preselected; the session only changes if the user selects a mode there. Do not claim the mode changed until the tool returns an accepted decision. Use `request_permissions` instead for named paths or narrow filesystem/network grants; never use the preset picker for requests like \"allow writing to ~/Downloads\"." } fn granular_instructions( @@ -629,12 +648,15 @@ fn granular_instructions( exec_policy: &Policy, exec_permission_approvals_enabled: bool, request_permissions_tool_enabled: bool, + request_permission_preset_tool_enabled: bool, ) -> String { let sandbox_approval_prompts_allowed = granular_config.allows_sandbox_approval(); let shell_permission_requests_available = exec_permission_approvals_enabled && sandbox_approval_prompts_allowed; let request_permissions_tool_prompts_allowed = request_permissions_tool_enabled && granular_config.allows_request_permissions(); + let request_permission_preset_tool_prompts_allowed = + request_permission_preset_tool_enabled && granular_config.allows_request_permissions(); let categories = [ Some(( granular_config.allows_sandbox_approval(), @@ -646,6 +668,10 @@ fn granular_instructions( granular_config.allows_request_permissions(), "`request_permissions`", )), + request_permission_preset_tool_enabled.then_some(( + granular_config.allows_request_permissions(), + "`request_permission_preset`", + )), Some(( granular_config.allows_mcp_elicitations(), "`mcp_elicitations`", @@ -686,6 +712,9 @@ fn granular_instructions( if request_permissions_tool_prompts_allowed { sections.push(request_permissions_tool_prompt_section().to_string()); } + if request_permission_preset_tool_prompts_allowed { + sections.push(request_permission_preset_tool_prompt_section().to_string()); + } if let Some(prefixes) = approved_command_prefixes_text(exec_policy) { sections.push(format!( @@ -1693,6 +1722,7 @@ mod tests { exec_policy: &Policy::empty(), exec_permission_approvals_enabled: false, request_permissions_tool_enabled: false, + request_permission_preset_tool_enabled: false, }, /*writable_roots*/ None, ); @@ -1726,6 +1756,7 @@ mod tests { &PathBuf::from("/tmp"), /*exec_permission_approvals_enabled*/ false, /*request_permissions_tool_enabled*/ false, + /*request_permission_preset_tool_enabled*/ false, ); let text = instructions.into_text(); assert!(text.contains("Network access is enabled.")); @@ -1750,6 +1781,7 @@ mod tests { exec_policy: &exec_policy, exec_permission_approvals_enabled: false, request_permissions_tool_enabled: false, + request_permission_preset_tool_enabled: false, }, /*writable_roots*/ None, ); @@ -1771,6 +1803,7 @@ mod tests { exec_policy: &Policy::empty(), exec_permission_approvals_enabled: false, request_permissions_tool_enabled: true, + request_permission_preset_tool_enabled: false, }, /*writable_roots*/ None, ); @@ -1778,6 +1811,8 @@ mod tests { let text = instructions.into_text(); assert!(text.contains("`approval_policy` is `unless-trusted`")); assert!(text.contains("# request_permissions Tool")); + assert!(text.contains("After this tool returns, the user has already approved or denied")); + assert!(text.contains("do not say \"once you approve\"")); } #[test] @@ -1791,6 +1826,7 @@ mod tests { exec_policy: &Policy::empty(), exec_permission_approvals_enabled: false, request_permissions_tool_enabled: true, + request_permission_preset_tool_enabled: false, }, /*writable_roots*/ None, ); @@ -1811,6 +1847,7 @@ mod tests { exec_policy: &Policy::empty(), exec_permission_approvals_enabled: true, request_permissions_tool_enabled: false, + request_permission_preset_tool_enabled: false, }, /*writable_roots*/ None, ); @@ -1831,6 +1868,7 @@ mod tests { exec_policy: &Policy::empty(), exec_permission_approvals_enabled: false, request_permissions_tool_enabled: true, + request_permission_preset_tool_enabled: false, }, /*writable_roots*/ None, ); @@ -1840,6 +1878,33 @@ mod tests { assert!( text.contains("The built-in `request_permissions` tool is available in this session.") ); + assert!(text.contains("allow writing to ~/Downloads in this session")); + assert!(text.contains("Set `scope` to `session`")); + } + + #[test] + fn includes_request_permission_preset_tool_instructions_when_enabled() { + let instructions = DeveloperInstructions::from_permissions_with_network( + SandboxMode::WorkspaceWrite, + NetworkAccess::Enabled, + PermissionsPromptConfig { + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::User, + exec_policy: &Policy::empty(), + exec_permission_approvals_enabled: false, + request_permissions_tool_enabled: false, + request_permission_preset_tool_enabled: true, + }, + /*writable_roots*/ None, + ); + + let text = instructions.into_text(); + assert!(text.contains("# request_permission_preset Tool")); + assert!(text.contains( + "asks conversationally to change the broad sandboxing, approval, or permission mode" + )); + assert!(text.contains("Use `request_permissions` instead for named paths")); + assert!(text.contains("never use the preset picker")); } #[test] @@ -1853,6 +1918,7 @@ mod tests { exec_policy: &Policy::empty(), exec_permission_approvals_enabled: true, request_permissions_tool_enabled: true, + request_permission_preset_tool_enabled: false, }, /*writable_roots*/ None, ); @@ -1870,6 +1936,7 @@ mod tests { &Policy::empty(), /*exec_permission_approvals_enabled*/ false, /*request_permissions_tool_enabled*/ false, + /*request_permission_preset_tool_enabled*/ false, ) .into_text(); @@ -1885,6 +1952,7 @@ mod tests { &Policy::empty(), /*exec_permission_approvals_enabled*/ false, /*request_permissions_tool_enabled*/ false, + /*request_permission_preset_tool_enabled*/ false, ) .into_text(); @@ -1900,6 +1968,7 @@ mod tests { rejected_categories: &[&str], include_shell_permission_request_instructions: bool, include_request_permissions_tool_section: bool, + include_request_permission_preset_tool_section: bool, ) -> String { let mut sections = vec![granular_prompt_intro_text().to_string()]; if !prompted_categories.is_empty() { @@ -1920,6 +1989,9 @@ mod tests { if include_request_permissions_tool_section { sections.push(request_permissions_tool_prompt_section().to_string()); } + if include_request_permission_preset_tool_section { + sections.push(request_permission_preset_tool_prompt_section().to_string()); + } sections.join("\n\n") } @@ -1937,6 +2009,7 @@ mod tests { &Policy::empty(), /*exec_permission_approvals_enabled*/ true, /*request_permissions_tool_enabled*/ false, + /*request_permission_preset_tool_enabled*/ false, ) .into_text(); @@ -1971,6 +2044,7 @@ mod tests { &Policy::empty(), /*exec_permission_approvals_enabled*/ true, /*request_permissions_tool_enabled*/ false, + /*request_permission_preset_tool_enabled*/ false, ) .into_text(); @@ -1986,6 +2060,7 @@ mod tests { &[], /*include_shell_permission_request_instructions*/ true, /*include_request_permissions_tool_section*/ false, + /*include_request_permission_preset_tool_section*/ false, ) ); } @@ -2004,6 +2079,7 @@ mod tests { &Policy::empty(), /*exec_permission_approvals_enabled*/ false, /*request_permissions_tool_enabled*/ false, + /*request_permission_preset_tool_enabled*/ false, ) .into_text(); @@ -2019,6 +2095,7 @@ mod tests { &[], /*include_shell_permission_request_instructions*/ false, /*include_request_permissions_tool_section*/ false, + /*include_request_permission_preset_tool_section*/ false, ) ); } @@ -2037,6 +2114,7 @@ mod tests { &Policy::empty(), /*exec_permission_approvals_enabled*/ true, /*request_permissions_tool_enabled*/ true, + /*request_permission_preset_tool_enabled*/ false, ) .into_text(); assert!(allowed.contains("# request_permissions Tool")); @@ -2053,11 +2131,51 @@ mod tests { &Policy::empty(), /*exec_permission_approvals_enabled*/ true, /*request_permissions_tool_enabled*/ true, + /*request_permission_preset_tool_enabled*/ false, ) .into_text(); assert!(!rejected.contains("# request_permissions Tool")); } + #[test] + fn granular_policy_includes_request_permission_preset_tool_only_when_that_prompt_can_still_fire() + { + let allowed = DeveloperInstructions::from( + AskForApproval::Granular(GranularApprovalConfig { + sandbox_approval: true, + rules: true, + skill_approval: true, + request_permissions: true, + mcp_elicitations: true, + }), + ApprovalsReviewer::User, + &Policy::empty(), + /*exec_permission_approvals_enabled*/ true, + /*request_permissions_tool_enabled*/ false, + /*request_permission_preset_tool_enabled*/ true, + ) + .into_text(); + assert!(allowed.contains("# request_permission_preset Tool")); + + let rejected = DeveloperInstructions::from( + AskForApproval::Granular(GranularApprovalConfig { + sandbox_approval: true, + rules: true, + skill_approval: true, + request_permissions: false, + mcp_elicitations: true, + }), + ApprovalsReviewer::User, + &Policy::empty(), + /*exec_permission_approvals_enabled*/ true, + /*request_permissions_tool_enabled*/ false, + /*request_permission_preset_tool_enabled*/ true, + ) + .into_text(); + assert!(!rejected.contains("# request_permission_preset Tool")); + assert!(rejected.contains("- `request_permission_preset`")); + } + #[test] fn granular_policy_lists_request_permissions_category_without_tool_section_when_tool_is_unavailable() { @@ -2073,6 +2191,7 @@ mod tests { &Policy::empty(), /*exec_permission_approvals_enabled*/ true, /*request_permissions_tool_enabled*/ false, + /*request_permission_preset_tool_enabled*/ false, ) .into_text(); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 65d6dab04c..d662d63078 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -45,6 +45,8 @@ use crate::num_format::format_with_separators; use crate::openai_models::ReasoningEffort as ReasoningEffortConfig; use crate::parse_command::ParsedCommand; use crate::plan_tool::UpdatePlanArgs; +use crate::request_permission_preset::RequestPermissionPresetEvent; +use crate::request_permission_preset::RequestPermissionPresetResponse; use crate::request_permissions::RequestPermissionsEvent; use crate::request_permissions::RequestPermissionsResponse; use crate::request_user_input::RequestUserInputResponse; @@ -574,6 +576,14 @@ pub enum Op { response: RequestPermissionsResponse, }, + /// Resolve a request_permission_preset tool call. + RequestPermissionPresetResponse { + /// Call id for the in-flight request. + id: String, + /// User decision for the requested permission preset. + response: RequestPermissionPresetResponse, + }, + /// Resolve a dynamic tool call request. DynamicToolResponse { /// Call id for the in-flight request. @@ -744,6 +754,7 @@ impl Op { Self::ResolveElicitation { .. } => "resolve_elicitation", Self::UserInputAnswer { .. } => "user_input_answer", Self::RequestPermissionsResponse { .. } => "request_permissions_response", + Self::RequestPermissionPresetResponse { .. } => "request_permission_preset_response", Self::DynamicToolResponse { .. } => "dynamic_tool_response", Self::AddToHistory { .. } => "add_to_history", Self::GetHistoryEntryRequest { .. } => "get_history_entry_request", @@ -1471,6 +1482,8 @@ pub enum EventMsg { RequestPermissions(RequestPermissionsEvent), + RequestPermissionPreset(RequestPermissionPresetEvent), + RequestUserInput(RequestUserInputEvent), DynamicToolCallRequest(DynamicToolCallRequest), diff --git a/codex-rs/protocol/src/request_permission_preset.rs b/codex-rs/protocol/src/request_permission_preset.rs new file mode 100644 index 0000000000..dd2f24a1e2 --- /dev/null +++ b/codex-rs/protocol/src/request_permission_preset.rs @@ -0,0 +1,91 @@ +//! Protocol types for conversational permission-mode preset requests. +//! +//! These messages represent the narrow bridge between a model tool call and a +//! client-owned confirmation UI. Core resolves a requested preset into concrete +//! sandbox, approval, and reviewer settings before emitting the event, and the +//! settings only become active after the client returns an accepted response. + +use crate::config_types::ApprovalsReviewer; +use crate::protocol::AskForApproval; +use crate::protocol::SandboxPolicy; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +/// A built-in permission-mode preset that can be requested conversationally. +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(rename_all = "kebab-case")] +pub enum PermissionPresetId { + Auto, + FullAccess, + ReadOnly, + GuardianApprovals, +} + +impl PermissionPresetId { + /// Returns the stable kebab-case identifier used in tool arguments and UI payloads. + pub fn as_str(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::FullAccess => "full-access", + Self::ReadOnly => "read-only", + Self::GuardianApprovals => "guardian-approvals", + } + } + + /// Parses a stable preset identifier into a permission preset enum value. + pub fn from_id(id: &str) -> Option { + match id { + "auto" => Some(Self::Auto), + "full-access" => Some(Self::FullAccess), + "read-only" => Some(Self::ReadOnly), + "guardian-approvals" => Some(Self::GuardianApprovals), + _ => None, + } + } +} + +/// Arguments passed by the model when it asks the client to open the preset picker. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct RequestPermissionPresetArgs { + pub preset: PermissionPresetId, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +/// The user's completed decision from the permission preset confirmation UI. +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum RequestPermissionPresetDecision { + Accepted, + Declined, +} + +/// The result returned to the model after the permission preset picker is resolved. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct RequestPermissionPresetResponse { + pub decision: RequestPermissionPresetDecision, + pub preset: PermissionPresetId, + pub message: String, +} + +/// Event sent from core to a client so the client can confirm a preset change. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct RequestPermissionPresetEvent { + /// Responses API call id for the associated tool call, if available. + pub call_id: String, + /// Turn ID that this request belongs to. + /// Uses `#[serde(default)]` for backwards compatibility. + #[serde(default)] + pub turn_id: String, + pub preset: PermissionPresetId, + pub label: String, + pub description: String, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_policy: SandboxPolicy, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, +} diff --git a/codex-rs/protocol/src/request_permissions.rs b/codex-rs/protocol/src/request_permissions.rs index 400fde4e20..6894601687 100644 --- a/codex-rs/protocol/src/request_permissions.rs +++ b/codex-rs/protocol/src/request_permissions.rs @@ -1,3 +1,10 @@ +//! Protocol types for conversational requests to grant narrow permissions. +//! +//! These messages are scoped to concrete filesystem and network grants, not +//! broad sandbox-mode changes. The model asks for a specific permission profile, +//! core normalizes it, and the client returns the permissions the user actually +//! granted so partial approvals and denials stay explicit. + use crate::models::FileSystemPermissions; use crate::models::NetworkPermissions; use crate::models::PermissionProfile; @@ -6,6 +13,7 @@ use serde::Deserialize; use serde::Serialize; use ts_rs::TS; +/// How long an approved narrow permission grant should remain active. #[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum PermissionGrantScope { @@ -14,6 +22,7 @@ pub enum PermissionGrantScope { Session, } +/// A narrow permission profile that can be requested through the confirmation UI. #[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(deny_unknown_fields)] pub struct RequestPermissionProfile { @@ -22,6 +31,7 @@ pub struct RequestPermissionProfile { } impl RequestPermissionProfile { + /// Returns true when no filesystem or network permission was requested or granted. pub fn is_empty(&self) -> bool { self.network.is_none() && self.file_system.is_none() } @@ -45,13 +55,17 @@ impl From for RequestPermissionProfile { } } +/// Arguments passed by the model when it asks the client to grant specific permissions. #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] pub struct RequestPermissionsArgs { #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, pub permissions: RequestPermissionProfile, + #[serde(default)] + pub scope: PermissionGrantScope, } +/// The permissions the user granted after resolving the confirmation UI. #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] pub struct RequestPermissionsResponse { pub permissions: RequestPermissionProfile, @@ -59,6 +73,7 @@ pub struct RequestPermissionsResponse { pub scope: PermissionGrantScope, } +/// Event sent from core to a client so the client can confirm a narrow permission grant. #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] pub struct RequestPermissionsEvent { /// Responses API call id for the associated tool call, if available. @@ -70,4 +85,6 @@ pub struct RequestPermissionsEvent { #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, pub permissions: RequestPermissionProfile, + #[serde(default)] + pub suggested_scope: PermissionGrantScope, } diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index 4b50781e76..4ce2770749 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -150,6 +150,7 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option { | EventMsg::ExecCommandOutputDelta(_) | EventMsg::ExecApprovalRequest(_) | EventMsg::RequestPermissions(_) + | EventMsg::RequestPermissionPreset(_) | EventMsg::RequestUserInput(_) | EventMsg::ElicitationRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) diff --git a/codex-rs/tools/Cargo.toml b/codex-rs/tools/Cargo.toml index 179681003c..de821d4908 100644 --- a/codex-rs/tools/Cargo.toml +++ b/codex-rs/tools/Cargo.toml @@ -13,6 +13,7 @@ codex-code-mode = { workspace = true } codex-features = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } +codex-utils-approval-presets = { workspace = true } codex-utils-pty = { workspace = true } rmcp = { workspace = true, default-features = false, features = [ "base64", diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index c310222a0f..4bbcc243d1 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -63,10 +63,12 @@ pub use json_schema::parse_tool_input_schema; pub use local_tool::CommandToolOptions; pub use local_tool::ShellToolOptions; pub use local_tool::create_exec_command_tool; +pub use local_tool::create_request_permission_preset_tool; pub use local_tool::create_request_permissions_tool; pub use local_tool::create_shell_command_tool; pub use local_tool::create_shell_tool; pub use local_tool::create_write_stdin_tool; +pub use local_tool::request_permission_preset_tool_description; pub use local_tool::request_permissions_tool_description; pub use mcp_resource_tool::create_list_mcp_resource_templates_tool; pub use mcp_resource_tool::create_list_mcp_resources_tool; diff --git a/codex-rs/tools/src/local_tool.rs b/codex-rs/tools/src/local_tool.rs index 3e369ab1e3..d0e03e0543 100644 --- a/codex-rs/tools/src/local_tool.rs +++ b/codex-rs/tools/src/local_tool.rs @@ -275,6 +275,16 @@ pub fn create_request_permissions_tool(description: String) -> ToolSpec { )), ), ("permissions".to_string(), permission_profile_schema()), + ( + "scope".to_string(), + JsonSchema::string_enum( + vec![json!("turn"), json!("session")], + Some( + "How long the granted permissions should apply. Use \"session\" when the user explicitly asks for access in this session; defaults to \"turn\"." + .to_string(), + ), + ), + ), ]); ToolSpec::Function(ResponsesApiTool { @@ -292,7 +302,46 @@ pub fn create_request_permissions_tool(description: String) -> ToolSpec { } pub fn request_permissions_tool_description() -> String { - "Request additional filesystem or network permissions from the user and wait for the client to grant a subset of the requested permission profile. Granted permissions apply automatically to later shell-like commands in the current turn, or for the rest of the session if the client approves them at session scope." + "Open a permissions request for specific filesystem or network access, such as writing to a named path like ~/Downloads. Use this immediately when the user conversationally asks to allow access to a specific path or network permission. The returned result means the user has already approved or denied the request in the UI. Granted permissions apply automatically to later shell-like commands in the current turn, or for the rest of the session when scope is \"session\" and the client approves them at session scope. After the tool returns, do not ask the user to approve the same request again." + .to_string() +} + +pub fn create_request_permission_preset_tool(available_preset_ids: Vec<&'static str>) -> ToolSpec { + let properties = BTreeMap::from([ + ( + "preset".to_string(), + JsonSchema::string_enum( + available_preset_ids + .into_iter() + .map(|id| json!(id)) + .collect(), + Some("Built-in permission preset to request from the user.".to_string()), + ), + ), + ( + "reason".to_string(), + JsonSchema::string(Some( + "Optional short explanation for why the permission mode should change.".to_string(), + )), + ), + ]); + + ToolSpec::Function(ResponsesApiTool { + name: "request_permission_preset".to_string(), + description: request_permission_preset_tool_description(), + strict: false, + defer_loading: None, + parameters: JsonSchema::object( + properties, + Some(vec!["preset".to_string()]), + Some(false.into()), + ), + output_schema: None, + }) +} + +pub fn request_permission_preset_tool_description() -> String { + "Open the permission-mode picker with a built-in preset such as Default or Full Access preselected. Use this immediately when the user says things like \"make this session full access\", \"switch to read-only\", or otherwise conversationally asks to change sandboxing, approval, or permission mode. The session only changes if the user selects a permission mode in the picker." .to_string() } diff --git a/codex-rs/tools/src/local_tool_tests.rs b/codex-rs/tools/src/local_tool_tests.rs index b751545b3a..8911253416 100644 --- a/codex-rs/tools/src/local_tool_tests.rs +++ b/codex-rs/tools/src/local_tool_tests.rs @@ -308,6 +308,16 @@ fn request_permissions_tool_includes_full_permission_schema() { )), ), ("permissions".to_string(), permission_profile_schema()), + ( + "scope".to_string(), + JsonSchema::string_enum( + vec![serde_json::json!("turn"), serde_json::json!("session")], + Some( + "How long the granted permissions should apply. Use \"session\" when the user explicitly asks for access in this session; defaults to \"turn\"." + .to_string(), + ), + ), + ), ]); assert_eq!( @@ -327,6 +337,14 @@ fn request_permissions_tool_includes_full_permission_schema() { ); } +#[test] +fn request_permissions_tool_description_marks_returned_decision_as_resolved() { + let description = request_permissions_tool_description(); + + assert!(description.contains("the user has already approved or denied the request")); + assert!(description.contains("do not ask the user to approve the same request again")); +} + #[test] fn shell_command_tool_matches_expected_spec() { let tool = create_shell_command_tool(CommandToolOptions { diff --git a/codex-rs/tools/src/tool_config.rs b/codex-rs/tools/src/tool_config.rs index 912542d9f6..b17b9628ac 100644 --- a/codex-rs/tools/src/tool_config.rs +++ b/codex-rs/tools/src/tool_config.rs @@ -97,6 +97,8 @@ pub struct ToolsConfig { pub tool_suggest: bool, pub exec_permission_approvals_enabled: bool, pub request_permissions_tool_enabled: bool, + pub request_permission_preset_tool_enabled: bool, + pub guardian_approval_enabled: bool, pub code_mode_enabled: bool, pub code_mode_only_enabled: bool, pub js_repl_enabled: bool, @@ -161,6 +163,9 @@ impl ToolsConfig { && supports_image_generation(model_info); let exec_permission_approvals_enabled = features.enabled(Feature::ExecPermissionApprovals); let request_permissions_tool_enabled = features.enabled(Feature::RequestPermissionsTool); + let request_permission_preset_tool_enabled = + features.enabled(Feature::RequestPermissionPresetTool); + let guardian_approval_enabled = features.enabled(Feature::GuardianApproval); let shell_command_backend = if features.enabled(Feature::ShellTool) && features.enabled(Feature::ShellZshFork) { ShellCommandBackendConfig::ZshFork @@ -218,6 +223,8 @@ impl ToolsConfig { tool_suggest: include_tool_suggest, exec_permission_approvals_enabled, request_permissions_tool_enabled, + request_permission_preset_tool_enabled, + guardian_approval_enabled, code_mode_enabled: include_code_mode, code_mode_only_enabled: include_code_mode_only, js_repl_enabled: include_js_repl, diff --git a/codex-rs/tools/src/tool_registry_plan.rs b/codex-rs/tools/src/tool_registry_plan.rs index 82b4c3a142..efa93405e2 100644 --- a/codex-rs/tools/src/tool_registry_plan.rs +++ b/codex-rs/tools/src/tool_registry_plan.rs @@ -34,6 +34,7 @@ use crate::create_list_mcp_resources_tool; use crate::create_local_shell_tool; use crate::create_read_mcp_resource_tool; use crate::create_report_agent_job_result_tool; +use crate::create_request_permission_preset_tool; use crate::create_request_permissions_tool; use crate::create_request_user_input_tool; use crate::create_resume_agent_tool; @@ -61,6 +62,7 @@ use crate::request_user_input_tool_description; use crate::tool_registry_plan_types::agent_type_description; use codex_protocol::openai_models::ApplyPatchToolType; use codex_protocol::openai_models::ConfigShellToolType; +use codex_utils_approval_presets::builtin_permission_presets; use rmcp::model::Tool as McpTool; use std::collections::BTreeMap; @@ -247,6 +249,25 @@ pub fn build_tool_registry_plan( plan.register_handler("request_permissions", ToolHandlerKind::RequestPermissions); } + if config.request_permission_preset_tool_enabled { + let available_preset_ids = builtin_permission_presets( + cfg!(target_os = "windows"), + config.guardian_approval_enabled, + ) + .into_iter() + .map(|preset| preset.id) + .collect(); + plan.push_spec( + create_request_permission_preset_tool(available_preset_ids), + /*supports_parallel_tool_calls*/ false, + config.code_mode_enabled, + ); + plan.register_handler( + "request_permission_preset", + ToolHandlerKind::RequestPermissionPreset, + ); + } + if config.search_tool && let Some(deferred_mcp_tools) = params.deferred_mcp_tools { diff --git a/codex-rs/tools/src/tool_registry_plan_tests.rs b/codex-rs/tools/src/tool_registry_plan_tests.rs index 9da58849c1..5ef00cfad9 100644 --- a/codex-rs/tools/src/tool_registry_plan_tests.rs +++ b/codex-rs/tools/src/tool_registry_plan_tests.rs @@ -123,10 +123,19 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() { expected.insert(spec.name().to_string(), spec); } - if config.exec_permission_approvals_enabled { + if config.request_permissions_tool_enabled { let spec = create_request_permissions_tool(request_permissions_tool_description()); expected.insert(spec.name().to_string(), spec); } + if config.request_permission_preset_tool_enabled { + let available_preset_ids = if cfg!(target_os = "windows") { + vec!["auto", "full-access", "read-only"] + } else { + vec!["auto", "full-access"] + }; + let spec = create_request_permission_preset_tool(available_preset_ids); + expected.insert(spec.name().to_string(), spec); + } assert_eq!( actual.keys().collect::>(), @@ -560,7 +569,7 @@ fn request_user_input_description_reflects_default_mode_feature_flag() { } #[test] -fn request_permissions_requires_feature_flag() { +fn request_permissions_is_enabled_by_default_and_can_be_disabled() { let model_info = model_info(); let features = Features::with_defaults(); let available_models = Vec::new(); @@ -580,31 +589,85 @@ fn request_permissions_requires_feature_flag() { /*deferred_mcp_tools*/ None, &[], ); - assert_lacks_tool_name(&tools, "request_permissions"); - - let mut features = Features::with_defaults(); - features.enable(Feature::RequestPermissionsTool); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - sandbox_policy: &SandboxPolicy::DangerFullAccess, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); let request_permissions_tool = find_tool(&tools, "request_permissions"); assert_eq!( request_permissions_tool.spec, create_request_permissions_tool(request_permissions_tool_description()) ); + + let mut features = Features::with_defaults(); + features.disable(Feature::RequestPermissionsTool); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + image_generation_tool_auth_allowed: true, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + let (tools, _) = build_specs( + &tools_config, + /*mcp_tools*/ None, + /*deferred_mcp_tools*/ None, + &[], + ); + assert_lacks_tool_name(&tools, "request_permissions"); +} + +#[test] +fn request_permission_preset_requires_feature_flag() { + let model_info = model_info(); + let mut features = Features::with_defaults(); + features.disable(Feature::RequestPermissionPresetTool); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + image_generation_tool_auth_allowed: true, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + let (tools, _) = build_specs( + &tools_config, + /*mcp_tools*/ None, + /*deferred_mcp_tools*/ None, + &[], + ); + assert_lacks_tool_name(&tools, "request_permission_preset"); + + let mut features = Features::with_defaults(); + features.enable(Feature::RequestPermissionPresetTool); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + image_generation_tool_auth_allowed: true, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + let (tools, _) = build_specs( + &tools_config, + /*mcp_tools*/ None, + /*deferred_mcp_tools*/ None, + &[], + ); + let request_permission_preset_tool = find_tool(&tools, "request_permission_preset"); + let available_preset_ids = if cfg!(target_os = "windows") { + vec!["auto", "full-access", "read-only"] + } else { + vec!["auto", "full-access"] + }; + assert_eq!( + request_permission_preset_tool.spec, + create_request_permission_preset_tool(available_preset_ids) + ); } #[test] @@ -630,7 +693,11 @@ fn request_permissions_tool_is_independent_from_additional_permissions() { &[], ); - assert_lacks_tool_name(&tools, "request_permissions"); + let request_permissions_tool = find_tool(&tools, "request_permissions"); + assert_eq!( + request_permissions_tool.spec, + create_request_permissions_tool(request_permissions_tool_description()) + ); } #[test] diff --git a/codex-rs/tools/src/tool_registry_plan_types.rs b/codex-rs/tools/src/tool_registry_plan_types.rs index 7459954dcd..fa802be433 100644 --- a/codex-rs/tools/src/tool_registry_plan_types.rs +++ b/codex-rs/tools/src/tool_registry_plan_types.rs @@ -26,6 +26,7 @@ pub enum ToolHandlerKind { Mcp, McpResource, Plan, + RequestPermissionPreset, RequestPermissions, RequestUserInput, ResumeAgentV1, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e7ef6a0606..31b2c97e7b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1572,6 +1572,7 @@ fn request_permissions_from_params( call_id: params.item_id, reason: params.reason, permissions: params.permissions.into(), + suggested_scope: codex_protocol::request_permissions::PermissionGrantScope::Turn, } } diff --git a/codex-rs/utils/approval-presets/src/lib.rs b/codex-rs/utils/approval-presets/src/lib.rs index fbfa120e61..5b14a121cf 100644 --- a/codex-rs/utils/approval-presets/src/lib.rs +++ b/codex-rs/utils/approval-presets/src/lib.rs @@ -1,3 +1,10 @@ +//! Built-in permission presets shared by clients that render permission pickers. +//! +//! This crate keeps preset definitions independent from any single UI. Callers +//! decide which presets are available in their environment, then apply the +//! returned policies only after the relevant confirmation surface resolves. + +use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; @@ -16,6 +23,23 @@ pub struct ApprovalPreset { pub sandbox: SandboxPolicy, } +/// A built-in permission mode the user can select. +#[derive(Debug, Clone)] +pub struct PermissionPreset { + /// Stable identifier for the preset. + pub id: &'static str, + /// Display label shown in UIs. + pub label: &'static str, + /// Short human description shown next to the label in UIs. + pub description: &'static str, + /// Approval policy to apply. + pub approval: AskForApproval, + /// Sandbox policy to apply. + pub sandbox: SandboxPolicy, + /// Approval reviewer to apply. + pub approvals_reviewer: ApprovalsReviewer, +} + /// Built-in list of approval presets that pair approval and sandbox policy. /// /// Keep this UI-agnostic so it can be reused by both TUI and MCP server. @@ -44,3 +68,55 @@ pub fn builtin_approval_presets() -> Vec { }, ] } + +/// Built-in permission presets exposed by permission-mode selection surfaces. +/// +/// `include_read_only` and `include_guardian` let each client match the set of +/// presets it already exposes without duplicating the preset definitions. +pub fn builtin_permission_presets( + include_read_only: bool, + include_guardian: bool, +) -> Vec { + let mut presets = Vec::new(); + for preset in builtin_approval_presets() { + if !include_read_only && preset.id == "read-only" { + continue; + } + + presets.push(PermissionPreset { + id: preset.id, + label: preset.label, + description: preset.description, + approval: preset.approval, + sandbox: preset.sandbox.clone(), + approvals_reviewer: ApprovalsReviewer::User, + }); + + if include_guardian && preset.id == "auto" { + presets.push(PermissionPreset { + id: "guardian-approvals", + label: "Guardian Approvals", + description: "Same workspace-write permissions as Default, but eligible `on-request` approvals are routed through the guardian reviewer subagent.", + approval: preset.approval, + sandbox: preset.sandbox, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + }); + } + } + presets +} + +/// Finds a built-in permission preset by stable identifier. +/// +/// The availability flags must match the client surface that will display the +/// preset. Passing broader flags than the UI uses can let a model request a +/// preset that the user never had a chance to select. +pub fn find_builtin_permission_preset( + id: &str, + include_read_only: bool, + include_guardian: bool, +) -> Option { + builtin_permission_presets(include_read_only, include_guardian) + .into_iter() + .find(|preset| preset.id == id) +}