permissions: enforce managed permission profile allowlists (#24852)

## Why

Permission profile allowlists are an enterprise security boundary, but
they also need to compose across the managed requirements layers added
in #24620.

A map representation lets each requirements layer add, allow, or revoke
individual profiles without replacing an entire array.

## Managed Contract

Administrators configure the mergeable allow map with
`allowed_permission_profiles`. A recommended enterprise configuration
explicitly lists every built-in and custom profile users should be able
to select:

```toml
default_permissions = "review_only"

[allowed_permission_profiles]
":read-only" = true
":workspace" = true
review_only = true
# ":danger-full-access" is intentionally omitted, so it is denied.

[permissions.review_only]
extends = ":read-only"
```

- Profiles whose effective merged value is `true` are allowed.
- Missing profiles and profiles set to `false` are denied.
- This is a closed allowlist: built-in profiles and profiles introduced
in future versions are denied unless explicitly allowed.
- Explicitly list each built-in profile the enterprise wants to make
available. Omit built-ins such as `:danger-full-access` when they should
remain unavailable.
- Set `default_permissions` explicitly to the allowed profile users
should receive when they have no local selection.
- Higher-precedence layers override only the profile keys they define.
- `false` is only needed when a higher-precedence layer must revoke a
`true` inherited from a lower layer.
- Explicit keys must refer to known built-in or managed profiles.

A custom or narrowed allowlist requires an allowed
`default_permissions`. For compatibility, if both `:workspace` and
`:read-only` are explicitly allowed, an omitted default resolves to
`:workspace`; customer configurations should still set the intended
default explicitly.

When `allowed_permission_profiles` is absent, existing implicit
permission and legacy `sandbox_mode` behavior is unchanged.

## What Changed

- Add `allowed_permission_profiles` as a `BTreeMap<String, bool>` that
merges per profile across requirements layers.
- Enforce managed defaults, strict denial of omitted profiles, and the
explicitly allowed standard-pair fallback.
- Expose `allowedPermissionProfiles` through `configRequirements/read`
and regenerate its schemas.
- Add regression coverage for map composition and revocation, managed
defaults, strict denial of omitted built-ins, and API output.

## Verification

- Focused `codex-config` coverage for layered map composition and
revocation
- Focused `codex-core` coverage for managed defaults, invalid defaults,
strict denial of omitted built-ins, and the standard built-in pair
- Focused `codex-app-server` coverage for requirements API output
- Scoped Clippy for `codex-config`, `codex-core`,
`codex-app-server-protocol`, and `codex-app-server`

## Documentation

The managed `requirements.toml` documentation should introduce
`allowed_permission_profiles` as a closed permission-profile allowlist
before this setting is published on developers.openai.com.

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
viyatb-oai
2026-06-05 18:06:29 -07:00
committed by GitHub
Unverified
parent 954e2878bb
commit 2f108f9fd9
15 changed files with 468 additions and 119 deletions
@@ -8020,12 +8020,12 @@
"null"
]
},
"allowedPermissions": {
"items": {
"type": "string"
"allowedPermissionProfiles": {
"additionalProperties": {
"type": "boolean"
},
"type": [
"array",
"object",
"null"
]
},
@@ -8066,6 +8066,12 @@
}
]
},
"defaultPermissions": {
"type": [
"string",
"null"
]
},
"enforceResidency": {
"anyOf": [
{
@@ -4362,12 +4362,12 @@
"null"
]
},
"allowedPermissions": {
"items": {
"type": "string"
"allowedPermissionProfiles": {
"additionalProperties": {
"type": "boolean"
},
"type": [
"array",
"object",
"null"
]
},
@@ -4408,6 +4408,12 @@
}
]
},
"defaultPermissions": {
"type": [
"string",
"null"
]
},
"enforceResidency": {
"anyOf": [
{
@@ -94,12 +94,12 @@
"null"
]
},
"allowedPermissions": {
"items": {
"type": "string"
"allowedPermissionProfiles": {
"additionalProperties": {
"type": "boolean"
},
"type": [
"array",
"object",
"null"
]
},
@@ -140,6 +140,12 @@
}
]
},
"defaultPermissions": {
"type": [
"string",
"null"
]
},
"enforceResidency": {
"anyOf": [
{
@@ -8,4 +8,4 @@ import type { ResidencyRequirement } from "./ResidencyRequirement";
import type { SandboxMode } from "./SandboxMode";
import type { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode";
export type ConfigRequirements = {allowedApprovalPolicies: Array<AskForApproval> | null, allowedSandboxModes: Array<SandboxMode> | null, allowedWindowsSandboxImplementations: Array<WindowsSandboxSetupMode> | null, allowedPermissions: Array<string> | null, allowedWebSearchModes: Array<WebSearchMode> | null, allowManagedHooksOnly: boolean | null, allowAppshots: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null};
export type ConfigRequirements = {allowedApprovalPolicies: Array<AskForApproval> | null, allowedSandboxModes: Array<SandboxMode> | null, allowedWindowsSandboxImplementations: Array<WindowsSandboxSetupMode> | null, allowedPermissionProfiles: { [key in string]?: boolean } | null, defaultPermissions: string | null, allowedWebSearchModes: Array<WebSearchMode> | null, allowManagedHooksOnly: boolean | null, allowAppshots: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null};
@@ -375,7 +375,8 @@ pub struct ConfigRequirements {
pub allowed_approvals_reviewers: Option<Vec<ApprovalsReviewer>>,
pub allowed_sandbox_modes: Option<Vec<SandboxMode>>,
pub allowed_windows_sandbox_implementations: Option<Vec<WindowsSandboxSetupMode>>,
pub allowed_permissions: Option<Vec<String>>,
pub allowed_permission_profiles: Option<BTreeMap<String, bool>>,
pub default_permissions: Option<String>,
pub allowed_web_search_modes: Option<Vec<WebSearchMode>>,
pub allow_managed_hooks_only: Option<bool>,
pub allow_appshots: Option<bool>,
@@ -1674,7 +1674,8 @@ fn config_requirements_granular_allowed_approval_policy_is_marked_experimental()
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_windows_sandbox_implementations: None,
allowed_permissions: None,
allowed_permission_profiles: None,
default_permissions: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
allow_appshots: None,
+1 -1
View File
@@ -231,7 +231,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`, `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`.
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), 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
@@ -350,7 +350,8 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR
.collect()
})
}),
allowed_permissions: requirements.allowed_permissions,
allowed_permission_profiles: requirements.allowed_permission_profiles,
default_permissions: requirements.default_permissions,
allowed_web_search_modes: requirements.allowed_web_search_modes.map(|modes| {
let mut normalized = modes
.into_iter()
@@ -569,29 +570,43 @@ mod tests {
use codex_config::ConfigRequirementsToml;
use codex_config::WindowsRequirementsToml;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
#[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);
}
#[test]
fn requirements_api_includes_permission_default_and_allowlist() {
let mapped = map_requirements_toml_to_api(ConfigRequirementsToml {
allowed_permission_profiles: Some(BTreeMap::from([
("managed-build".to_string(), false),
("managed-standard".to_string(), true),
])),
default_permissions: Some("managed-standard".to_string()),
..ConfigRequirementsToml::default()
});
assert_eq!(
mapped.allowed_permission_profiles,
Some(BTreeMap::from([
("managed-build".to_string(), false),
("managed-standard".to_string(), true),
]))
);
assert_eq!(
mapped.default_permissions,
Some("managed-standard".to_string())
);
}
#[test]
fn requirements_api_includes_allow_appshots() {
let mapped = map_requirements_toml_to_api(ConfigRequirementsToml {
+44 -21
View File
@@ -822,7 +822,8 @@ pub struct ConfigRequirementsToml {
pub allowed_approval_policies: Option<Vec<AskForApproval>>,
pub allowed_approvals_reviewers: Option<Vec<ApprovalsReviewer>>,
pub allowed_sandbox_modes: Option<Vec<SandboxModeRequirement>>,
pub allowed_permissions: Option<Vec<String>>,
pub allowed_permission_profiles: Option<BTreeMap<String, bool>>,
pub default_permissions: Option<String>,
pub remote_sandbox_config: Option<Vec<RemoteSandboxConfigToml>>,
pub allowed_web_search_modes: Option<Vec<WebSearchModeRequirement>>,
pub allow_managed_hooks_only: Option<bool>,
@@ -876,7 +877,8 @@ pub struct ConfigRequirementsWithSources {
pub allowed_approval_policies: Option<Sourced<Vec<AskForApproval>>>,
pub allowed_approvals_reviewers: Option<Sourced<Vec<ApprovalsReviewer>>>,
pub allowed_sandbox_modes: Option<Sourced<Vec<SandboxModeRequirement>>>,
pub allowed_permissions: Option<Sourced<Vec<String>>>,
pub allowed_permission_profiles: Option<Sourced<BTreeMap<String, bool>>>,
pub default_permissions: Option<Sourced<String>>,
pub allowed_web_search_modes: Option<Sourced<Vec<WebSearchModeRequirement>>>,
pub allow_managed_hooks_only: Option<Sourced<bool>>,
pub allow_appshots: Option<Sourced<bool>>,
@@ -916,7 +918,8 @@ impl ConfigRequirementsWithSources {
allowed_approval_policies: _,
allowed_approvals_reviewers: _,
allowed_sandbox_modes: _,
allowed_permissions: _,
allowed_permission_profiles: _,
default_permissions: _,
remote_sandbox_config: _,
allowed_web_search_modes: _,
allow_managed_hooks_only: _,
@@ -951,7 +954,8 @@ impl ConfigRequirementsWithSources {
allowed_approval_policies,
allowed_approvals_reviewers,
allowed_sandbox_modes,
allowed_permissions,
allowed_permission_profiles,
default_permissions,
allowed_web_search_modes,
allow_managed_hooks_only,
allow_appshots,
@@ -983,7 +987,8 @@ impl ConfigRequirementsWithSources {
allowed_approval_policies,
allowed_approvals_reviewers,
allowed_sandbox_modes,
allowed_permissions,
allowed_permission_profiles,
default_permissions,
allowed_web_search_modes,
allow_managed_hooks_only,
allow_appshots,
@@ -1004,7 +1009,8 @@ 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),
allowed_permission_profiles: allowed_permission_profiles.map(|sourced| sourced.value),
default_permissions: default_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),
@@ -1092,7 +1098,8 @@ 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.allowed_permission_profiles.is_none()
&& self.default_permissions.is_none()
&& self.remote_sandbox_config.is_none()
&& self.allowed_web_search_modes.is_none()
&& self.allow_managed_hooks_only.is_none()
@@ -1144,7 +1151,8 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
allowed_approval_policies,
allowed_approvals_reviewers,
allowed_sandbox_modes,
allowed_permissions: _,
allowed_permission_profiles: _,
default_permissions: _,
allowed_web_search_modes,
allow_managed_hooks_only,
allow_appshots,
@@ -1511,7 +1519,8 @@ mod tests {
allowed_approval_policies,
allowed_approvals_reviewers,
allowed_sandbox_modes,
allowed_permissions,
allowed_permission_profiles,
default_permissions,
remote_sandbox_config: _,
allowed_web_search_modes,
allow_managed_hooks_only,
@@ -1536,7 +1545,9 @@ 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
allowed_permission_profiles: allowed_permission_profiles
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
default_permissions: default_permissions
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
allowed_web_search_modes: allowed_web_search_modes
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
@@ -1592,7 +1603,11 @@ mod tests {
fn deserialize_managed_permission_profiles() -> Result<()> {
let requirements: ConfigRequirementsToml = from_str(
r#"
allowed_permissions = ["managed-standard", "managed-build"]
default_permissions = "managed-standard"
[allowed_permission_profiles]
managed-standard = true
managed-build = true
[permissions.managed-standard]
extends = ":workspace"
@@ -1603,11 +1618,15 @@ mod tests {
)?;
assert_eq!(
requirements.allowed_permissions,
Some(vec![
"managed-standard".to_string(),
"managed-build".to_string(),
])
requirements.allowed_permission_profiles,
Some(BTreeMap::from([
("managed-build".to_string(), true),
("managed-standard".to_string(), true),
]))
);
assert_eq!(
requirements.default_permissions,
Some("managed-standard".to_string())
);
let permissions = requirements
.permissions
@@ -1720,7 +1739,8 @@ 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()]),
allowed_permission_profiles: Some(BTreeMap::from([("managed".to_string(), true)])),
default_permissions: Some("managed".to_string()),
remote_sandbox_config: None,
allowed_web_search_modes: Some(allowed_web_search_modes.clone()),
allow_managed_hooks_only: Some(true),
@@ -1753,10 +1773,11 @@ mod tests {
source.clone(),
)),
allowed_sandbox_modes: Some(Sourced::new(allowed_sandbox_modes, source.clone(),)),
allowed_permissions: Some(Sourced::new(
vec!["managed".to_string()],
allowed_permission_profiles: Some(Sourced::new(
BTreeMap::from([("managed".to_string(), true)]),
source.clone(),
)),
default_permissions: Some(Sourced::new("managed".to_string(), source.clone(),)),
allowed_web_search_modes: Some(Sourced::new(
allowed_web_search_modes,
enforce_source.clone(),
@@ -1809,7 +1830,8 @@ mod tests {
)),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
allowed_permission_profiles: None,
default_permissions: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
allow_appshots: None,
@@ -1861,7 +1883,8 @@ mod tests {
)),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
allowed_permission_profiles: None,
default_permissions: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
allow_appshots: None,
@@ -176,7 +176,8 @@ fn populate_merged_regular_fields_with_sources(
allowed_approval_policies,
allowed_approvals_reviewers,
allowed_sandbox_modes,
allowed_permissions,
allowed_permission_profiles,
default_permissions,
remote_sandbox_config: _,
allowed_web_search_modes,
allow_managed_hooks_only,
@@ -201,7 +202,11 @@ fn populate_merged_regular_fields_with_sources(
&["allowed_approvals_reviewers"]
);
set_sourced!(allowed_sandbox_modes, &["allowed_sandbox_modes"]);
set_sourced!(allowed_permissions, &["allowed_permissions"]);
set_sourced!(
allowed_permission_profiles,
&["allowed_permission_profiles"]
);
set_sourced!(default_permissions, &["default_permissions"]);
set_sourced!(allowed_web_search_modes, &["allowed_web_search_modes"]);
set_sourced!(allow_managed_hooks_only, &["allow_managed_hooks_only"]);
set_sourced!(allow_appshots, &["allow_appshots"]);
@@ -62,6 +62,11 @@ fn top_level_values_use_toml_priority() {
r#"
allowed_approval_policies = ["on-request"]
allowed_sandbox_modes = ["workspace-write"]
default_permissions = ":workspace"
[allowed_permission_profiles]
":read-only" = true
":workspace" = true
"#,
),
layer(
@@ -70,6 +75,11 @@ allowed_sandbox_modes = ["workspace-write"]
r#"
allowed_approval_policies = ["never"]
allowed_sandbox_modes = ["read-only"]
default_permissions = ":read-only"
[allowed_permission_profiles]
":danger-full-access" = false
":workspace" = false
"#,
),
])
@@ -82,6 +92,12 @@ allowed_sandbox_modes = ["read-only"]
r#"
allowed_approval_policies = ["never"]
allowed_sandbox_modes = ["read-only"]
default_permissions = ":read-only"
[allowed_permission_profiles]
":danger-full-access" = false
":read-only" = true
":workspace" = false
"#
)
);
+253 -34
View File
@@ -31,7 +31,7 @@ use codex_config::test_support::CloudConfigBundleFixture;
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_DANGER_FULL_ACCESS;
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
@@ -1375,7 +1375,10 @@ default_permissions = "managed-standard"
tokio::fs::write(
&requirements_path,
r#"
allowed_permissions = ["managed-standard"]
default_permissions = "managed-standard"
[allowed_permission_profiles]
managed-standard = true
[permissions.managed-standard]
extends = ":workspace"
@@ -1397,8 +1400,8 @@ extends = ":workspace"
config
.config_layer_stack
.requirements_toml()
.allowed_permissions,
Some(vec!["managed-standard".to_string()])
.allowed_permission_profiles,
Some(BTreeMap::from([("managed-standard".to_string(), true)]))
);
assert_eq!(
config
@@ -1411,26 +1414,9 @@ extends = ":workspace"
}
#[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),
] {
async fn system_allowed_permission_profiles_select_managed_default_without_local_default()
-> anyhow::Result<()> {
for trust_level in [Some(TrustLevel::Trusted), Some(TrustLevel::Untrusted), None] {
let tmp = tempdir()?;
let codex_home = tmp.path().join("home");
tokio::fs::create_dir_all(&codex_home).await?;
@@ -1447,10 +1433,17 @@ async fn system_allowed_permissions_keep_builtin_permission_fallbacks() -> anyho
tokio::fs::write(
&requirements_path,
r#"
allowed_permissions = ["managed-standard"]
default_permissions = "managed-standard"
[allowed_permission_profiles]
managed-build = true
managed-standard = true
[permissions.managed-standard.filesystem]
":workspace_roots" = "read"
[permissions.managed-build]
extends = ":workspace"
"#,
)
.await?;
@@ -1470,30 +1463,189 @@ allowed_permissions = ["managed-standard"]
.permissions
.active_permission_profile()
.map(|profile| profile.id),
Some(expected_profile.to_string()),
Some("managed-standard".to_string()),
"trust level {trust_level:?}",
);
assert!(
!config.startup_warnings.iter().any(|warning| warning
.contains("Configured value for `permission_profile` is disallowed")),
"{:?}",
config.startup_warnings
);
}
Ok(())
}
#[tokio::test]
async fn system_allowed_permissions_keep_explicit_builtin_defaults() -> anyhow::Result<()> {
async fn system_allowed_permission_profiles_require_managed_default() -> 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#"
[permissions.managed-standard]
extends = ":read-only"
[allowed_permission_profiles]
managed-standard = true
"#,
)
.await?;
let mut overrides = LoaderOverrides::without_managed_config_for_tests();
overrides.system_requirements_path = Some(requirements_path);
let err = ConfigBuilder::default()
.codex_home(codex_home)
.fallback_cwd(Some(tmp.path().to_path_buf()))
.loader_overrides(overrides)
.build()
.await
.expect_err("allowed_permission_profiles without default_permissions should fail");
assert!(
err.to_string().contains(
"default_permissions must be set unless allowed_permission_profiles allows both"
),
"{err}"
);
Ok(())
}
#[tokio::test]
async fn system_allowed_permission_profiles_standard_pair_defaults_to_workspace()
-> 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_permission_profiles]
":read-only" = true
":workspace" = true
"#,
)
.await?;
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(tmp.path().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_managed_default_must_be_allowed() -> 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#"
default_permissions = "managed-build"
[allowed_permission_profiles]
managed-standard = true
[permissions.managed-standard]
extends = ":read-only"
[permissions.managed-build]
extends = ":workspace"
"#,
)
.await?;
let mut overrides = LoaderOverrides::without_managed_config_for_tests();
overrides.system_requirements_path = Some(requirements_path);
let err = ConfigBuilder::default()
.codex_home(codex_home)
.fallback_cwd(Some(tmp.path().to_path_buf()))
.loader_overrides(overrides)
.build()
.await
.expect_err("managed default outside allowed_permission_profiles should fail");
assert!(
err.to_string().contains(
"default_permissions `managed-build` must be allowed by allowed_permission_profiles"
),
"{err}"
);
Ok(())
}
#[tokio::test]
async fn system_managed_default_requires_allowed_permission_profiles() -> 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#"
default_permissions = ":read-only"
"#,
)
.await?;
let mut overrides = LoaderOverrides::without_managed_config_for_tests();
overrides.system_requirements_path = Some(requirements_path);
let err = ConfigBuilder::default()
.codex_home(codex_home)
.fallback_cwd(Some(tmp.path().to_path_buf()))
.loader_overrides(overrides)
.build()
.await
.expect_err("managed default without allowed_permission_profiles should fail");
assert!(
err.to_string()
.contains("default_permissions requires allowed_permission_profiles"),
"{err}"
);
Ok(())
}
#[tokio::test]
async fn system_allowed_permission_profiles_fall_back_from_disallowed_danger_full_access()
-> 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"
"#,
format!(
r#"
default_permissions = "{BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS}"
"#
),
)
.await?;
let requirements_path = tmp.path().join("requirements.toml");
tokio::fs::write(
&requirements_path,
r#"
allowed_permissions = ["managed-standard"]
default_permissions = "managed-standard"
[allowed_permission_profiles]
managed-standard = true
[permissions.managed-standard.filesystem]
":workspace_roots" = "read"
@@ -1516,7 +1668,67 @@ allowed_permissions = ["managed-standard"]
.permissions
.active_permission_profile()
.map(|profile| profile.id),
Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string())
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 system_allowed_permission_profiles_fall_back_from_disallowed_workspace()
-> 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#"
default_permissions = "managed-standard"
[allowed_permission_profiles]
managed-standard = true
[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("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(())
}
@@ -1538,7 +1750,11 @@ default_permissions = "managed-build"
tokio::fs::write(
&requirements_path,
r#"
allowed_permissions = ["managed-standard", "managed-build"]
default_permissions = "managed-standard"
[allowed_permission_profiles]
managed-build = true
managed-standard = true
[permissions.managed-standard]
extends = ":read-only"
@@ -1579,7 +1795,10 @@ async fn system_requirements_warn_for_disallowed_explicit_permission_override()
tokio::fs::write(
&requirements_path,
r#"
allowed_permissions = ["managed-standard"]
default_permissions = "managed-standard"
[allowed_permission_profiles]
managed-standard = true
[permissions.managed-standard]
extends = ":workspace"
+2 -1
View File
@@ -8351,7 +8351,8 @@ 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,
allowed_permission_profiles: None,
default_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: Some(vec![codex_config::WebSearchModeRequirement::Cached]),
allow_managed_hooks_only: None,
+79 -31
View File
@@ -119,6 +119,7 @@ use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use crate::config::permissions::BUILT_IN_READ_ONLY_PROFILE;
use crate::config::permissions::BUILT_IN_WORKSPACE_PROFILE;
use crate::config::permissions::apply_network_proxy_feature_config;
use crate::config::permissions::builtin_permission_profile;
@@ -3835,7 +3836,9 @@ fn resolve_effective_permission_selection<'a>(
Ok(EffectivePermissionSelection {
profiles,
selected_profile_id,
requirements_force_profile_selection: requirements_toml.allowed_permissions.is_some(),
requirements_force_profile_selection: requirements_toml
.allowed_permission_profiles
.is_some(),
})
}
@@ -3845,28 +3848,36 @@ fn resolve_default_permissions<'a>(
requirements_toml: &'a ConfigRequirementsToml,
startup_warnings: &mut Vec<String>,
) -> std::io::Result<Option<&'a str>> {
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}`."
let selected_permissions = default_permissions_override.or(configured_default_permissions);
let Some(allowed_permission_profiles) = requirements_toml.allowed_permission_profiles.as_ref()
else {
return Ok(selected_permissions);
};
let Some(fallback_permissions) = requirements_toml
.default_permissions
.as_deref()
.or_else(|| implicit_default_permissions(allowed_permission_profiles))
else {
return Err(std::io::Error::new(
ErrorKind::InvalidInput,
"requirements.toml default_permissions must be set unless allowed_permission_profiles allows both `:workspace` and `:read-only`",
));
default_permissions = Some(fallback_permissions);
}
};
Ok(default_permissions)
match selected_permissions {
None => Ok(Some(fallback_permissions)),
Some(selected_permissions)
if is_permission_allowed(allowed_permission_profiles, selected_permissions) =>
{
Ok(Some(selected_permissions))
}
Some(selected_permissions) => {
startup_warnings.push(format!(
"Configured value for `permission_profile` is disallowed by requirements; falling back from `{selected_permissions}` to required value `{fallback_permissions}`."
));
Ok(Some(fallback_permissions))
}
}
}
fn validate_required_permission_profile_catalog(
@@ -3880,30 +3891,67 @@ fn validate_required_permission_profile_catalog(
.is_some_and(|permissions| permissions.entries.contains_key(profile_id))
};
let Some(allowed_permissions) = requirements_toml.allowed_permissions.as_ref() else {
let Some(allowed_permission_profiles) = requirements_toml.allowed_permission_profiles.as_ref()
else {
if requirements_toml.default_permissions.is_some() {
return Err(std::io::Error::new(
ErrorKind::InvalidInput,
"requirements.toml default_permissions requires allowed_permission_profiles",
));
}
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 {
for profile_id in allowed_permission_profiles.keys() {
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}`"
"requirements.toml allowed_permission_profiles refers to undefined profile `{profile_id}`"
),
));
}
}
let Some(default_permissions) = requirements_toml
.default_permissions
.as_deref()
.or_else(|| implicit_default_permissions(allowed_permission_profiles))
else {
return Err(std::io::Error::new(
ErrorKind::InvalidInput,
"requirements.toml default_permissions must be set unless allowed_permission_profiles allows both `:workspace` and `:read-only`",
));
};
if !is_permission_allowed(allowed_permission_profiles, default_permissions) {
return Err(std::io::Error::new(
ErrorKind::InvalidInput,
format!(
"requirements.toml default_permissions `{default_permissions}` must be allowed by allowed_permission_profiles"
),
));
}
Ok(())
}
fn implicit_default_permissions(
allowed_permission_profiles: &BTreeMap<String, bool>,
) -> Option<&'static str> {
(is_permission_allowed(allowed_permission_profiles, BUILT_IN_WORKSPACE_PROFILE)
&& is_permission_allowed(allowed_permission_profiles, BUILT_IN_READ_ONLY_PROFILE))
.then_some(BUILT_IN_WORKSPACE_PROFILE)
}
fn is_permission_allowed(
allowed_permission_profiles: &BTreeMap<String, bool>,
profile_id: &str,
) -> bool {
allowed_permission_profiles
.get(profile_id)
.copied()
.unwrap_or(false)
}
fn normalize_guardian_policy_config(value: Option<&str>) -> Option<String> {
value.and_then(|value| {
let trimmed = value.trim();
+4 -2
View File
@@ -702,7 +702,8 @@ 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,
allowed_permission_profiles: None,
default_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: Some(vec![WebSearchModeRequirement::Cached]),
allow_managed_hooks_only: Some(true),
@@ -973,7 +974,8 @@ approval_policy = "never"
allowed_approval_policies: None,
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
allowed_permission_profiles: None,
default_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: Some(Vec::new()),
allow_managed_hooks_only: None,