Repository navigation
[codex] Add keyed shell environment rules to config #28411
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5e5d0df
0e7e760
822b6dc
543af26
a0b18b2
d4e440e
81c349d
84f2597
8cec5f1
ce0ebcf
97d1070
1a2dbaa
2e1dc30
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
|
|
@@ -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()) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can you break this out into a separate 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, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -39,6 +39,34 @@ no_memories_if_mcp_or_web_search = true | |
| ); | ||
| } | ||
|
|
||
| #[test] | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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"); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -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)?; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: Destructure all of the fields (similar to |
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(); | ||
|
|
||
There was a problem hiding this comment.
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.