From 713a5b1b00d762b2a2a5ec3d6fd44c252205896f Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Wed, 20 May 2026 17:33:01 -0700 Subject: [PATCH] feat: support managed permission profiles in requirements.toml (#23433) ## Why Cloud-managed `requirements.toml` should be able to define the managed permission profiles a client may select and constrain that selectable set without requiring local user config to recreate the profile catalog. This keeps requirements focused on restrictions. The selected default remains a config or session choice, while requirements contribute the managed profile bodies and `allowed_permissions` allowlist that the config-loading boundary validates before a resolved runtime `PermissionProfile` is installed. ## What changed - Add `requirements.toml` support for a managed permission-profile catalog plus its allowlist: ```toml allowed_permissions = ["review", "build"] [permissions.review] extends = ":read-only" [permissions.build] extends = ":workspace" ``` - Merge requirements-defined profile bodies into the effective permission catalog and reject profile ids that collide with config-defined profiles. - Validate that every `allowed_permissions` entry resolves to a built-in or catalog profile before selection uses it. - Preserve allowed configured named-profile selections. When a configured named profile is disallowed, fall back to the first allowed requirements profile with a startup warning. - Keep built-in selections and the stock trust-based `:read-only` / `:workspace` fallback path intact when no permission profile is explicitly selected. - Centralize the managed catalog and allowlist selection path in `EffectivePermissionSelection` so the requirements boundary is visible in config loading. - Surface `allowedPermissions` through `configRequirements/read`, and update the generated app-server schema fixtures plus the app-server README. ## Validation - `cargo test -p codex-config` - `cargo test -p codex-core system_requirements_` - `cargo test -p codex-core system_allowed_permissions_` - `cargo test -p codex-app-server-protocol` - `just write-app-server-schema` ## Related work - Uses merged permission-profile inheritance support from #22270 and #23705. - Kept separate from the in-flight permission profile listing API in #23412. --- .../codex_app_server_protocol.schemas.json | 9 + .../codex_app_server_protocol.v2.schemas.json | 9 + .../v2/ConfigRequirementsReadResponse.json | 9 + .../typescript/v2/ConfigRequirements.ts | 2 +- .../src/protocol/v2/config.rs | 1 + .../src/protocol/v2/tests.rs | 1 + codex-rs/app-server/README.md | 2 +- .../request_processors/config_processor.rs | 12 + codex-rs/cloud-requirements/src/lib.rs | 16 ++ codex-rs/config/src/config_requirements.rs | 123 +++++++- .../core/src/config/config_loader_tests.rs | 263 ++++++++++++++++++ codex-rs/core/src/config/config_tests.rs | 2 + codex-rs/core/src/config/mod.rs | 222 ++++++++++++--- codex-rs/tui/src/debug_config.rs | 2 + 14 files changed, 638 insertions(+), 35 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index c7a7989fbc..dda285476d 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -7744,6 +7744,15 @@ "null" ] }, + "allowedPermissions": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "allowedSandboxModes": { "items": { "$ref": "#/definitions/v2/SandboxMode" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 7da34ca00c..3a23ba3a69 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -4113,6 +4113,15 @@ "null" ] }, + "allowedPermissions": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "allowedSandboxModes": { "items": { "$ref": "#/definitions/SandboxMode" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index 761177ea4c..d25ca854af 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -88,6 +88,15 @@ "null" ] }, + "allowedPermissions": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "allowedSandboxModes": { "items": { "$ref": "#/definitions/SandboxMode" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts index 5d2755d5ac..8653da78cd 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts @@ -7,4 +7,4 @@ import type { ComputerUseRequirements } from "./ComputerUseRequirements"; import type { ResidencyRequirement } from "./ResidencyRequirement"; import type { SandboxMode } from "./SandboxMode"; -export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null}; +export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedPermissions: Array | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null}; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index 98466078e2..16f9bf154b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -386,6 +386,7 @@ pub struct ConfigRequirements { #[experimental("configRequirements/read.allowedApprovalsReviewers")] pub allowed_approvals_reviewers: Option>, pub allowed_sandbox_modes: Option>, + pub allowed_permissions: Option>, pub allowed_web_search_modes: Option>, pub allow_managed_hooks_only: Option, pub computer_use: Option, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 3aadc6b24c..bbe6bec3f2 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -1728,6 +1728,7 @@ fn config_requirements_granular_allowed_approval_policy_is_marked_experimental() }]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, computer_use: None, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 29ac2b2cc5..2ceffc86fe 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -226,7 +226,7 @@ Example with notification opt-out: - `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. When a request includes migration items, the server emits `externalAgentConfig/import/completed` once after the full import finishes (immediately after the response when everything completed synchronously, or after background imports finish). - `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface. - `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits. -- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), lifecycle hook lockdown (`allowManagedHooksOnly`), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. +- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`, `allowedPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. ### Example: Start or resume a thread diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index 5327f4ffa2..dd6e8e3c92 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -419,6 +419,7 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR .filter_map(map_sandbox_mode_requirement_to_api) .collect() }), + allowed_permissions: requirements.allowed_permissions, allowed_web_search_modes: requirements.allowed_web_search_modes.map(|modes| { let mut normalized = modes .into_iter() @@ -638,10 +639,21 @@ mod tests { #[test] fn requirements_api_includes_allow_managed_hooks_only() { let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { + allowed_permissions: Some(vec![ + "managed-standard".to_string(), + "managed-build".to_string(), + ]), allow_managed_hooks_only: Some(true), ..ConfigRequirementsToml::default() }); + assert_eq!( + mapped.allowed_permissions, + Some(vec![ + "managed-standard".to_string(), + "managed-build".to_string(), + ]) + ); assert_eq!(mapped.allow_managed_hooks_only, Some(true)); assert_eq!(mapped.hooks, None); } diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index b9740602dd..27649ad4c1 100644 --- a/codex-rs/cloud-requirements/src/lib.rs +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -1216,6 +1216,7 @@ mod tests { allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, @@ -1300,6 +1301,7 @@ mod tests { allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, @@ -1335,6 +1337,7 @@ mod tests { allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, @@ -1387,6 +1390,7 @@ mod tests { allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, @@ -1568,6 +1572,7 @@ command = "sample-mcp" allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, @@ -1650,6 +1655,7 @@ command = "sample-mcp" allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, @@ -1730,6 +1736,7 @@ command = "sample-mcp" allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, @@ -1938,6 +1945,7 @@ command = "sample-mcp" allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, @@ -1980,6 +1988,7 @@ command = "sample-mcp" allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, @@ -2042,6 +2051,7 @@ command = "sample-mcp" allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, @@ -2100,6 +2110,7 @@ command = "sample-mcp" allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, @@ -2160,6 +2171,7 @@ command = "sample-mcp" allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, @@ -2221,6 +2233,7 @@ command = "sample-mcp" allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, @@ -2286,6 +2299,7 @@ command = "sample-mcp" allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, @@ -2377,6 +2391,7 @@ command = "sample-mcp" allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, @@ -2414,6 +2429,7 @@ command = "sample-mcp" allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 8e2e21f0c4..d209c16199 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -19,6 +19,7 @@ use crate::Constrained; use crate::ConstraintError; use crate::ManagedHooksRequirementsToml; use crate::mcp_types::AppToolApproval; +use crate::permissions_toml::PermissionProfileToml; #[derive(Debug, Clone, PartialEq, Eq)] pub enum RequirementSource { @@ -432,14 +433,58 @@ impl From for NetworkConstraints { } } -#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct FilesystemRequirementsToml { pub deny_read: Option>, } +#[derive(Deserialize)] +struct RawFilesystemRequirementsToml { + deny_read: Option>, + description: Option, + extends: Option, + workspace_roots: Option, + filesystem: Option, + network: Option, +} + +impl<'de> Deserialize<'de> for FilesystemRequirementsToml { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = RawFilesystemRequirementsToml::deserialize(deserializer)?; + let RawFilesystemRequirementsToml { + deny_read, + description, + extends, + workspace_roots, + filesystem, + network, + } = raw; + + if description.is_some() + || extends.is_some() + || workspace_roots.is_some() + || filesystem.is_some() + || network.is_some() + { + return Err(D::Error::custom( + "`permissions.filesystem` is reserved for requirements-level filesystem constraints and cannot define a profile", + )); + } + + Ok(Self { deny_read }) + } +} + #[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] pub struct PermissionsRequirementsToml { pub filesystem: Option, + // For legacy reasons, `filesystem` stays reserved for requirements-level + // filesystem constraints and cannot name a profile. + #[serde(default, flatten)] + pub profiles: BTreeMap, } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -701,6 +746,7 @@ pub struct ConfigRequirementsToml { pub allowed_approval_policies: Option>, pub allowed_approvals_reviewers: Option>, pub allowed_sandbox_modes: Option>, + pub allowed_permissions: Option>, pub remote_sandbox_config: Option>, pub allowed_web_search_modes: Option>, pub allow_managed_hooks_only: Option, @@ -752,6 +798,7 @@ pub struct ConfigRequirementsWithSources { pub allowed_approval_policies: Option>>, pub allowed_approvals_reviewers: Option>>, pub allowed_sandbox_modes: Option>>, + pub allowed_permissions: Option>>, pub allowed_web_search_modes: Option>>, pub allow_managed_hooks_only: Option>, pub computer_use: Option>, @@ -789,6 +836,7 @@ impl ConfigRequirementsWithSources { allowed_approval_policies: _, allowed_approvals_reviewers: _, allowed_sandbox_modes: _, + allowed_permissions: _, remote_sandbox_config: _, allowed_web_search_modes: _, allow_managed_hooks_only: _, @@ -821,6 +869,7 @@ impl ConfigRequirementsWithSources { allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, + allowed_permissions, allowed_web_search_modes, allow_managed_hooks_only, computer_use, @@ -850,6 +899,7 @@ impl ConfigRequirementsWithSources { allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, + allowed_permissions, allowed_web_search_modes, allow_managed_hooks_only, computer_use, @@ -868,6 +918,7 @@ impl ConfigRequirementsWithSources { allowed_approval_policies: allowed_approval_policies.map(|sourced| sourced.value), allowed_approvals_reviewers: allowed_approvals_reviewers.map(|sourced| sourced.value), allowed_sandbox_modes: allowed_sandbox_modes.map(|sourced| sourced.value), + allowed_permissions: allowed_permissions.map(|sourced| sourced.value), remote_sandbox_config: None, allowed_web_search_modes: allowed_web_search_modes.map(|sourced| sourced.value), allow_managed_hooks_only: allow_managed_hooks_only.map(|sourced| sourced.value), @@ -953,6 +1004,7 @@ impl ConfigRequirementsToml { self.allowed_approval_policies.is_none() && self.allowed_approvals_reviewers.is_none() && self.allowed_sandbox_modes.is_none() + && self.allowed_permissions.is_none() && self.remote_sandbox_config.is_none() && self.allowed_web_search_modes.is_none() && self.allow_managed_hooks_only.is_none() @@ -992,10 +1044,14 @@ impl TryFrom for ConfigRequirements { type Error = ConstraintError; fn try_from(toml: ConfigRequirementsWithSources) -> Result { + // Profile catalog selection remains on ConfigRequirementsToml for + // config loading and requirements API projection. The normalized + // constraints below only need the compiled PermissionProfile envelope. let ConfigRequirementsWithSources { allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, + allowed_permissions: _, allowed_web_search_modes, allow_managed_hooks_only, computer_use, @@ -1301,6 +1357,7 @@ mod tests { allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, + allowed_permissions, remote_sandbox_config: _, allowed_web_search_modes, allow_managed_hooks_only, @@ -1323,6 +1380,8 @@ mod tests { .map(|value| Sourced::new(value, RequirementSource::Unknown)), allowed_sandbox_modes: allowed_sandbox_modes .map(|value| Sourced::new(value, RequirementSource::Unknown)), + allowed_permissions: allowed_permissions + .map(|value| Sourced::new(value, RequirementSource::Unknown)), allowed_web_search_modes: allowed_web_search_modes .map(|value| Sourced::new(value, RequirementSource::Unknown)), allow_managed_hooks_only: allow_managed_hooks_only @@ -1370,6 +1429,61 @@ mod tests { Ok(()) } + #[test] + fn deserialize_managed_permission_profiles() -> Result<()> { + let requirements: ConfigRequirementsToml = from_str( + r#" + allowed_permissions = ["managed-standard", "managed-build"] + + [permissions.managed-standard] + extends = ":workspace" + + [permissions.managed-build] + extends = "managed-standard" + "#, + )?; + + assert_eq!( + requirements.allowed_permissions, + Some(vec![ + "managed-standard".to_string(), + "managed-build".to_string(), + ]) + ); + let permissions = requirements + .permissions + .as_ref() + .expect("managed permission profiles"); + assert!(permissions.profiles.contains_key("managed-standard")); + assert!( + permissions + .profiles + .get("managed-build") + .and_then(|profile| profile.extends.as_deref()) + .is_some() + ); + assert!(!requirements.is_empty()); + Ok(()) + } + + #[test] + fn filesystem_requirements_table_cannot_define_a_permission_profile() { + let err = from_str::( + r#" + [permissions.filesystem] + extends = ":workspace" + "#, + ) + .expect_err("filesystem requirements cannot define a permission profile"); + + assert!( + err.to_string().contains( + "`permissions.filesystem` is reserved for requirements-level filesystem constraints and cannot define a profile" + ), + "unexpected error: {err:#}" + ); + } + #[test] fn deserialize_computer_use_requirements() -> Result<()> { let requirements: ConfigRequirementsToml = from_str( @@ -1421,6 +1535,7 @@ mod tests { allowed_approval_policies: Some(allowed_approval_policies.clone()), allowed_approvals_reviewers: Some(allowed_approvals_reviewers.clone()), allowed_sandbox_modes: Some(allowed_sandbox_modes.clone()), + allowed_permissions: Some(vec!["managed".to_string()]), remote_sandbox_config: None, allowed_web_search_modes: Some(allowed_web_search_modes.clone()), allow_managed_hooks_only: Some(true), @@ -1451,6 +1566,10 @@ mod tests { source.clone(), )), allowed_sandbox_modes: Some(Sourced::new(allowed_sandbox_modes, source.clone(),)), + allowed_permissions: Some(Sourced::new( + vec!["managed".to_string()], + source.clone(), + )), allowed_web_search_modes: Some(Sourced::new( allowed_web_search_modes, enforce_source.clone(), @@ -1501,6 +1620,7 @@ mod tests { )), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, computer_use: None, @@ -1550,6 +1670,7 @@ mod tests { )), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, computer_use: None, diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index 760b1f3fc5..db671e1d3a 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -27,6 +27,8 @@ use codex_config::loader::load_requirements_toml; use codex_exec_server::LOCAL_FS; use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::WebSearchMode; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_utils_absolute_path::AbsolutePathBuf; @@ -1095,6 +1097,7 @@ allowed_approval_policies = ["on-request"] allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, @@ -1154,6 +1157,7 @@ allowed_approval_policies = ["on-request"] allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, @@ -1362,6 +1366,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, @@ -1411,6 +1416,264 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> Ok(()) } +#[tokio::test] +async fn system_requirements_define_managed_permission_profiles() -> anyhow::Result<()> { + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + tokio::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#" +default_permissions = "managed-standard" +"#, + ) + .await?; + let requirements_path = tmp.path().join("requirements.toml"); + tokio::fs::write( + &requirements_path, + r#" +allowed_permissions = ["managed-standard"] + +[permissions.managed-standard] +extends = ":workspace" +"#, + ) + .await?; + + let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.system_requirements_path = Some(requirements_path); + let config = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(cwd.to_path_buf())) + .loader_overrides(overrides) + .build() + .await?; + + assert_eq!( + config + .config_layer_stack + .requirements_toml() + .allowed_permissions, + Some(vec!["managed-standard".to_string()]) + ); + assert_eq!( + config + .permissions + .active_permission_profile() + .map(|profile| profile.id), + Some("managed-standard".to_string()) + ); + Ok(()) +} + +#[tokio::test] +async fn system_allowed_permissions_keep_builtin_permission_fallbacks() -> anyhow::Result<()> { + for (trust_level, expected_profile) in [ + ( + Some(TrustLevel::Trusted), + if cfg!(target_os = "windows") { + BUILT_IN_PERMISSION_PROFILE_READ_ONLY + } else { + BUILT_IN_PERMISSION_PROFILE_WORKSPACE + }, + ), + ( + Some(TrustLevel::Untrusted), + if cfg!(target_os = "windows") { + BUILT_IN_PERMISSION_PROFILE_READ_ONLY + } else { + BUILT_IN_PERMISSION_PROFILE_WORKSPACE + }, + ), + (None, BUILT_IN_PERMISSION_PROFILE_READ_ONLY), + ] { + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + if let Some(trust_level) = trust_level { + make_config_for_test( + &codex_home, + tmp.path(), + trust_level, + /*project_root_markers*/ None, + ) + .await?; + } + let requirements_path = tmp.path().join("requirements.toml"); + tokio::fs::write( + &requirements_path, + r#" +allowed_permissions = ["managed-standard"] + +[permissions.managed-standard.filesystem] +":workspace_roots" = "read" +"#, + ) + .await?; + + let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.system_requirements_path = Some(requirements_path); + let config = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(cwd.to_path_buf())) + .loader_overrides(overrides) + .build() + .await?; + + assert_eq!( + config + .permissions + .active_permission_profile() + .map(|profile| profile.id), + Some(expected_profile.to_string()), + "trust level {trust_level:?}", + ); + } + Ok(()) +} + +#[tokio::test] +async fn system_allowed_permissions_keep_explicit_builtin_defaults() -> anyhow::Result<()> { + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + tokio::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#" +default_permissions = ":workspace" +"#, + ) + .await?; + let requirements_path = tmp.path().join("requirements.toml"); + tokio::fs::write( + &requirements_path, + r#" +allowed_permissions = ["managed-standard"] + +[permissions.managed-standard.filesystem] +":workspace_roots" = "read" +"#, + ) + .await?; + + let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.system_requirements_path = Some(requirements_path); + let config = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(cwd.to_path_buf())) + .loader_overrides(overrides) + .build() + .await?; + + assert_eq!( + config + .permissions + .active_permission_profile() + .map(|profile| profile.id), + Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()) + ); + Ok(()) +} + +#[tokio::test] +async fn system_requirements_preserve_allowed_configured_permission_default() -> anyhow::Result<()> +{ + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + tokio::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#" +default_permissions = "managed-build" +"#, + ) + .await?; + let requirements_path = tmp.path().join("requirements.toml"); + tokio::fs::write( + &requirements_path, + r#" +allowed_permissions = ["managed-standard", "managed-build"] + +[permissions.managed-standard] +extends = ":read-only" + +[permissions.managed-build] +extends = ":workspace" +"#, + ) + .await?; + + let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.system_requirements_path = Some(requirements_path); + let config = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(cwd.to_path_buf())) + .loader_overrides(overrides) + .build() + .await?; + + assert_eq!( + config + .permissions + .active_permission_profile() + .map(|profile| profile.id), + Some("managed-build".to_string()) + ); + Ok(()) +} + +#[tokio::test] +async fn system_requirements_warn_for_disallowed_explicit_permission_override() -> anyhow::Result<()> +{ + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + let requirements_path = tmp.path().join("requirements.toml"); + tokio::fs::write( + &requirements_path, + r#" +allowed_permissions = ["managed-standard"] + +[permissions.managed-standard] +extends = ":workspace" +"#, + ) + .await?; + + let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.system_requirements_path = Some(requirements_path); + let config = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(cwd.to_path_buf())) + .harness_overrides(ConfigOverrides { + default_permissions: Some("managed-build".to_string()), + ..ConfigOverrides::default() + }) + .loader_overrides(overrides) + .build() + .await?; + + assert_eq!( + config + .permissions + .active_permission_profile() + .map(|profile| profile.id), + Some("managed-standard".to_string()) + ); + assert!( + config.startup_warnings.iter().any(|warning| warning + .contains("Configured value for `permission_profile` is disallowed by requirements")), + "{:?}", + config.startup_warnings + ); + Ok(()) +} + #[tokio::test] async fn load_config_layers_can_ignore_managed_requirements() -> anyhow::Result<()> { let tmp = tempdir()?; diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index ccde74afe3..46f29b32a8 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -9091,6 +9091,7 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() allowed_approval_policies: None, allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: Some(vec![codex_config::WebSearchModeRequirement::Cached]), allow_managed_hooks_only: None, @@ -9875,6 +9876,7 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s allowed_approval_policies: None, allowed_approvals_reviewers: None, allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]), + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 9ff9434fbf..75bcf5aa3c 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -33,6 +33,7 @@ use codex_config::config_toml::ThreadStoreToml; use codex_config::config_toml::validate_model_providers; use codex_config::loader::load_config_layers_state; use codex_config::loader::project_trust_key; +use codex_config::permissions_toml::PermissionsToml; use codex_config::profile_toml::ConfigProfile; use codex_config::sandbox_mode_requirement_for_permission_profile; use codex_config::types::ApprovalsReviewer; @@ -1997,6 +1998,38 @@ struct PermissionSelectionToml { sandbox_mode: Option, } +// Resolve the named-profile catalog and selected profile id together. Runtime +// profile constraints are applied later after this selection compiles into a +// concrete `PermissionProfile`. +#[derive(Debug)] +struct EffectivePermissionSelection<'a> { + profiles: Option, + selected_profile_id: Option<&'a str>, + requirements_force_profile_selection: bool, +} + +impl EffectivePermissionSelection<'_> { + fn has_profiles(&self) -> bool { + self.profiles + .as_ref() + .is_some_and(|profiles| !profiles.is_empty()) + } + + fn profiles_are_active( + &self, + default_permissions_override: Option<&str>, + permission_config_syntax: Option, + ) -> bool { + self.requirements_force_profile_selection + || default_permissions_override.is_some() + || matches!( + permission_config_syntax, + Some(PermissionConfigSyntax::Profiles) + ) + || permission_config_syntax.is_none() + } +} + fn resolve_permission_config_syntax( config_layer_stack: &ConfigLayerStack, cfg: &ConfigToml, @@ -2621,20 +2654,21 @@ impl Config { sandbox_mode, config_profile.sandbox_mode, ); - let has_permission_profiles = cfg - .permissions - .as_ref() - .is_some_and(|profiles| !profiles.is_empty()); - let default_permissions = default_permissions_override - .as_deref() - .or(cfg.default_permissions.as_deref()); - validate_user_permission_profile_names(cfg.permissions.as_ref())?; - if has_permission_profiles + let requirements_toml = config_layer_stack.requirements_toml(); + let effective_permission_selection = resolve_effective_permission_selection( + cfg.permissions.as_ref(), + default_permissions_override.as_deref(), + cfg.default_permissions.as_deref(), + requirements_toml, + &mut startup_warnings, + )?; + if effective_permission_selection.has_profiles() && !matches!( permission_config_syntax, Some(PermissionConfigSyntax::Legacy) ) - && default_permissions.is_none() + && effective_permission_selection.selected_profile_id.is_none() + && !effective_permission_selection.requirements_force_profile_selection { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, @@ -2651,12 +2685,10 @@ impl Config { std::fs::create_dir_all(&memories_root)?; let internal_writable_roots = vec![memories_root]; - let profiles_are_active = default_permissions_override.is_some() - || matches!( - permission_config_syntax, - Some(PermissionConfigSyntax::Profiles) - ) - || permission_config_syntax.is_none(); + let profiles_are_active = effective_permission_selection.profiles_are_active( + default_permissions_override.as_deref(), + permission_config_syntax, + ); let explicit_permission_profile_mode = default_permissions_override.is_some() || matches!( permission_config_syntax, @@ -2668,9 +2700,11 @@ impl Config { .map_or_else(Vec::new, |permissions| { permissions.entries.keys().cloned().collect() }); - let using_implicit_builtin_profile = - permission_config_syntax.is_none() && default_permissions.is_none(); - let should_seed_legacy_workspace_roots = default_permissions.is_none() + let using_implicit_builtin_profile = permission_config_syntax.is_none() + && effective_permission_selection.selected_profile_id.is_none(); + let should_seed_legacy_workspace_roots = effective_permission_selection + .selected_profile_id + .is_none() && matches!( permission_config_syntax, None | Some(PermissionConfigSyntax::Legacy) @@ -2720,14 +2754,16 @@ impl Config { // PermissionProfile carries the active network sandbox bit, not the configured // proxy/allowlist policy. Keep that config so active profiles can round-trip // without broadening network behavior. - let default_permissions = default_permissions.unwrap_or_else(|| { - default_builtin_permission_profile_name( - &active_project, - windows_sandbox_level, - ) - }); + let default_permissions = effective_permission_selection + .selected_profile_id + .unwrap_or_else(|| { + default_builtin_permission_profile_name( + &active_project, + windows_sandbox_level, + ) + }); network_proxy_config_for_profile_selection( - cfg.permissions.as_ref(), + effective_permission_selection.profiles.as_ref(), default_permissions, )? } else { @@ -2765,28 +2801,30 @@ impl Config { Vec::new(), ) } else if profiles_are_active { - let default_permissions = default_permissions.unwrap_or_else(|| { - default_builtin_permission_profile_name(&active_project, windows_sandbox_level) - }); + let default_permissions = effective_permission_selection + .selected_profile_id + .unwrap_or_else(|| { + default_builtin_permission_profile_name(&active_project, windows_sandbox_level) + }); let builtin_workspace_write_settings = if using_implicit_builtin_profile { cfg.sandbox_workspace_write.as_ref() } else { None }; let configured_network_proxy_config = network_proxy_config_for_profile_selection( - cfg.permissions.as_ref(), + effective_permission_selection.profiles.as_ref(), default_permissions, )?; let (mut file_system_sandbox_policy, network_sandbox_policy) = compile_permission_profile_selection( - cfg.permissions.as_ref(), + effective_permission_selection.profiles.as_ref(), default_permissions, builtin_workspace_write_settings, resolved_cwd.as_path(), &mut startup_warnings, )?; let mut configured_workspace_roots = compile_permission_profile_workspace_roots( - cfg.permissions.as_ref(), + effective_permission_selection.profiles.as_ref(), default_permissions, resolved_cwd.as_path(), )?; @@ -3779,6 +3817,126 @@ fn guardian_policy_config_from_requirements( normalize_guardian_policy_config(requirements_toml.guardian_policy_config.as_deref()) } +fn merge_managed_permission_profiles( + configured_permissions: Option<&PermissionsToml>, + requirements_toml: &ConfigRequirementsToml, +) -> std::io::Result> { + let managed_profiles = requirements_toml + .permissions + .as_ref() + .map(|permissions| &permissions.profiles) + .filter(|profiles| !profiles.is_empty()); + let Some(managed_profiles) = managed_profiles else { + return Ok(configured_permissions.cloned()); + }; + + let mut merged_permissions = configured_permissions.cloned().unwrap_or_default(); + for (profile_id, managed_profile) in managed_profiles { + if merged_permissions.entries.contains_key(profile_id) { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + format!( + "requirements.toml permissions profile `{profile_id}` conflicts with a config-defined profile of the same name" + ), + )); + } + merged_permissions + .entries + .insert(profile_id.clone(), managed_profile.clone()); + } + + Ok(Some(merged_permissions)) +} + +fn resolve_effective_permission_selection<'a>( + configured_permissions: Option<&PermissionsToml>, + default_permissions_override: Option<&'a str>, + configured_default_permissions: Option<&'a str>, + requirements_toml: &'a ConfigRequirementsToml, + startup_warnings: &mut Vec, +) -> std::io::Result> { + let profiles = merge_managed_permission_profiles(configured_permissions, requirements_toml)?; + validate_user_permission_profile_names(profiles.as_ref())?; + validate_required_permission_profile_catalog(requirements_toml, profiles.as_ref())?; + let selected_profile_id = resolve_default_permissions( + default_permissions_override, + configured_default_permissions, + requirements_toml, + startup_warnings, + )?; + + Ok(EffectivePermissionSelection { + profiles, + selected_profile_id, + requirements_force_profile_selection: requirements_toml.allowed_permissions.is_some(), + }) +} + +fn resolve_default_permissions<'a>( + default_permissions_override: Option<&'a str>, + configured_default_permissions: Option<&'a str>, + requirements_toml: &'a ConfigRequirementsToml, + startup_warnings: &mut Vec, +) -> std::io::Result> { + let allowed_permissions = requirements_toml.allowed_permissions.as_ref(); + let mut default_permissions = default_permissions_override.or(configured_default_permissions); + if let (Some(selected_permissions), Some(allowed_permissions)) = + (default_permissions, allowed_permissions) + && !is_builtin_permission_profile_name(selected_permissions) + && !allowed_permissions + .iter() + .any(|allowed_permission| allowed_permission == selected_permissions) + { + let Some(fallback_permissions) = allowed_permissions.first().map(String::as_str) else { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + "requirements.toml allowed_permissions must include at least one profile", + )); + }; + startup_warnings.push(format!( + "Configured value for `permission_profile` is disallowed by requirements; falling back from `{selected_permissions}` to required value `{fallback_permissions}`." + )); + default_permissions = Some(fallback_permissions); + } + + Ok(default_permissions) +} + +fn validate_required_permission_profile_catalog( + requirements_toml: &ConfigRequirementsToml, + available_permissions: Option<&PermissionsToml>, +) -> std::io::Result<()> { + let is_known_profile = |profile_id: &str| { + is_builtin_permission_profile_name(profile_id) + || available_permissions + .as_ref() + .is_some_and(|permissions| permissions.entries.contains_key(profile_id)) + }; + + let Some(allowed_permissions) = requirements_toml.allowed_permissions.as_ref() else { + return Ok(()); + }; + if allowed_permissions.is_empty() { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + "requirements.toml allowed_permissions must include at least one profile", + )); + } + + for profile_id in allowed_permissions { + if !is_known_profile(profile_id) { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + format!( + "requirements.toml allowed_permissions refers to undefined profile `{profile_id}`" + ), + )); + } + } + + Ok(()) +} + fn normalize_guardian_policy_config(value: Option<&str>) -> Option { value.and_then(|value| { let trimmed = value.trim(); diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index 2075636e3d..e89b97ed54 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -697,6 +697,7 @@ mod tests { allowed_approval_policies: Some(vec![AskForApproval::OnRequest.to_core()]), allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]), allowed_sandbox_modes: Some(vec![SandboxModeRequirement::ReadOnly]), + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: Some(vec![WebSearchModeRequirement::Cached]), allow_managed_hooks_only: Some(true), @@ -912,6 +913,7 @@ approval_policy = "never" allowed_approval_policies: None, allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: Some(Vec::new()), allow_managed_hooks_only: None,