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
@@ -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 {