[app-server] expose resolved permission presets

This commit is contained in:
Joey Trasatti
2026-06-22 15:25:01 -07:00
parent 372b5ac7c2
commit bf86242824
17 changed files with 573 additions and 1 deletions
+1
View File
@@ -2011,6 +2011,7 @@ dependencies = [
"codex-thread-store",
"codex-tools",
"codex-utils-absolute-path",
"codex-utils-approval-presets",
"codex-utils-cargo-bin",
"codex-utils-cli",
"codex-utils-json-to-toml",
@@ -12990,6 +12990,39 @@
}
]
},
"PermissionPreset": {
"properties": {
"approvalPolicy": {
"$ref": "#/definitions/v2/AskForApproval"
},
"approvalsReviewer": {
"$ref": "#/definitions/v2/ApprovalsReviewer"
},
"description": {
"type": [
"string",
"null"
]
},
"id": {
"type": "string"
},
"isDefault": {
"type": "boolean"
},
"permissionProfileId": {
"type": "string"
}
},
"required": [
"approvalPolicy",
"approvalsReviewer",
"id",
"isDefault",
"permissionProfileId"
],
"type": "object"
},
"PermissionProfileListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -9394,6 +9394,39 @@
}
]
},
"PermissionPreset": {
"properties": {
"approvalPolicy": {
"$ref": "#/definitions/AskForApproval"
},
"approvalsReviewer": {
"$ref": "#/definitions/ApprovalsReviewer"
},
"description": {
"type": [
"string",
"null"
]
},
"id": {
"type": "string"
},
"isDefault": {
"type": "boolean"
},
"permissionProfileId": {
"type": "string"
}
},
"required": [
"approvalPolicy",
"approvalsReviewer",
"id",
"isDefault",
"permissionProfileId"
],
"type": "object"
},
"PermissionProfileListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -0,0 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ApprovalsReviewer } from "./ApprovalsReviewer";
import type { AskForApproval } from "./AskForApproval";
export type PermissionPreset = { id: string, permissionProfileId: string, description: string | null, approvalPolicy: AskForApproval, approvalsReviewer: ApprovalsReviewer, isDefault: boolean, };
@@ -279,6 +279,7 @@ export type { OverriddenMetadata } from "./OverriddenMetadata";
export type { PatchApplyStatus } from "./PatchApplyStatus";
export type { PatchChangeKind } from "./PatchChangeKind";
export type { PermissionGrantScope } from "./PermissionGrantScope";
export type { PermissionPreset } from "./PermissionPreset";
export type { PermissionProfileListParams } from "./PermissionProfileListParams";
export type { PermissionProfileListResponse } from "./PermissionProfileListResponse";
export type { PermissionProfileSummary } from "./PermissionProfileSummary";
@@ -875,6 +875,12 @@ client_request_definitions! {
serialization: global_shared_read("config"),
response: v2::PermissionProfileListResponse,
},
#[experimental("permissionPreset/list")]
PermissionPresetList => "permissionPreset/list" {
params: v2::PermissionPresetListParams,
serialization: global_shared_read("config"),
response: v2::PermissionPresetListResponse,
},
ExperimentalFeatureEnablementSet => "experimentalFeature/enablement/set" {
params: v2::ExperimentalFeatureEnablementSetParams,
serialization: global("config"),
@@ -1,3 +1,5 @@
use super::ApprovalsReviewer;
use super::AskForApproval;
use super::shared::v2_enum_from_core;
use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment;
use codex_protocol::approvals::NetworkApprovalContext as CoreNetworkApprovalContext;
@@ -392,6 +394,38 @@ pub struct PermissionProfileListResponse {
pub next_cursor: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PermissionPresetListParams {
#[ts(optional = nullable)]
pub cursor: Option<String>,
#[ts(optional = nullable)]
pub limit: Option<u32>,
#[ts(optional = nullable)]
pub cwd: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PermissionPreset {
pub id: String,
pub permission_profile_id: String,
pub description: Option<String>,
pub approval_policy: AskForApproval,
pub approvals_reviewer: ApprovalsReviewer,
pub is_default: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PermissionPresetListResponse {
pub data: Vec<PermissionPreset>,
pub next_cursor: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
+1
View File
@@ -75,6 +75,7 @@ codex-state = { workspace = true }
codex-thread-store = { workspace = true }
codex-tools = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-approval-presets = { workspace = true }
codex-utils-json-to-toml = { workspace = true }
codex-utils-path-uri = { workspace = true }
chrono = { workspace = true }
+1
View File
@@ -203,6 +203,7 @@ Example with notification opt-out:
- `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider.
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
- `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text and an `allowed` flag reflecting effective requirements, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.<id>]` entries to be included in the current catalog view.
- `permissionPreset/list` — experimental; list resolved, selectable permission presets for an optional `cwd`, using cursor pagination. Each preset includes an opaque `id`, `permissionProfileId`, `approvalPolicy`, `approvalsReviewer`, optional custom-profile `description`, and `isDefault`. The server applies managed requirements, omits unavailable profiles, and removes presets that resolve to the same settings; clients should preserve response order and apply the returned settings without reconstructing preset policy.
- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for currently supported feature keys. For each feature, precedence is: cloud requirements > --enable <feature_name> > config.toml > experimentalFeature/enablement/set (new) > code default. Invalid keys will be ignored.
- `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; optional `connectTimeoutMs` overrides the WebSocket connection timeout; returns `{}` and does not change the default environment.
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly.
+1
View File
@@ -102,6 +102,7 @@ mod message_processor;
mod models;
mod models_refresh_worker;
mod outgoing_message;
mod permission_presets;
mod request_processors;
mod request_serialization;
mod server_request_error;
@@ -1309,6 +1309,9 @@ impl MessageProcessor {
ClientRequest::PermissionProfileList { params, .. } => {
self.catalog_processor.permission_profile_list(params).await
}
ClientRequest::PermissionPresetList { params, .. } => {
self.catalog_processor.permission_preset_list(params).await
}
ClientRequest::CollaborationModeList { params, .. } => {
self.catalog_processor.collaboration_mode_list(params).await
}
@@ -0,0 +1,143 @@
use crate::config_manager::ConfigManager;
use codex_app_server_protocol::PermissionPreset;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_features::Feature;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::GranularApprovalConfig;
use codex_utils_approval_presets::builtin_approval_presets;
use futures::StreamExt;
use std::path::PathBuf;
const PRESET_RESOLUTION_CONCURRENCY: usize = 5;
struct PermissionPresetCandidate {
id: String,
permission_profile_id: String,
description: Option<String>,
approval_policy: AskForApproval,
approvals_reviewer: ApprovalsReviewer,
}
pub(crate) async fn permission_presets(
config_manager: &ConfigManager,
cwd: Option<PathBuf>,
) -> std::io::Result<Vec<PermissionPreset>> {
let config = config_manager
.load_for_cwd(None, ConfigOverrides::default(), cwd.clone())
.await?;
let mut candidates = Vec::new();
for preset in builtin_approval_presets() {
candidates.push(PermissionPresetCandidate {
id: preset.id.to_string(),
permission_profile_id: preset.active_permission_profile.id.clone(),
description: None,
approval_policy: preset.approval,
approvals_reviewer: ApprovalsReviewer::User,
});
if preset.id == "auto" {
candidates.push(PermissionPresetCandidate {
id: "granular".to_string(),
permission_profile_id: preset.active_permission_profile.id.clone(),
description: None,
approval_policy: AskForApproval::Granular(GranularApprovalConfig {
sandbox_approval: false,
rules: false,
skill_approval: false,
request_permissions: true,
mcp_elicitations: true,
}),
approvals_reviewer: ApprovalsReviewer::User,
});
if config.features.enabled(Feature::GuardianApproval) {
candidates.push(PermissionPresetCandidate {
id: "guardian-approvals".to_string(),
permission_profile_id: preset.active_permission_profile.id,
description: None,
approval_policy: AskForApproval::OnRequest,
approvals_reviewer: ApprovalsReviewer::AutoReview,
});
}
}
}
let mut custom_profiles = config.custom_permission_profiles.clone();
custom_profiles.sort_by(|left, right| left.id.cmp(&right.id));
candidates.extend(
custom_profiles
.into_iter()
.map(|profile| PermissionPresetCandidate {
id: format!("permission-profile:{}", profile.id),
permission_profile_id: profile.id,
description: profile.description,
approval_policy: config.permissions.approval_policy.value(),
approvals_reviewer: config.approvals_reviewer,
}),
);
let resolved_presets = futures::stream::iter(candidates)
.map(|candidate| resolve_candidate(config_manager, cwd.clone(), &config, candidate))
.buffered(PRESET_RESOLUTION_CONCURRENCY)
.collect::<Vec<_>>()
.await;
let mut presets = Vec::new();
for preset in resolved_presets {
let Some(preset) = preset else {
continue;
};
if presets.iter().any(|existing: &PermissionPreset| {
existing.permission_profile_id == preset.permission_profile_id
&& existing.approval_policy == preset.approval_policy
&& existing.approvals_reviewer == preset.approvals_reviewer
}) {
continue;
}
presets.push(preset);
}
Ok(presets)
}
async fn resolve_candidate(
config_manager: &ConfigManager,
cwd: Option<PathBuf>,
default_config: &Config,
candidate: PermissionPresetCandidate,
) -> Option<PermissionPreset> {
let config = config_manager
.load_for_cwd(
None,
ConfigOverrides {
approval_policy: Some(candidate.approval_policy),
approvals_reviewer: Some(candidate.approvals_reviewer),
default_permissions: Some(candidate.permission_profile_id.clone()),
..Default::default()
},
cwd,
)
.await
.ok()?;
let active_profile = config.permissions.active_permission_profile()?;
if active_profile.id != candidate.permission_profile_id {
return None;
}
let approval_policy = config.permissions.approval_policy.value();
let approvals_reviewer = config.approvals_reviewer;
let is_default = default_config
.permissions
.active_permission_profile()
.is_some_and(|profile| profile.id == candidate.permission_profile_id)
&& default_config.permissions.approval_policy.value() == approval_policy
&& default_config.approvals_reviewer == approvals_reviewer;
Some(PermissionPreset {
id: candidate.id,
permission_profile_id: candidate.permission_profile_id,
description: candidate.description,
approval_policy: approval_policy.into(),
approvals_reviewer: approvals_reviewer.into(),
is_default,
})
}
@@ -115,6 +115,8 @@ use codex_app_server_protocol::MockExperimentalMethodParams;
use codex_app_server_protocol::MockExperimentalMethodResponse;
use codex_app_server_protocol::ModelListParams;
use codex_app_server_protocol::ModelListResponse;
use codex_app_server_protocol::PermissionPresetListParams;
use codex_app_server_protocol::PermissionPresetListResponse;
use codex_app_server_protocol::PermissionProfileListParams;
use codex_app_server_protocol::PermissionProfileListResponse;
use codex_app_server_protocol::PermissionProfileSummary;
@@ -1,5 +1,6 @@
use super::*;
use crate::permission_presets::permission_presets;
use codex_core::config::permission_profile_catalog;
use super::*;
use futures::StreamExt;
#[derive(Clone)]
@@ -180,6 +181,15 @@ impl CatalogRequestProcessor {
.map(|response| Some(response.into()))
}
pub(crate) async fn permission_preset_list(
&self,
params: PermissionPresetListParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.permission_preset_list_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn collaboration_mode_list(
&self,
params: CollaborationModeListParams,
@@ -466,6 +476,39 @@ impl CatalogRequestProcessor {
Ok(PermissionProfileListResponse { data, next_cursor })
}
async fn permission_preset_list_response(
&self,
params: PermissionPresetListParams,
) -> Result<PermissionPresetListResponse, JSONRPCErrorError> {
let PermissionPresetListParams { cursor, limit, cwd } = params;
let presets = permission_presets(&self.config_manager, cwd.map(PathBuf::from))
.await
.map_err(|err| {
internal_error(format!("failed to resolve permission presets: {err}"))
})?;
let total = presets.len();
let effective_limit = limit.unwrap_or(total as u32).max(1) as usize;
let effective_limit = effective_limit.min(total);
let start = match cursor {
Some(cursor) => cursor
.parse::<usize>()
.map_err(|_| invalid_request(format!("invalid cursor: {cursor}")))?,
None => 0,
};
if start > total {
return Err(invalid_request(format!(
"cursor {start} exceeds total permission presets {total}"
)));
}
let end = start.saturating_add(effective_limit).min(total);
let data = presets[start..end].to_vec();
let next_cursor = (end < total).then_some(end.to_string());
Ok(PermissionPresetListResponse { data, next_cursor })
}
async fn mock_experimental_method_inner(
&self,
params: MockExperimentalMethodParams,
@@ -58,6 +58,7 @@ use codex_app_server_protocol::McpServerToolCallParams;
use codex_app_server_protocol::MockExperimentalMethodParams;
use codex_app_server_protocol::ModelListParams;
use codex_app_server_protocol::ModelProviderCapabilitiesReadParams;
use codex_app_server_protocol::PermissionPresetListParams;
use codex_app_server_protocol::PermissionProfileListParams;
use codex_app_server_protocol::PluginInstallParams;
use codex_app_server_protocol::PluginInstalledParams;
@@ -648,6 +649,15 @@ impl TestAppServer {
self.send_request("permissionProfile/list", params).await
}
/// Send a `permissionPreset/list` JSON-RPC request.
pub async fn send_permission_preset_list_request(
&mut self,
params: PermissionPresetListParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("permissionPreset/list", params).await
}
/// Send an `experimentalFeature/enablement/set` JSON-RPC request.
pub async fn send_experimental_feature_enablement_set_request(
&mut self,
@@ -35,6 +35,7 @@ mod memory_reset;
mod model_list;
mod model_provider_capabilities_read;
mod output_schema;
mod permission_preset_list;
mod permission_profile_list;
mod plan_item;
mod plugin_install;
@@ -0,0 +1,252 @@
use std::time::Duration;
use anyhow::Result;
use app_test_support::TestAppServer;
use app_test_support::to_response;
use codex_app_server_protocol::ApprovalsReviewer;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::PermissionPreset;
use codex_app_server_protocol::PermissionPresetListParams;
use codex_app_server_protocol::PermissionPresetListResponse;
use codex_app_server_protocol::RequestId;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
#[tokio::test]
async fn permission_preset_list_returns_resolved_builtin_presets() -> Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("config.toml"),
r#"
default_permissions = ":workspace"
approvals_reviewer = "auto_review"
[features]
guardian_approval = true
"#,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_permission_preset_list_request(PermissionPresetListParams {
cursor: None,
limit: None,
cwd: None,
})
.await?;
let actual = read_response::<PermissionPresetListResponse>(&mut mcp, request_id).await?;
assert_eq!(
actual,
PermissionPresetListResponse {
data: vec![
preset(
"read-only",
BUILT_IN_PERMISSION_PROFILE_READ_ONLY,
AskForApproval::OnRequest,
ApprovalsReviewer::User,
false,
),
preset(
"auto",
BUILT_IN_PERMISSION_PROFILE_WORKSPACE,
AskForApproval::OnRequest,
ApprovalsReviewer::User,
false,
),
preset(
"granular",
BUILT_IN_PERMISSION_PROFILE_WORKSPACE,
AskForApproval::Granular {
sandbox_approval: false,
rules: false,
skill_approval: false,
request_permissions: true,
mcp_elicitations: true,
},
ApprovalsReviewer::User,
false,
),
preset(
"guardian-approvals",
BUILT_IN_PERMISSION_PROFILE_WORKSPACE,
AskForApproval::OnRequest,
ApprovalsReviewer::AutoReview,
true,
),
preset(
"full-access",
BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS,
AskForApproval::Never,
ApprovalsReviewer::User,
false,
),
],
next_cursor: None,
}
);
Ok(())
}
#[tokio::test]
async fn permission_preset_list_applies_managed_profile_and_policy_fallbacks() -> Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("config.toml"),
r#"
default_permissions = "dev"
approval_policy = "never"
approvals_reviewer = "auto_review"
[permissions.dev]
description = "Day-to-day coding work."
[permissions.dev.filesystem]
":workspace_roots" = "write"
[permissions.audit]
description = "Inspect without writes."
[permissions.audit.filesystem]
":workspace_roots" = "read"
"#,
)?;
std::fs::write(
codex_home.path().join("requirements.toml"),
r#"
allowed_approval_policies = ["on-request"]
allowed_approvals_reviewers = ["user"]
default_permissions = "audit"
[allowed_permission_profiles]
":read-only" = true
audit = true
"#,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_permission_preset_list_request(PermissionPresetListParams {
cursor: None,
limit: None,
cwd: None,
})
.await?;
let actual = read_response::<PermissionPresetListResponse>(&mut mcp, request_id).await?;
assert_eq!(
actual,
PermissionPresetListResponse {
data: vec![
preset(
"read-only",
BUILT_IN_PERMISSION_PROFILE_READ_ONLY,
AskForApproval::OnRequest,
ApprovalsReviewer::User,
false,
),
PermissionPreset {
id: "permission-profile:audit".to_string(),
permission_profile_id: "audit".to_string(),
description: Some("Inspect without writes.".to_string()),
approval_policy: AskForApproval::OnRequest,
approvals_reviewer: ApprovalsReviewer::User,
is_default: true,
},
],
next_cursor: None,
}
);
Ok(())
}
#[tokio::test]
async fn permission_preset_list_removes_presets_with_identical_resolved_settings() -> Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("config.toml"),
r#"
default_permissions = ":workspace"
approval_policy = "never"
approvals_reviewer = "auto_review"
"#,
)?;
std::fs::write(
codex_home.path().join("requirements.toml"),
r#"
allowed_approval_policies = ["on-request"]
allowed_approvals_reviewers = ["user"]
default_permissions = ":workspace"
[allowed_permission_profiles]
":workspace" = true
"#,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_permission_preset_list_request(PermissionPresetListParams {
cursor: None,
limit: None,
cwd: None,
})
.await?;
let actual = read_response::<PermissionPresetListResponse>(&mut mcp, request_id).await?;
assert_eq!(
actual,
PermissionPresetListResponse {
data: vec![preset(
"auto",
BUILT_IN_PERMISSION_PROFILE_WORKSPACE,
AskForApproval::OnRequest,
ApprovalsReviewer::User,
true,
)],
next_cursor: None,
}
);
Ok(())
}
fn preset(
id: &str,
permission_profile_id: &str,
approval_policy: AskForApproval,
approvals_reviewer: ApprovalsReviewer,
is_default: bool,
) -> PermissionPreset {
PermissionPreset {
id: id.to_string(),
permission_profile_id: permission_profile_id.to_string(),
description: None,
approval_policy,
approvals_reviewer,
is_default,
}
}
async fn read_response<T: serde::de::DeserializeOwned>(
mcp: &mut TestAppServer,
request_id: i64,
) -> Result<T> {
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
to_response(response)
}