Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions codex-rs/config/src/merge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ pub fn merge_toml_values(base: &mut TomlValue, overlay: &TomlValue) {
}

fn merge_toml_values_at_path(base: &mut TomlValue, overlay: &TomlValue, path: &mut Vec<String>) {
replace_shell_environment_policy_filter_representation(base, overlay, path);

if let TomlValue::Table(overlay_table) = overlay
&& let TomlValue::Table(base_table) = base
{
Expand All @@ -34,6 +36,31 @@ fn merge_toml_values_at_path(base: &mut TomlValue, overlay: &TomlValue, path: &m
}
}

/// Switching between legacy arrays and keyed filters replaces lower filter
/// fields instead of attempting to reconcile the two representations.
Comment on lines +39 to +40

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should add a note here explaining this compromise a bit more - since the legacy form doesn't support merging (arrays fully replace each other) anyways, we don't need a more complicated merge that combines both formats in a lossless way.

fn replace_shell_environment_policy_filter_representation(
base: &mut TomlValue,
overlay: &TomlValue,
path: &[String],
) {
if !matches!(path, [policy] if policy == "shell_environment_policy") {
return;
}
let TomlValue::Table(base) = base else {
return;
};
let TomlValue::Table(overlay) = overlay else {
return;
};

if overlay.contains_key("filters") {
base.remove("exclude");
base.remove("include_only");
} else if overlay.contains_key("exclude") || overlay.contains_key("include_only") {
base.remove("filters");
}
}

fn is_permission_network_domains_path(path: &[String]) -> bool {
matches!(
path,
Expand Down
124 changes: 124 additions & 0 deletions codex-rs/config/src/merge_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,127 @@ fn merge_toml_values_normalizes_permission_network_domains_before_overlaying() {
);
assert_eq!(base, expected);
}

#[test]
fn shell_environment_policy_legacy_array_overlay_replaces_legacy_array() {
let mut base = parse_toml(
r#"
[shell_environment_policy]
exclude = ["LOW_*", "SHARED_*"]
"#,
);
let overlay = parse_toml(
r#"
[shell_environment_policy]
exclude = ["HIGH_*"]
"#,
);

merge_toml_values(&mut base, &overlay);

assert_eq!(base, overlay);
}

#[test]
fn shell_environment_policy_filters_overlay_merges_by_exact_key() {
let mut base = parse_toml(
r#"
[shell_environment_policy.filters]
"FLIP_*" = "exclude"
"KEEP_*" = "include"
"#,
);
let overlay = parse_toml(
r#"
[shell_environment_policy.filters]
"ADD_*" = "exclude"
"flip_*" = "include"
"#,
);

merge_toml_values(&mut base, &overlay);

assert_eq!(
base,
parse_toml(
r#"
[shell_environment_policy.filters]
"ADD_*" = "exclude"
"FLIP_*" = "exclude"
"KEEP_*" = "include"
"flip_*" = "include"
"#,
)
);
}

#[test]
fn shell_environment_policy_filters_replace_lower_legacy_filter_fields() {
let mut base = parse_toml(
r#"
[shell_environment_policy]
inherit = "core"
exclude = ["FLIP_TO_INCLUDE", "KEEP_EXCLUDED"]
include_only = ["FLIP_TO_EXCLUDE", "KEEP_INCLUDED"]
"#,
);
let overlay = parse_toml(
r#"
[shell_environment_policy.filters]
"ADD_INCLUDED" = "include"
"FLIP_TO_EXCLUDE" = "exclude"
"FLIP_TO_INCLUDE" = "include"
"#,
);

merge_toml_values(&mut base, &overlay);

assert_eq!(
base,
parse_toml(
r#"
[shell_environment_policy]
inherit = "core"

[shell_environment_policy.filters]
"ADD_INCLUDED" = "include"
"FLIP_TO_EXCLUDE" = "exclude"
"FLIP_TO_INCLUDE" = "include"
"#,
)
);
}

#[test]
fn shell_environment_policy_legacy_arrays_replace_lower_filters() {
let mut base = parse_toml(
r#"
[shell_environment_policy]
inherit = "core"

[shell_environment_policy.filters]
"FLIP_TO_EXCLUDE" = "include"
"LOW_EXCLUDED" = "exclude"
"KEEP_INCLUDED" = "include"
"#,
);
let overlay = parse_toml(
r#"
[shell_environment_policy]
exclude = ["FLIP_TO_EXCLUDE", "HIGH_EXCLUDED"]
"#,
);

merge_toml_values(&mut base, &overlay);

assert_eq!(
base,
parse_toml(
r#"
[shell_environment_policy]
inherit = "core"
exclude = ["FLIP_TO_EXCLUDE", "HIGH_EXCLUDED"]
"#,
)
);
}
18 changes: 18 additions & 0 deletions codex-rs/config/src/state.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
use crate::CONFIG_TOML_FILE;
use crate::config_requirements::ConfigRequirements;
use crate::config_requirements::ConfigRequirementsToml;
use crate::format_config_layer_source;

use super::fingerprint::record_origins;
use super::fingerprint::version_for_toml;
use super::key_aliases::normalized_with_key_aliases;
use super::merge::merge_toml_values;
use crate::CloudConfigBundleLoader;
use crate::ProfileV2Name;
use crate::types::ShellEnvironmentPolicyToml;
use codex_app_server_protocol::ConfigLayer;
use codex_app_server_protocol::ConfigLayerMetadata;
use codex_app_server_protocol::ConfigLayerSource;
Expand Down Expand Up @@ -275,6 +278,21 @@ impl ConfigLayerStack {
requirements: ConfigRequirements,
requirements_toml: ConfigRequirementsToml,
) -> std::io::Result<Self> {
// Validate enabled layers before merging to reject mixed forms and malformed entries
for layer in layers.iter().filter(|layer| !layer.is_disabled()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you break this out into a separate

fn validate_enabled_config_layers(layers: &[ConfigLayerEntry]) -> io::Result<()>

function to keep our factory method clean?

let Some(policy) = layer.config.get("shell_environment_policy") else {
continue;
};
let _: ShellEnvironmentPolicyToml = policy.clone().try_into().map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"invalid shell environment policy in {}: {error}",
format_config_layer_source(&layer.name, CONFIG_TOML_FILE)
),
)
})?;
}
let user_layer_index = verify_layer_ordering(&layers)?;
Ok(Self {
layers,
Expand Down
28 changes: 28 additions & 0 deletions codex-rs/config/src/state_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,34 @@ no_memories_if_mcp_or_web_search = true
);
}

#[test]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing a test for the actual validation for enabled layers?

fn disabled_layers_do_not_validate_shell_environment_policy() {
let layer = ConfigLayerEntry::new_disabled(
ConfigLayerSource::Project {
dot_codex_folder: AbsolutePathBuf::from_absolute_path("/untrusted/.codex")
.expect("project path should be absolute"),
},
toml::from_str(
r#"
[shell_environment_policy]
exclude = ["LEGACY_*"]

[shell_environment_policy.filters]
"CANONICAL_*" = "include"
"#,
)
.expect("project config"),
"project is untrusted",
);

ConfigLayerStack::new(
vec![layer],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("disabled layers should not be validated");
}

#[test]
fn active_user_layer_is_highest_precedence_user_layer() {
let temp_dir = TempDir::new().expect("tempdir");
Expand Down
78 changes: 69 additions & 9 deletions codex-rs/config/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ pub use codex_protocol::config_types::ModeKind;
pub use codex_protocol::config_types::Personality;
pub use codex_protocol::config_types::ServiceTier;
use codex_protocol::config_types::ShellEnvironmentPolicy;
use codex_protocol::config_types::ShellEnvironmentPolicyFilter;
use codex_protocol::config_types::ShellEnvironmentPolicyInherit;
pub use codex_protocol::config_types::WebSearchMode;
use codex_utils_absolute_path::AbsolutePathBuf;
Expand Down Expand Up @@ -932,39 +933,98 @@ impl From<SandboxWorkspaceWrite> for codex_app_server_protocol::SandboxSettings
}

/// Policy for building the `env` when spawning a process via shell-like tools.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
#[derive(Serialize, Debug, Clone, PartialEq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ShellEnvironmentPolicyToml {
pub inherit: Option<ShellEnvironmentPolicyInherit>,

pub ignore_default_excludes: Option<bool>,

/// List of regular expressions.
/// Legacy list of regular expressions to exclude.
pub exclude: Option<Vec<String>>,

pub r#set: Option<HashMap<String, String>>,

/// List of regular expressions.
/// Legacy list of regular expressions to include.
pub include_only: Option<Vec<String>>,

/// Pattern actions used by the canonical table representation.
///
/// Ordinary config keeps accepting the legacy arrays above during the
/// migration. Requirements will accept only this keyed form, keeping array
/// compatibility isolated so the legacy fields can be deprecated later.
/// Pattern keys merge case-sensitively across config layers even though the
/// resulting patterns match environment variable names case-insensitively.
pub filters: Option<BTreeMap<String, ShellEnvironmentPolicyFilter>>,

pub experimental_use_profile: Option<bool>,
}

#[derive(Deserialize)]
struct ShellEnvironmentPolicyTomlRaw {
inherit: Option<ShellEnvironmentPolicyInherit>,
ignore_default_excludes: Option<bool>,
exclude: Option<Vec<String>>,
r#set: Option<HashMap<String, String>>,
include_only: Option<Vec<String>>,
filters: Option<BTreeMap<String, ShellEnvironmentPolicyFilter>>,
experimental_use_profile: Option<bool>,
}

impl<'de> Deserialize<'de> for ShellEnvironmentPolicyToml {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = ShellEnvironmentPolicyTomlRaw::deserialize(deserializer)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Destructure all of the fields (similar to RawNetworkRequirementsToml) so we catch new fields only being added to the raw variant at compile time.

if raw.filters.is_some() && (raw.exclude.is_some() || raw.include_only.is_some()) {
return Err(serde::de::Error::custom(
"cannot mix `filters` with legacy `exclude` or `include_only`",
));
}
if let Some(filters) = raw.filters.as_ref() {
let mut patterns = std::collections::HashSet::new();
for pattern in filters.keys() {
if !patterns.insert(pattern.to_ascii_lowercase()) {
return Err(serde::de::Error::custom(format!(
"duplicate shell environment filter `{pattern}` ignoring ASCII case"
)));
}
}
}
Ok(Self {
inherit: raw.inherit,
ignore_default_excludes: raw.ignore_default_excludes,
exclude: raw.exclude,
r#set: raw.r#set,
include_only: raw.include_only,
filters: raw.filters,
experimental_use_profile: raw.experimental_use_profile,
})
}
}

impl From<ShellEnvironmentPolicyToml> for ShellEnvironmentPolicy {
fn from(toml: ShellEnvironmentPolicyToml) -> Self {
// Default to inheriting the full environment when not specified.
let inherit = toml.inherit.unwrap_or(ShellEnvironmentPolicyInherit::All);
let ignore_default_excludes = toml.ignore_default_excludes.unwrap_or(true);
let exclude = toml
.exclude
.unwrap_or_default()
let mut exclude = toml.exclude.unwrap_or_default();
let mut include_only = toml.include_only.unwrap_or_default();
for (pattern, filter) in toml.filters.unwrap_or_default() {
exclude.retain(|candidate| candidate != &pattern);
include_only.retain(|candidate| candidate != &pattern);
match filter {
ShellEnvironmentPolicyFilter::Include => include_only.push(pattern),
ShellEnvironmentPolicyFilter::Exclude => exclude.push(pattern),
}
}
let exclude = exclude
Comment on lines +1012 to +1022

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Old code since during deserialization and merging, we ensure we only have one or the other. Simplify this to use the include/exclude list from one of the two.

.into_iter()
.map(|s| EnvironmentVariablePattern::new_case_insensitive(&s))
.collect();
let r#set = toml.r#set.unwrap_or_default();
let include_only = toml
.include_only
.unwrap_or_default()
let include_only = include_only
.into_iter()
.map(|s| EnvironmentVariablePattern::new_case_insensitive(&s))
.collect();
Expand Down
Loading
Loading