Compose requirements layers (#24619)

## Summary

PR 2 of 5 in the cloud-managed config client stack.

Adds a shared requirements-layer composition engine. The composer
defines how ordered requirements layers combine, with focused tests for
the merge semantics and provenance behavior. The final PR in the stack
wires runtime requirements sources into this path.

## Details

- Mental model: requirements layers are ordered lowest priority first,
matching `ConfigLayerStack`; lower-priority layers provide defaults
while higher-priority layers win scalar/list conflicts.
- Regular fields use config-style TOML merging, including recursive
table merging, so requirements layering follows the same broad model as
`config.toml` layering.
- Domain-specific fields keep explicit semantics: `rules.prefix_rules`
and hooks preserve high-priority-first output, hooks fail closed on
active managed-dir conflicts, and `permissions.filesystem.deny_read`
dedupes as a stable high-priority-first union.
- `remote_sandbox_config` is evaluated within each layer before the
regular TOML merge, so host-specific sandbox constraints do not leak
across layers.
- Provenance points at the exact source when one layer owns a value and
uses composite provenance when a table field is assembled from multiple
layers.

## Validation

Local validation:

- `just fmt`
- `cargo check -p codex-config`
- `just test -p codex-config requirements_composition`
- `git diff --check`

CI will run the broader test matrix.
This commit is contained in:
joeflorencio-openai
2026-05-31 15:14:06 -07:00
committed by GitHub
parent 5f60b01352
commit 20debf746b
10 changed files with 1932 additions and 6 deletions

View File

@@ -25,13 +25,61 @@ use crate::types::WindowsSandboxModeToml;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RequirementSource {
Unknown,
MdmManagedPreferences { domain: String, key: String },
MdmManagedPreferences {
domain: String,
key: String,
},
CloudRequirements,
SystemRequirementsToml { file: AbsolutePathBuf },
LegacyManagedConfigTomlFromFile { file: AbsolutePathBuf },
/// Multiple requirements layers contributed to the final value. Sources are
/// stored highest-priority first, matching the order surfaced in errors.
Composite {
sources: Vec<RequirementSource>,
},
/// A backend-delivered enterprise-managed layer. `id` is the stable backend
/// identifier; `name` is the admin-facing display name.
EnterpriseManaged {
id: String,
name: String,
},
SystemRequirementsToml {
file: AbsolutePathBuf,
},
LegacyManagedConfigTomlFromFile {
file: AbsolutePathBuf,
},
LegacyManagedConfigTomlFromMdm,
}
impl RequirementSource {
pub fn composite(sources: impl IntoIterator<Item = RequirementSource>) -> Self {
let mut flattened = Vec::new();
for source in sources {
source.append_to_composite(&mut flattened);
}
match flattened.len() {
0 => RequirementSource::Unknown,
1 => flattened.remove(0),
_ => RequirementSource::Composite { sources: flattened },
}
}
fn append_to_composite(self, flattened: &mut Vec<RequirementSource>) {
match self {
RequirementSource::Composite { sources } => {
for source in sources {
source.append_to_composite(flattened);
}
}
source => {
if !flattened.contains(&source) {
flattened.push(source);
}
}
}
}
}
impl fmt::Display for RequirementSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
@@ -42,6 +90,19 @@ impl fmt::Display for RequirementSource {
RequirementSource::CloudRequirements => {
write!(f, "cloud requirements")
}
RequirementSource::Composite { sources } => {
write!(f, "requirements layers: ")?;
for (index, source) in sources.iter().enumerate() {
if index > 0 {
write!(f, ", ")?;
}
write!(f, "{source}")?;
}
Ok(())
}
RequirementSource::EnterpriseManaged { id, name } => {
write!(f, "enterprise-managed requirements {name} ({id})")
}
RequirementSource::SystemRequirementsToml { file } => {
write!(f, "{}", file.as_path().display())
}
@@ -983,7 +1044,7 @@ fn hostname_matches_any_pattern(hostname: &str, patterns: &[String]) -> bool {
/// Currently, `external-sandbox` is not supported in config.toml, but it is
/// supported through programmatic use.
#[derive(Deserialize, Debug, Clone, Copy, PartialEq)]
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq)]
pub enum SandboxModeRequirement {
#[serde(rename = "read-only")]
ReadOnly,
@@ -1430,6 +1491,41 @@ mod tests {
)?)
}
#[test]
fn composite_requirement_source_flattens_and_deduplicates_sources() {
let mdm_source = RequirementSource::MdmManagedPreferences {
domain: "com.openai.codex".to_string(),
key: "requirements_toml_base64".to_string(),
};
let legacy_source = RequirementSource::LegacyManagedConfigTomlFromMdm;
assert_eq!(
RequirementSource::composite([
mdm_source.clone(),
RequirementSource::composite([legacy_source.clone(), mdm_source.clone()]),
]),
RequirementSource::Composite {
sources: vec![mdm_source, legacy_source],
}
);
}
#[test]
fn composite_requirement_source_display_lists_sources_in_priority_order() {
let source = RequirementSource::composite([
RequirementSource::MdmManagedPreferences {
domain: "com.openai.codex".to_string(),
key: "requirements_toml_base64".to_string(),
},
RequirementSource::LegacyManagedConfigTomlFromMdm,
]);
assert_eq!(
source.to_string(),
"requirements layers: MDM com.openai.codex:requirements_toml_base64, MDM managed_config.toml (legacy)"
);
}
fn with_unknown_source(toml: ConfigRequirementsToml) -> ConfigRequirementsWithSources {
let ConfigRequirementsToml {
allowed_approval_policies,
@@ -2273,14 +2369,20 @@ allowed_approvals_reviewers = ["user"]
}
#[test]
fn constraint_error_includes_cloud_requirements_source() -> Result<()> {
fn constraint_error_includes_composite_requirement_source() -> Result<()> {
let source: ConfigRequirementsToml = from_str(
r#"
allowed_approval_policies = ["on-request"]
"#,
)?;
let source_location = RequirementSource::CloudRequirements;
let source_location = RequirementSource::composite([
RequirementSource::MdmManagedPreferences {
domain: "com.openai.codex".to_string(),
key: "requirements_toml_base64".to_string(),
},
RequirementSource::LegacyManagedConfigTomlFromMdm,
]);
let mut target = ConfigRequirementsWithSources::default();
target.merge_unset_fields(source_location.clone(), source);

View File

@@ -18,6 +18,7 @@ mod plugin_edit;
pub mod profile_toml;
mod project_root_markers;
mod requirements_exec_policy;
mod requirements_layers;
pub mod schema;
mod skills_config;
mod state;
@@ -117,6 +118,8 @@ pub use requirements_exec_policy::RequirementsExecPolicyParseError;
pub use requirements_exec_policy::RequirementsExecPolicyPatternTokenToml;
pub use requirements_exec_policy::RequirementsExecPolicyPrefixRuleToml;
pub use requirements_exec_policy::RequirementsExecPolicyToml;
pub use requirements_layers::RequirementsLayerEntry;
pub use requirements_layers::compose_requirements;
pub use skills_config::BundledSkillsConfig;
pub use skills_config::SkillConfig;
pub use skills_config::SkillsConfig;

View File

@@ -0,0 +1,237 @@
//! Hook events are append-only across requirements layers. The managed hook
//! directory is different: only one directory is usable on a given platform, so
//! conflicting values for the active platform fail closed. The inactive platform
//! field is first-filled to allow the same layer stack to carry OS-specific
//! directories.
use crate::HookEventsToml;
use crate::ManagedHooksRequirementsToml;
use crate::RequirementSource;
use crate::Sourced;
use std::collections::BTreeMap;
use std::path::PathBuf;
use super::stack::composition_conflict;
use super::stack::merge_output_source;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
pub(super) enum HookDirectoryField {
#[default]
ManagedDir,
WindowsManagedDir,
}
impl HookDirectoryField {
pub(super) fn current_platform() -> Self {
if cfg!(windows) {
Self::WindowsManagedDir
} else {
Self::ManagedDir
}
}
fn field_name(self) -> &'static str {
match self {
Self::ManagedDir => "hooks.managed_dir",
Self::WindowsManagedDir => "hooks.windows_managed_dir",
}
}
fn inactive(self) -> Self {
match self {
Self::ManagedDir => Self::WindowsManagedDir,
Self::WindowsManagedDir => Self::ManagedDir,
}
}
}
pub(super) struct HookMergeState {
directory_field: HookDirectoryField,
dir_sources: BTreeMap<HookDirectoryField, RequirementSource>,
}
impl HookMergeState {
pub(super) fn new(directory_field: HookDirectoryField) -> Self {
Self {
directory_field,
dir_sources: BTreeMap::new(),
}
}
pub(super) fn merge(
&mut self,
target: &mut Option<Sourced<ManagedHooksRequirementsToml>>,
incoming: Option<ManagedHooksRequirementsToml>,
source: &RequirementSource,
) -> Result<(), super::stack::RequirementsCompositionError> {
let Some(mut incoming) = incoming.filter(|value| !value.is_empty()) else {
return Ok(());
};
let Some(existing) = target.as_mut() else {
self.track_singleton_source(
HookDirectoryField::ManagedDir,
&incoming.managed_dir,
source,
);
self.track_singleton_source(
HookDirectoryField::WindowsManagedDir,
&incoming.windows_managed_dir,
source,
);
*target = Some(Sourced::new(incoming, source.clone()));
return Ok(());
};
let active_field = self.directory_field;
let inactive_field = active_field.inactive();
let incoming_active_dir = take_hook_dir(&mut incoming, active_field);
let incoming_inactive_dir = take_hook_dir(&mut incoming, inactive_field);
let mut changed = false;
changed |= self.merge_active_singleton(
active_field,
hook_dir_mut(&mut existing.value, active_field),
incoming_active_dir,
source,
)?;
changed |= self.fill_singleton(
inactive_field,
hook_dir_mut(&mut existing.value, inactive_field),
incoming_inactive_dir,
source,
);
changed |= append_hook_events(&mut existing.value.hooks, incoming.hooks);
if changed {
merge_output_source(&mut existing.source, source);
}
Ok(())
}
fn track_singleton_source(
&mut self,
field: HookDirectoryField,
value: &Option<PathBuf>,
source: &RequirementSource,
) {
if value.is_some() {
self.dir_sources
.entry(field)
.or_insert_with(|| source.clone());
}
}
fn merge_active_singleton(
&mut self,
field: HookDirectoryField,
existing: &mut Option<PathBuf>,
incoming: Option<PathBuf>,
incoming_source: &RequirementSource,
) -> Result<bool, super::stack::RequirementsCompositionError> {
let Some(incoming) = incoming else {
return Ok(false);
};
match existing {
Some(existing_value) if existing_value != &incoming => {
let existing_source = self
.dir_sources
.get(&field)
.cloned()
.unwrap_or_else(|| incoming_source.clone());
Err(composition_conflict(
field.field_name().to_string(),
existing_source,
incoming_source.clone(),
format!(
"`{}` conflicts with `{}`",
existing_value.display(),
incoming.display()
),
))
}
Some(_) => Ok(false),
None => {
*existing = Some(incoming);
self.dir_sources
.entry(field)
.or_insert_with(|| incoming_source.clone());
Ok(true)
}
}
}
fn fill_singleton(
&mut self,
field: HookDirectoryField,
existing: &mut Option<PathBuf>,
incoming: Option<PathBuf>,
incoming_source: &RequirementSource,
) -> bool {
if existing.is_none()
&& let Some(incoming) = incoming
{
*existing = Some(incoming);
self.dir_sources
.entry(field)
.or_insert_with(|| incoming_source.clone());
true
} else {
false
}
}
}
fn take_hook_dir(
hooks: &mut ManagedHooksRequirementsToml,
field: HookDirectoryField,
) -> Option<PathBuf> {
match field {
HookDirectoryField::ManagedDir => hooks.managed_dir.take(),
HookDirectoryField::WindowsManagedDir => hooks.windows_managed_dir.take(),
}
}
fn hook_dir_mut(
hooks: &mut ManagedHooksRequirementsToml,
field: HookDirectoryField,
) -> &mut Option<PathBuf> {
match field {
HookDirectoryField::ManagedDir => &mut hooks.managed_dir,
HookDirectoryField::WindowsManagedDir => &mut hooks.windows_managed_dir,
}
}
fn append_hook_events(existing: &mut HookEventsToml, incoming: HookEventsToml) -> bool {
// Destructure without `..` so new hook events cannot be introduced without
// deciding whether requirements layer merging should append them.
let HookEventsToml {
pre_tool_use,
permission_request,
post_tool_use,
pre_compact,
post_compact,
session_start,
user_prompt_submit,
subagent_start,
subagent_stop,
stop,
} = incoming;
let mut changed = false;
changed |= append_vec(&mut existing.pre_tool_use, pre_tool_use);
changed |= append_vec(&mut existing.permission_request, permission_request);
changed |= append_vec(&mut existing.post_tool_use, post_tool_use);
changed |= append_vec(&mut existing.pre_compact, pre_compact);
changed |= append_vec(&mut existing.post_compact, post_compact);
changed |= append_vec(&mut existing.session_start, session_start);
changed |= append_vec(&mut existing.user_prompt_submit, user_prompt_submit);
changed |= append_vec(&mut existing.subagent_start, subagent_start);
changed |= append_vec(&mut existing.subagent_stop, subagent_stop);
changed |= append_vec(&mut existing.stop, stop);
changed
}
fn append_vec<T>(existing: &mut Vec<T>, mut incoming: Vec<T>) -> bool {
let changed = !incoming.is_empty();
existing.append(&mut incoming);
changed
}

View File

@@ -0,0 +1,191 @@
use crate::ConfigRequirementsToml;
use crate::ManagedHooksRequirementsToml;
use crate::RequirementSource;
use crate::RequirementsExecPolicyToml;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::AbsolutePathBufGuard;
use toml::Value as TomlValue;
use super::stack::RequirementsCompositionError;
#[derive(Clone, Debug)]
pub struct RequirementsLayerEntry {
pub(super) source: RequirementSource,
toml: RequirementsLayerToml,
base_dir: Option<AbsolutePathBuf>,
}
impl RequirementsLayerEntry {
pub fn from_toml(source: RequirementSource, contents: impl Into<String>) -> Self {
Self {
source,
toml: RequirementsLayerToml::String(contents.into()),
base_dir: None,
}
}
pub fn from_toml_value(source: RequirementSource, value: TomlValue) -> Self {
Self {
source,
toml: RequirementsLayerToml::Value(value),
base_dir: None,
}
}
pub fn with_base_dir(mut self, base_dir: AbsolutePathBuf) -> Self {
self.base_dir = Some(base_dir);
self
}
}
#[derive(Clone, Debug)]
enum RequirementsLayerToml {
String(String),
Value(TomlValue),
}
#[derive(Clone, Debug)]
pub(super) struct ComposableRequirementsLayer {
pub(super) source: RequirementSource,
pub(super) regular_toml: TomlValue,
pub(super) domain_fields: DomainMergedRequirementsFields,
}
impl ComposableRequirementsLayer {
pub(super) fn from_entry(
layer: RequirementsLayerEntry,
hostname: Option<&str>,
) -> Result<Self, RequirementsCompositionError> {
let RequirementsLayerEntry {
source,
toml,
base_dir,
} = layer;
let (mut regular_toml, mut requirements) = {
let _guard = base_dir
.as_ref()
.map(|base_dir| AbsolutePathBufGuard::new(base_dir.as_path()));
let regular_toml = parse_layer_toml(&toml, &source)?;
let requirements = parse_layer_requirements(&toml, &source)?;
(regular_toml, requirements)
};
requirements.apply_remote_sandbox_config(hostname);
materialize_remote_sandbox_config(&mut regular_toml, &requirements)?;
strip_special_fields(&mut regular_toml);
Ok(Self {
source,
regular_toml,
domain_fields: DomainMergedRequirementsFields {
rules: requirements.rules,
hooks: requirements.hooks,
permissions: requirements.permissions,
},
})
}
}
#[derive(Clone, Debug)]
pub(super) struct DomainMergedRequirementsFields {
pub(super) rules: Option<RequirementsExecPolicyToml>,
pub(super) hooks: Option<ManagedHooksRequirementsToml>,
pub(super) permissions: Option<crate::config_requirements::PermissionsRequirementsToml>,
}
fn parse_layer_toml(
toml: &RequirementsLayerToml,
source: &RequirementSource,
) -> Result<TomlValue, RequirementsCompositionError> {
match toml {
RequirementsLayerToml::String(contents) => {
toml::from_str(contents).map_err(|err: toml::de::Error| {
RequirementsCompositionError::Parse {
layer_source: source.clone(),
message: err.to_string(),
}
})
}
RequirementsLayerToml::Value(value) => Ok(value.clone()),
}
}
fn parse_layer_requirements(
toml: &RequirementsLayerToml,
source: &RequirementSource,
) -> Result<ConfigRequirementsToml, RequirementsCompositionError> {
match toml {
RequirementsLayerToml::String(contents) => {
toml::from_str(contents).map_err(|err: toml::de::Error| {
RequirementsCompositionError::Parse {
layer_source: source.clone(),
message: err.to_string(),
}
})
}
RequirementsLayerToml::Value(value) => {
value.clone().try_into().map_err(|err: toml::de::Error| {
RequirementsCompositionError::Parse {
layer_source: source.clone(),
message: err.to_string(),
}
})
}
}
}
fn materialize_remote_sandbox_config(
layer_toml: &mut TomlValue,
requirements: &ConfigRequirementsToml,
) -> Result<(), RequirementsCompositionError> {
remove_top_level_field(layer_toml, "remote_sandbox_config");
let Some(allowed_sandbox_modes) = requirements.allowed_sandbox_modes.as_ref() else {
return Ok(());
};
let Some(table) = layer_toml.as_table_mut() else {
return Ok(());
};
table.insert(
"allowed_sandbox_modes".to_string(),
toml_value_from_serializable(allowed_sandbox_modes)?,
);
Ok(())
}
fn toml_value_from_serializable<T: serde::Serialize>(
value: T,
) -> Result<TomlValue, RequirementsCompositionError> {
TomlValue::try_from(value).map_err(|err| RequirementsCompositionError::ComposedParse {
message: err.to_string(),
})
}
fn strip_special_fields(layer_toml: &mut TomlValue) {
remove_top_level_field(layer_toml, "rules");
remove_top_level_field(layer_toml, "hooks");
remove_nested_field_and_prune_empty(layer_toml, &["permissions", "filesystem", "deny_read"]);
}
fn remove_top_level_field(value: &mut TomlValue, key: &str) -> Option<TomlValue> {
value.as_table_mut()?.remove(key)
}
fn remove_nested_field_and_prune_empty(value: &mut TomlValue, path: &[&str]) -> Option<TomlValue> {
let (key, remaining) = path.split_first()?;
let table = value.as_table_mut()?;
if remaining.is_empty() {
return table.remove(*key);
}
let removed = table
.get_mut(*key)
.and_then(|child| remove_nested_field_and_prune_empty(child, remaining));
if table
.get(*key)
.and_then(TomlValue::as_table)
.is_some_and(toml::map::Map::is_empty)
{
table.remove(*key);
}
removed
}

View File

@@ -0,0 +1,8 @@
mod hooks;
mod layer;
mod permissions;
mod rules;
mod stack;
pub use layer::RequirementsLayerEntry;
pub use stack::compose_requirements;

View File

@@ -0,0 +1,82 @@
//! `permissions.filesystem.deny_read` is intentionally additive across
//! requirements layers. Other `[permissions]` content stays in the regular TOML
//! merge path so permission profile tables follow config-style precedence.
use crate::FilesystemDenyReadPattern;
use crate::RequirementSource;
use crate::Sourced;
use crate::config_requirements::FilesystemRequirementsToml;
use crate::config_requirements::PermissionsRequirementsToml;
use super::stack::merge_output_source;
#[derive(Default)]
pub(super) struct DenyReadMergeState {
deny_read: Vec<FilesystemDenyReadPattern>,
source: Option<RequirementSource>,
}
impl DenyReadMergeState {
pub(super) fn merge(
&mut self,
incoming: Option<PermissionsRequirementsToml>,
source: &RequirementSource,
) {
let Some(incoming_deny_read) = incoming
.and_then(|permissions| permissions.filesystem)
.and_then(|filesystem| filesystem.deny_read)
.filter(|deny_read| !deny_read.is_empty())
else {
return;
};
for pattern in incoming_deny_read {
if !self.deny_read.contains(&pattern) {
self.deny_read.push(pattern);
self.merge_source(source);
}
}
}
pub(super) fn apply_to(self, target: &mut Option<Sourced<PermissionsRequirementsToml>>) {
if self.deny_read.is_empty() {
return;
}
let source = self.source.unwrap_or(RequirementSource::Unknown);
let Some(existing) = target.as_mut() else {
*target = Some(Sourced::new(
PermissionsRequirementsToml {
filesystem: Some(FilesystemRequirementsToml {
deny_read: Some(self.deny_read),
}),
profiles: Default::default(),
},
source,
));
return;
};
let filesystem = existing
.value
.filesystem
.get_or_insert_with(Default::default);
let deny_read = filesystem.deny_read.get_or_insert_with(Vec::new);
for pattern in self.deny_read {
if !deny_read.contains(&pattern) {
deny_read.push(pattern);
}
}
if existing.source != source {
existing.source = RequirementSource::composite([existing.source.clone(), source]);
}
}
fn merge_source(&mut self, source: &RequirementSource) {
let Some(existing) = self.source.as_mut() else {
self.source = Some(source.clone());
return;
};
merge_output_source(existing, source);
}
}

View File

@@ -0,0 +1,26 @@
//! Requirements rules are additive across layers. Higher-priority rules are
//! appended first so the final rule order keeps priority visible.
use crate::RequirementSource;
use crate::RequirementsExecPolicyToml;
use crate::Sourced;
use super::stack::merge_output_source;
pub(super) fn merge(
target: &mut Option<Sourced<RequirementsExecPolicyToml>>,
incoming: Option<RequirementsExecPolicyToml>,
source: &RequirementSource,
) {
let Some(incoming) = incoming else {
return;
};
let Some(existing) = target.as_mut() else {
*target = Some(Sourced::new(incoming, source.clone()));
return;
};
let RequirementsExecPolicyToml { prefix_rules } = incoming;
existing.value.prefix_rules.extend(prefix_rules);
merge_output_source(&mut existing.source, source);
}

View File

@@ -0,0 +1,286 @@
//! 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_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_permissions, &["allowed_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;

View File

@@ -0,0 +1,922 @@
use super::super::RequirementsLayerEntry;
use super::super::hooks::HookDirectoryField;
use super::RequirementsCompositionError;
use super::compose_requirements_for_hostname;
use super::compose_requirements_for_hostname_and_hook_directory;
use crate::ConfigRequirementsToml;
use crate::ConfigRequirementsWithSources;
use crate::RequirementSource;
use crate::Sourced;
use codex_protocol::protocol::AskForApproval;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
fn layer(id: &str, name: &str, contents: &str) -> RequirementsLayerEntry {
RequirementsLayerEntry::from_toml(
RequirementSource::EnterpriseManaged {
id: id.to_string(),
name: name.to_string(),
},
contents,
)
}
fn compose(
layers: Vec<RequirementsLayerEntry>,
) -> Result<Option<ConfigRequirementsToml>, RequirementsCompositionError> {
Ok(
compose_requirements_for_hostname(layers, /*hostname*/ None)?
.map(ConfigRequirementsWithSources::into_toml),
)
}
fn compose_with_hook_directory_field(
layers: Vec<RequirementsLayerEntry>,
hook_directory_field: HookDirectoryField,
) -> Result<Option<ConfigRequirementsToml>, RequirementsCompositionError> {
Ok(compose_requirements_for_hostname_and_hook_directory(
layers,
/*hostname*/ None,
hook_directory_field,
)?
.map(ConfigRequirementsWithSources::into_toml))
}
fn expected_requirements(contents: impl AsRef<str>) -> ConfigRequirementsToml {
toml::from_str(contents.as_ref()).expect("parse expected requirements TOML")
}
#[test]
fn empty_layers_compose_to_none() {
let composed = compose(Vec::new()).expect("compose empty layers");
assert_eq!(composed, None);
}
#[test]
fn top_level_values_use_toml_priority() {
let composed = compose(vec![
layer(
"req_low",
"Low",
r#"
allowed_approval_policies = ["on-request"]
allowed_sandbox_modes = ["workspace-write"]
"#,
),
layer(
"req_high",
"High",
r#"
allowed_approval_policies = ["never"]
allowed_sandbox_modes = ["read-only"]
"#,
),
])
.expect("compose requirements")
.expect("requirements present");
assert_eq!(
composed,
expected_requirements(
r#"
allowed_approval_policies = ["never"]
allowed_sandbox_modes = ["read-only"]
"#
)
);
}
#[test]
fn composition_strategy_applies_to_non_cloud_layers() {
let mdm_source = RequirementSource::MdmManagedPreferences {
domain: "com.openai.codex".to_string(),
key: "requirements_toml_base64".to_string(),
};
let system_file = if cfg!(windows) {
"C:\\requirements.toml"
} else {
"/etc/codex/requirements.toml"
};
let system_source = RequirementSource::SystemRequirementsToml {
file: AbsolutePathBuf::from_absolute_path(system_file).expect("absolute path"),
};
let high_path = if cfg!(windows) {
"C:\\secret"
} else {
"/secret"
};
let low_path = if cfg!(windows) {
"C:\\other-secret"
} else {
"/other-secret"
};
let composed = compose_requirements_for_hostname(
vec![
RequirementsLayerEntry::from_toml(
system_source,
format!(
r#"
allowed_approval_policies = ["on-request"]
[features]
shared = false
system = true
[[rules.prefix_rules]]
pattern = [{{ token = "npm" }}]
decision = "prompt"
[permissions.filesystem]
deny_read = [{low_path:?}]
"#
),
),
RequirementsLayerEntry::from_toml(
mdm_source.clone(),
format!(
r#"
allowed_approval_policies = ["never"]
[features]
shared = true
[[rules.prefix_rules]]
pattern = [{{ token = "git" }}]
decision = "forbidden"
[permissions.filesystem]
deny_read = [{high_path:?}]
"#
),
),
],
/*hostname*/ None,
)
.expect("compose requirements")
.expect("requirements present");
assert_eq!(
composed.clone().into_toml(),
expected_requirements(format!(
r#"
allowed_approval_policies = ["never"]
[features]
shared = true
system = true
[[rules.prefix_rules]]
pattern = [{{ token = "git" }}]
decision = "forbidden"
[[rules.prefix_rules]]
pattern = [{{ token = "npm" }}]
decision = "prompt"
[permissions.filesystem]
deny_read = [{high_path:?}, {low_path:?}]
"#
))
);
assert_eq!(
composed.allowed_approval_policies,
Some(Sourced::new(vec![AskForApproval::Never], mdm_source))
);
}
#[test]
fn single_regular_layer_keeps_enterprise_managed_source() {
let composed = compose_requirements_for_hostname(
vec![layer(
"req_1",
"Security baseline",
r#"
allow_managed_hooks_only = true
"#,
)],
/*hostname*/ None,
)
.expect("compose requirements")
.expect("requirements present");
assert_eq!(
composed.allow_managed_hooks_only,
Some(Sourced::new(
/*value*/ true,
RequirementSource::EnterpriseManaged {
id: "req_1".to_string(),
name: "Security baseline".to_string(),
},
))
);
}
#[test]
fn regular_toml_merge_recurses_into_tables() {
let composed = compose(vec![
layer(
"req_low",
"Low",
r#"
[features]
beta = false
shared = false
[apps.connector_1]
enabled = false
[apps.connector_1.tools.search]
approval_mode = "prompt"
[apps.connector_1.tools.list]
approval_mode = "prompt"
"#,
),
layer(
"req_high",
"High",
r#"
[features]
alpha = true
shared = true
[apps.connector_1]
enabled = true
[apps.connector_1.tools.search]
approval_mode = "approve"
"#,
),
])
.expect("compose requirements")
.expect("requirements present");
assert_eq!(
composed,
expected_requirements(
r#"
[features]
alpha = true
beta = false
shared = true
[apps.connector_1]
enabled = true
[apps.connector_1.tools.list]
approval_mode = "prompt"
[apps.connector_1.tools.search]
approval_mode = "approve"
"#
)
);
}
#[test]
fn merged_table_source_is_composite_in_priority_order() {
let high_source = RequirementSource::EnterpriseManaged {
id: "req_high".to_string(),
name: "High".to_string(),
};
let low_source = RequirementSource::EnterpriseManaged {
id: "req_low".to_string(),
name: "Low".to_string(),
};
let composed = compose_requirements_for_hostname(
vec![
RequirementsLayerEntry::from_toml(
low_source.clone(),
r#"
[features]
beta = true
"#,
),
RequirementsLayerEntry::from_toml(
high_source.clone(),
r#"
[features]
alpha = true
"#,
),
],
/*hostname*/ None,
)
.expect("compose requirements")
.expect("requirements present");
assert_eq!(
composed.feature_requirements.expect("features"),
Sourced::new(
crate::FeatureRequirementsToml {
entries: BTreeMap::from([("alpha".to_string(), true), ("beta".to_string(), true),]),
},
RequirementSource::composite([high_source, low_source]),
)
);
}
#[test]
fn mcp_requirements_use_regular_toml_merge() {
let composed = compose(vec![
layer(
"req_low",
"Low",
r#"
[mcp_servers.shared.identity]
command = "low-mcp"
[mcp_servers.low.identity]
url = "https://low.example.com/mcp"
"#,
),
layer(
"req_high",
"High",
r#"
[mcp_servers.shared.identity]
command = "high-mcp"
"#,
),
])
.expect("compose requirements")
.expect("requirements present");
assert_eq!(
composed,
expected_requirements(
r#"
[mcp_servers.low.identity]
url = "https://low.example.com/mcp"
[mcp_servers.shared.identity]
command = "high-mcp"
"#
)
);
}
#[test]
fn network_maps_use_regular_toml_merge() {
let composed = compose(vec![
layer(
"req_low",
"Low",
r#"
[experimental_network.domains]
"example.com" = "deny"
"low.example.com" = "deny"
"internal.example.com" = "allow"
[experimental_network.unix_sockets]
"/tmp/shared.sock" = "deny"
"/tmp/low.sock" = "allow"
"/tmp/admin.sock" = "allow"
"#,
),
layer(
"req_high",
"High",
r#"
[experimental_network.domains]
"example.com" = "allow"
"high.example.com" = "allow"
"internal.example.com" = "deny"
[experimental_network.unix_sockets]
"/tmp/shared.sock" = "allow"
"/tmp/high.sock" = "allow"
"/tmp/admin.sock" = "deny"
"#,
),
])
.expect("compose requirements")
.expect("requirements present");
assert_eq!(
composed,
expected_requirements(
r#"
[experimental_network.domains]
"example.com" = "allow"
"high.example.com" = "allow"
"internal.example.com" = "deny"
"low.example.com" = "deny"
[experimental_network.unix_sockets]
"/tmp/admin.sock" = "deny"
"/tmp/high.sock" = "allow"
"/tmp/low.sock" = "allow"
"/tmp/shared.sock" = "allow"
"#
)
);
}
#[test]
fn windows_requirements_use_regular_toml_merge() {
let composed = compose(vec![
layer(
"req_low",
"Low",
r#"
[windows]
allowed_sandbox_implementations = ["unelevated"]
"#,
),
layer(
"req_high",
"High",
r#"
[windows]
allowed_sandbox_implementations = ["elevated"]
"#,
),
])
.expect("compose requirements")
.expect("requirements present");
assert_eq!(
composed,
expected_requirements(
r#"
[windows]
allowed_sandbox_implementations = ["elevated"]
"#
)
);
}
#[test]
fn remote_sandbox_config_is_applied_per_layer() {
let composed = compose_requirements_for_hostname(
vec![
layer(
"req_low",
"Low",
r#"
allowed_sandbox_modes = ["read-only"]
"#,
),
layer(
"req_high",
"High",
r#"
[[remote_sandbox_config]]
hostname_patterns = ["build-*.example.com"]
allowed_sandbox_modes = ["workspace-write"]
"#,
),
],
Some("BUILD-01.EXAMPLE.COM."),
)
.expect("compose requirements")
.expect("requirements present")
.into_toml();
assert_eq!(
composed,
expected_requirements(
r#"
allowed_sandbox_modes = ["workspace-write"]
"#
)
);
}
#[test]
fn unmatched_remote_sandbox_config_does_not_shadow_lower_layers() {
let composed = compose_requirements_for_hostname(
vec![
layer(
"req_low",
"Low",
r#"
allowed_sandbox_modes = ["read-only"]
"#,
),
layer(
"req_high",
"High",
r#"
[[remote_sandbox_config]]
hostname_patterns = ["mac-*.example.com"]
allowed_sandbox_modes = ["workspace-write"]
"#,
),
],
Some("linux-01.example.com"),
)
.expect("compose requirements")
.expect("requirements present")
.into_toml();
assert_eq!(
composed,
expected_requirements(
r#"
allowed_sandbox_modes = ["read-only"]
"#
)
);
}
#[test]
fn rules_are_appended_in_priority_order() {
let composed = compose(vec![
layer(
"req_low",
"Low",
r#"
[[rules.prefix_rules]]
pattern = [{ token = "npm" }]
decision = "prompt"
"#,
),
layer(
"req_high",
"High",
r#"
[[rules.prefix_rules]]
pattern = [{ token = "git" }]
decision = "forbidden"
"#,
),
])
.expect("compose requirements")
.expect("requirements present");
assert_eq!(
composed,
expected_requirements(
r#"
[[rules.prefix_rules]]
pattern = [{ token = "git" }]
decision = "forbidden"
[[rules.prefix_rules]]
pattern = [{ token = "npm" }]
decision = "prompt"
"#
)
);
}
#[test]
fn hooks_append_groups_and_reject_conflicting_managed_dirs() {
let composed = compose_with_hook_directory_field(
vec![
layer(
"req_low",
"Low",
r#"
[hooks]
managed_dir = "/managed/hooks"
[[hooks.PreToolUse]]
matcher = "Bash"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "low"
"#,
),
layer(
"req_high",
"High",
r#"
[hooks]
managed_dir = "/managed/hooks"
[[hooks.PreToolUse]]
matcher = "Edit"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "high"
"#,
),
],
HookDirectoryField::ManagedDir,
)
.expect("compose requirements")
.expect("requirements present");
assert_eq!(
composed,
expected_requirements(
r#"
[hooks]
managed_dir = "/managed/hooks"
[[hooks.PreToolUse]]
matcher = "Edit"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "high"
[[hooks.PreToolUse]]
matcher = "Bash"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "low"
"#
)
);
let err = compose_with_hook_directory_field(
vec![
layer(
"req_low",
"Low",
r#"
[hooks]
managed_dir = "/managed/low"
"#,
),
layer(
"req_high",
"High",
r#"
[hooks]
managed_dir = "/managed/high"
"#,
),
],
HookDirectoryField::ManagedDir,
)
.expect_err("conflicting managed dirs should fail closed");
assert!(err.to_string().contains("hooks.managed_dir"));
assert!(err.to_string().contains("High (req_high)"));
assert!(err.to_string().contains("Low (req_low)"));
}
#[test]
fn active_windows_managed_dir_conflicts_fail_closed() {
let err = compose_with_hook_directory_field(
vec![
layer(
"req_low",
"Low",
r#"
[hooks]
windows_managed_dir = 'C:\managed\low'
"#,
),
layer(
"req_high",
"High",
r#"
[hooks]
windows_managed_dir = 'C:\managed\high'
"#,
),
],
HookDirectoryField::WindowsManagedDir,
)
.expect_err("conflicting windows managed dirs should fail closed");
assert!(err.to_string().contains("hooks.windows_managed_dir"));
assert!(err.to_string().contains("High (req_high)"));
assert!(err.to_string().contains("Low (req_low)"));
}
#[test]
fn inactive_hook_dir_conflicts_do_not_fail_composition() {
let composed = compose_with_hook_directory_field(
vec![
layer(
"req_low",
"Low",
r#"
[hooks]
managed_dir = "/managed/hooks"
windows_managed_dir = 'C:\managed\low'
[[hooks.PreToolUse]]
matcher = "Bash"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "low"
"#,
),
layer(
"req_high",
"High",
r#"
[hooks]
managed_dir = "/managed/hooks"
windows_managed_dir = 'C:\managed\high'
[[hooks.PreToolUse]]
matcher = "Edit"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "high"
"#,
),
],
HookDirectoryField::ManagedDir,
)
.expect("inactive windows managed dir conflict should not fail")
.expect("requirements present");
assert_eq!(
composed,
expected_requirements(
r#"
[hooks]
managed_dir = "/managed/hooks"
windows_managed_dir = 'C:\managed\high'
[[hooks.PreToolUse]]
matcher = "Edit"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "high"
[[hooks.PreToolUse]]
matcher = "Bash"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "low"
"#
)
);
let composed = compose_with_hook_directory_field(
vec![
layer(
"req_low",
"Low",
r#"
[hooks]
managed_dir = "/managed/low"
windows_managed_dir = 'C:\managed\hooks'
[[hooks.PreToolUse]]
matcher = "Bash"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "low"
"#,
),
layer(
"req_high",
"High",
r#"
[hooks]
managed_dir = "/managed/high"
windows_managed_dir = 'C:\managed\hooks'
[[hooks.PreToolUse]]
matcher = "Edit"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "high"
"#,
),
],
HookDirectoryField::WindowsManagedDir,
)
.expect("inactive managed dir conflict should not fail")
.expect("requirements present");
assert_eq!(
composed,
expected_requirements(
r#"
[hooks]
managed_dir = "/managed/high"
windows_managed_dir = 'C:\managed\hooks'
[[hooks.PreToolUse]]
matcher = "Edit"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "high"
[[hooks.PreToolUse]]
matcher = "Bash"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "low"
"#
)
);
}
#[test]
fn permissions_deny_read_unions_while_profiles_use_regular_toml_merge() {
let high_path = if cfg!(windows) {
"C:\\secret"
} else {
"/secret"
};
let low_path = if cfg!(windows) {
"C:\\other-secret"
} else {
"/other-secret"
};
let composed = compose(vec![
layer(
"req_low",
"Low",
&format!(
r#"
[permissions.filesystem]
deny_read = [{high_path:?}, {low_path:?}]
[permissions.managed-standard]
description = "Low profile"
extends = ":workspace"
"#
),
),
layer(
"req_high",
"High",
&format!(
r#"
[permissions.filesystem]
deny_read = [{high_path:?}]
[permissions.managed-standard]
description = "High profile"
"#
),
),
])
.expect("compose requirements")
.expect("requirements present");
assert_eq!(
composed,
expected_requirements(format!(
r#"
[permissions.filesystem]
deny_read = [{high_path:?}, {low_path:?}]
[permissions.managed-standard]
description = "High profile"
extends = ":workspace"
"#
))
);
}
#[test]
fn deny_read_only_layers_do_not_leave_empty_permissions_tables() {
let path = if cfg!(windows) {
"C:\\secret"
} else {
"/secret"
};
let composed = compose(vec![layer(
"req_high",
"High",
&format!(
r#"
[permissions.filesystem]
deny_read = [{path:?}]
"#
),
)])
.expect("compose requirements")
.expect("requirements present");
assert_eq!(
composed,
expected_requirements(format!(
r#"
[permissions.filesystem]
deny_read = [{path:?}]
"#
))
);
}
#[test]
fn parse_error_names_layer() {
let err = compose(vec![layer(
"req_bad",
"Bad layer",
"allowed_approval_policies = [1]",
)])
.expect_err("invalid layer should fail");
assert!(err.to_string().contains("Bad layer (req_bad)"));
assert!(err.to_string().contains("allowed_approval_policies"));
}

View File

@@ -277,6 +277,16 @@ fn fallback_managed_hooks_source_path(
Some(RequirementSource::CloudRequirements) => {
synthetic_layer_path("<cloud-requirements>/requirements.toml")
}
Some(RequirementSource::Composite { .. }) => {
synthetic_layer_path("<requirements-composition>/requirements.toml")
}
Some(RequirementSource::EnterpriseManaged { id, name }) => {
let name = escape_xml_text(name);
let id = escape_xml_text(id);
synthetic_layer_path(&format!(
"<enterprise-managed:{name}:{id}>/requirements.toml"
))
}
Some(RequirementSource::LegacyManagedConfigTomlFromMdm) => {
synthetic_layer_path("<legacy-managed-config.toml-mdm>/managed_config.toml")
}
@@ -380,6 +390,21 @@ fn synthetic_layer_path(path: &str) -> AbsolutePathBuf {
}
}
fn escape_xml_text(value: &str) -> String {
let mut escaped = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'&' => escaped.push_str("&amp;"),
'<' => escaped.push_str("&lt;"),
'>' => escaped.push_str("&gt;"),
'"' => escaped.push_str("&quot;"),
'\'' => escaped.push_str("&apos;"),
_ => escaped.push(ch),
}
}
escaped
}
fn append_hook_events(
handlers: &mut Vec<ConfiguredHandler>,
hook_entries: &mut Vec<HookListEntry>,
@@ -607,6 +632,14 @@ fn hook_source_for_requirement_source(source: Option<&RequirementSource>) -> Hoo
HookSource::LegacyManagedConfigMdm
}
Some(RequirementSource::CloudRequirements) => HookSource::CloudRequirements,
Some(RequirementSource::Composite { sources }) => {
// Requirements hook composition preserves contributing sources in
// priority order, but discovery only carries one source for the
// whole merged hooks field. Use the primary contributor as the best
// available coarse attribution.
hook_source_for_requirement_source(sources.first())
}
Some(RequirementSource::EnterpriseManaged { .. }) => HookSource::CloudRequirements,
Some(RequirementSource::Unknown) | None => HookSource::Unknown,
}
}
@@ -616,6 +649,7 @@ mod tests {
use codex_config::ConfigLayerEntry;
use codex_config::ConfigLayerSource;
use codex_config::HookEventsToml;
use codex_config::RequirementSource;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::HookSource;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -672,6 +706,41 @@ mod tests {
}
}
#[test]
fn composite_requirement_hook_source_uses_primary_source() {
let source = RequirementSource::Composite {
sources: vec![
RequirementSource::SystemRequirementsToml {
file: test_path_buf("/etc/codex/requirements.toml").abs(),
},
RequirementSource::EnterpriseManaged {
id: "layer-1".to_string(),
name: "Engineering".to_string(),
},
],
};
assert_eq!(
super::hook_source_for_requirement_source(Some(&source)),
HookSource::System
);
}
#[test]
fn enterprise_managed_synthetic_path_escapes_display_fields() {
let source = RequirementSource::EnterpriseManaged {
id: "id<&>".to_string(),
name: "Name <Admin> & \"Ops\"".to_string(),
};
let source_path = super::fallback_managed_hooks_source_path(Some(&source));
let source_path = source_path.display().to_string();
assert!(source_path.contains("Name &lt;Admin&gt; &amp; &quot;Ops&quot;"));
assert!(source_path.contains("id&lt;&amp;&gt;"));
assert!(!source_path.contains("Name <Admin>"));
}
fn command_group(matcher: Option<&str>) -> MatcherGroup {
MatcherGroup {
matcher: matcher.map(str::to_string),