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
+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();