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
co-authored by Codex
parent 954e2878bb
commit 2f108f9fd9
15 changed files with 468 additions and 119 deletions
+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();