docs(core): document conversational permissions

Explain the confirmation lifecycle for conversational sandbox presets and
narrow permission grants across protocol, core, app-server, and TUI replay
state.

Call out where clients own confirmation, where core applies accepted
responses, and how late or partial responses should be interpreted.
This commit is contained in:
Felipe Coury
2026-04-12 19:59:15 -03:00
parent 755c7ff1ee
commit 408a8cf02c
8 changed files with 97 additions and 13 deletions
@@ -6212,6 +6212,11 @@ pub struct DynamicToolCallParams {
pub arguments: JsonValue,
}
/// Request sent to app clients when a model asks for a narrow permission grant.
///
/// The client owns the confirmation UI and may return fewer permissions than
/// requested. Treat `suggested_scope` as the model's requested lifetime, not as
/// an already-approved scope.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -6234,6 +6239,11 @@ v2_enum_from_core!(
}
);
/// Response from an app client after the narrow permission grant UI resolves.
///
/// Returning an empty permission profile is a denial. Returning a non-empty
/// profile grants only those permissions, and core applies them for the
/// specified scope.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -6243,6 +6253,7 @@ pub struct PermissionsRequestApprovalResponse {
pub scope: PermissionGrantScope,
}
/// A built-in permission-mode preset that app clients can confirm.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "kebab-case")]
#[ts(rename_all = "kebab-case", export_to = "v2/")]
@@ -6254,6 +6265,7 @@ pub enum PermissionPresetId {
}
impl PermissionPresetId {
/// Converts the app-server preset identifier into the core protocol type.
pub fn to_core(self) -> CorePermissionPresetId {
match self {
Self::Auto => CorePermissionPresetId::Auto,
@@ -6275,6 +6287,11 @@ impl From<CorePermissionPresetId> for PermissionPresetId {
}
}
/// Request sent to app clients when a model asks to switch permission modes.
///
/// Core resolves the requested preset before emitting this payload, so clients
/// should render the provided settings and label rather than recomputing policy
/// choices from the preset id.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -6298,6 +6315,7 @@ v2_enum_from_core!(
}
);
/// Response from an app client after the permission preset picker resolves.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
+26
View File
@@ -3279,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,
@@ -3335,6 +3342,13 @@ impl Session {
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,
@@ -3420,6 +3434,12 @@ impl Session {
})
}
/// 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,
@@ -3660,6 +3680,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,
@@ -8,6 +8,7 @@ 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 {
@@ -13,6 +13,7 @@ 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)]
@@ -30,6 +31,7 @@ struct RequestPermissionsToolOutput {
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 {
@@ -1,3 +1,10 @@
//! 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;
@@ -6,6 +13,7 @@ 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")]
@@ -17,6 +25,7 @@ pub enum PermissionPresetId {
}
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",
@@ -26,6 +35,7 @@ impl PermissionPresetId {
}
}
/// Parses a stable preset identifier into a permission preset enum value.
pub fn from_id(id: &str) -> Option<Self> {
match id {
"auto" => Some(Self::Auto),
@@ -37,6 +47,7 @@ impl PermissionPresetId {
}
}
/// 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,
@@ -44,6 +55,7 @@ pub struct RequestPermissionPresetArgs {
pub reason: Option<String>,
}
/// 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 {
@@ -51,6 +63,7 @@ pub enum RequestPermissionPresetDecision {
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,
@@ -58,6 +71,7 @@ pub struct RequestPermissionPresetResponse {
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.
@@ -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,6 +55,7 @@ impl From<PermissionProfile> 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")]
@@ -54,6 +65,7 @@ pub struct RequestPermissionsArgs {
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,
@@ -61,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.
@@ -22,20 +22,19 @@ impl ElicitationRequestKey {
}
}
/// Tracks which interactive prompts are still unresolved in the thread-event buffer.
///
/// Thread snapshots are replayed when switching threads or agents. Most events
/// should replay verbatim, but interactive prompts such as approvals,
/// `request_user_input`, MCP elicitations, permission grants, and permission
/// preset pickers must only replay while they are still pending.
///
/// The state is updated from inbound events, outbound ops that resolve a prompt,
/// and buffer eviction. It keeps both fast lookup sets for snapshot filtering
/// and turn-indexed queues so `TurnComplete` and `TurnAborted` can clear stale
/// prompts tied to a turn. `request_user_input` removal is FIFO because the
/// overlay answers queued prompts in FIFO order for a shared `turn_id`.
#[derive(Debug, Default)]
// Tracks which interactive prompts are still unresolved in the thread-event buffer.
//
// Thread snapshots are replayed when switching threads/agents. Most events should replay
// verbatim, but interactive prompts (approvals, request_user_input, MCP elicitations) must
// only replay if they are still pending. This state is updated from:
// - inbound events (`note_event`)
// - outbound ops that resolve a prompt (`note_outbound_op`)
// - buffer eviction (`note_evicted_event`)
//
// We keep both fast lookup sets (for snapshot filtering by call_id/request key) and
// turn-indexed queues/vectors so `TurnComplete`/`TurnAborted` can clear stale prompts tied
// to a turn. `request_user_input` removal is FIFO because the overlay answers queued prompts
// in FIFO order for a shared `turn_id`.
pub(super) struct PendingInteractiveReplayState {
exec_approval_call_ids: HashSet<String>,
exec_approval_call_ids_by_turn_id: HashMap<String, Vec<String>>,
@@ -1,3 +1,9 @@
//! 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;
@@ -100,6 +106,11 @@ pub fn builtin_permission_presets(
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,