Files
codex/codex-rs/config/src/requirements_layers/stack.rs
T
viyatb-oaiandCodex 2f108f9fd9 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>
2026-06-05 18:06:29 -07:00

292 lines
9.8 KiB
Rust

//! Requirements layers are composed in the same order as config layers: lowest
//! precedence first, highest precedence last. Most fields use the same
//! TOML-level merge policy as config: lower-priority layers provide defaults,
//! and higher-priority layers override scalar/list values while recursively
//! extending tables.
//!
//! A few fields carry domain-specific meaning that raw TOML replacement would
//! break:
//! - `remote_sandbox_config` is evaluated within each layer before merging.
//! - `rules.prefix_rules` append high-priority rules first.
//! - `hooks` append high-priority event groups first while failing closed on
//! active managed-dir conflicts.
//! - `permissions.filesystem.deny_read` is a high-priority-first union across
//! layers.
use crate::ConfigRequirementsToml;
use crate::ConfigRequirementsWithSources;
use crate::RequirementSource;
use crate::Sourced;
use crate::merge::merge_toml_values;
use std::io;
use thiserror::Error;
use toml::Value as TomlValue;
use super::hooks::HookDirectoryField;
use super::hooks::HookMergeState;
use super::layer::ComposableRequirementsLayer;
use super::layer::RequirementsLayerEntry;
use super::permissions::DenyReadMergeState;
#[derive(Debug, Error, PartialEq, Eq)]
pub enum RequirementsCompositionError {
#[error("failed to parse requirements layer {layer_source}: {message}")]
Parse {
layer_source: RequirementSource,
message: String,
},
#[error("failed to parse merged requirements: {message}")]
ComposedParse { message: String },
#[error(
"failed to compose requirements field `{field}` between {existing_source} and {incoming_source}: {message}"
)]
Conflict {
field: String,
existing_source: RequirementSource,
incoming_source: RequirementSource,
message: String,
},
}
impl From<RequirementsCompositionError> for io::Error {
fn from(error: RequirementsCompositionError) -> Self {
io::Error::new(io::ErrorKind::InvalidData, error)
}
}
pub fn compose_requirements(
layers: impl IntoIterator<Item = RequirementsLayerEntry>,
) -> Result<Option<ConfigRequirementsWithSources>, RequirementsCompositionError> {
let hostname = crate::host_name();
compose_requirements_for_hostname(layers, hostname.as_deref())
}
pub(super) fn compose_requirements_for_hostname(
layers: impl IntoIterator<Item = RequirementsLayerEntry>,
hostname: Option<&str>,
) -> Result<Option<ConfigRequirementsWithSources>, RequirementsCompositionError> {
compose_requirements_for_hostname_and_hook_directory(
layers,
hostname,
HookDirectoryField::current_platform(),
)
}
pub(super) fn compose_requirements_for_hostname_and_hook_directory(
layers: impl IntoIterator<Item = RequirementsLayerEntry>,
hostname: Option<&str>,
hook_directory_field: HookDirectoryField,
) -> Result<Option<ConfigRequirementsWithSources>, RequirementsCompositionError> {
let mut stack = RequirementsLayerStack::new(hook_directory_field);
for layer in layers {
stack.add_layer(layer, hostname)?;
}
stack.compose()
}
struct RequirementsLayerStack {
layers: Vec<ComposableRequirementsLayer>,
hook_directory_field: HookDirectoryField,
}
impl RequirementsLayerStack {
fn new(hook_directory_field: HookDirectoryField) -> Self {
Self {
layers: Vec::new(),
hook_directory_field,
}
}
fn add_layer(
&mut self,
layer: RequirementsLayerEntry,
hostname: Option<&str>,
) -> Result<(), RequirementsCompositionError> {
self.layers
.push(ComposableRequirementsLayer::from_entry(layer, hostname)?);
Ok(())
}
fn compose(
self,
) -> Result<Option<ConfigRequirementsWithSources>, RequirementsCompositionError> {
let Self {
layers,
hook_directory_field,
} = self;
let mut merged_toml = TomlValue::Table(toml::map::Map::new());
for layer in &layers {
merge_toml_values(&mut merged_toml, &layer.regular_toml);
}
let requirements: ConfigRequirementsToml =
merged_toml.try_into().map_err(|err: toml::de::Error| {
RequirementsCompositionError::ComposedParse {
message: err.to_string(),
}
})?;
let mut output = ConfigRequirementsWithSources::default();
populate_merged_regular_fields_with_sources(&mut output, requirements, &layers);
let mut rules = None;
let mut hooks = HookMergeState::new(hook_directory_field);
let mut hooks_output = None;
let mut deny_read = DenyReadMergeState::default();
// Regular TOML fields are folded low-to-high like config. These custom
// fields append or union values, so process them high-to-low to keep
// priority order visible in the output.
for layer in layers.iter().rev() {
let domain_fields = &layer.domain_fields;
super::rules::merge(&mut rules, domain_fields.rules.clone(), &layer.source);
hooks.merge(
&mut hooks_output,
domain_fields.hooks.clone(),
&layer.source,
)?;
deny_read.merge(domain_fields.permissions.clone(), &layer.source);
}
output.rules = rules;
output.hooks = hooks_output;
deny_read.apply_to(&mut output.permissions);
let output_is_empty = output.clone().into_toml().is_empty();
Ok((!output_is_empty).then_some(output))
}
}
fn populate_merged_regular_fields_with_sources(
output: &mut ConfigRequirementsWithSources,
requirements: ConfigRequirementsToml,
layers: &[ComposableRequirementsLayer],
) {
macro_rules! set_sourced {
($field:ident, $keys:expr) => {
if let Some(value) = $field {
output.$field = Some(Sourced::new(
value,
source_for_top_level_keys(layers, $keys),
));
}
};
}
// Destructure without `..` so every new requirements field must choose
// whether it belongs in the regular TOML merge path or in a special merger.
let ConfigRequirementsToml {
allowed_approval_policies,
allowed_approvals_reviewers,
allowed_sandbox_modes,
allowed_permission_profiles,
default_permissions,
remote_sandbox_config: _,
allowed_web_search_modes,
allow_managed_hooks_only,
allow_appshots,
computer_use,
windows,
feature_requirements,
hooks: _,
mcp_servers,
plugins,
apps,
rules: _,
enforce_residency,
network,
permissions,
guardian_policy_config,
} = requirements;
set_sourced!(allowed_approval_policies, &["allowed_approval_policies"]);
set_sourced!(
allowed_approvals_reviewers,
&["allowed_approvals_reviewers"]
);
set_sourced!(allowed_sandbox_modes, &["allowed_sandbox_modes"]);
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"]);
set_sourced!(computer_use, &["computer_use"]);
set_sourced!(windows, &["windows"]);
set_sourced!(feature_requirements, &["features", "feature_requirements"]);
set_sourced!(mcp_servers, &["mcp_servers"]);
set_sourced!(plugins, &["plugins"]);
set_sourced!(apps, &["apps"]);
set_sourced!(enforce_residency, &["enforce_residency"]);
set_sourced!(network, &["experimental_network"]);
set_sourced!(permissions, &["permissions"]);
if let Some(guardian_policy_config) =
guardian_policy_config.filter(|value| !value.trim().is_empty())
{
output.guardian_policy_config = Some(Sourced::new(
guardian_policy_config,
source_for_top_level_keys(layers, &["guardian_policy_config"]),
));
}
}
fn source_for_top_level_keys(
layers: &[ComposableRequirementsLayer],
keys: &[&str],
) -> RequirementSource {
let matching_layers = layers
.iter()
.filter_map(|layer| {
top_level_value_for_keys(&layer.regular_toml, keys).map(|value| (&layer.source, value))
})
.collect::<Vec<_>>();
let Some((winning_source, winning_value)) = matching_layers.last() else {
return RequirementSource::Unknown;
};
let winning_source = (*winning_source).clone();
if !winning_value.is_table() {
return winning_source;
}
let table_sources = matching_layers
.into_iter()
.rev()
.filter_map(|(source, value)| value.is_table().then_some(source.clone()))
.collect::<Vec<_>>();
if table_sources.len() > 1 {
RequirementSource::composite(table_sources)
} else {
winning_source
}
}
fn top_level_value_for_keys<'a>(value: &'a TomlValue, keys: &[&str]) -> Option<&'a TomlValue> {
let table = value.as_table()?;
keys.iter().find_map(|key| table.get(*key))
}
pub(super) fn merge_output_source(existing: &mut RequirementSource, incoming: &RequirementSource) {
if existing != incoming {
*existing = RequirementSource::composite([existing.clone(), incoming.clone()]);
}
}
pub(super) fn composition_conflict(
field: String,
existing_source: RequirementSource,
incoming_source: RequirementSource,
message: impl Into<String>,
) -> RequirementsCompositionError {
RequirementsCompositionError::Conflict {
field,
existing_source,
incoming_source,
message: message.into(),
}
}
#[cfg(test)]
#[path = "stack_tests.rs"]
mod tests;