mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
Add keyed shell environment policy filters (#34590)
## What changed - Add `shell_environment_policy.filters`, mapping environment-variable patterns to `include` or `exclude`, while continuing to accept the legacy `exclude` and `include_only` arrays. - Merge filter keys case-insensitively across config layers so higher-precedence entries can override individual patterns. Switching representations replaces the other representation instead of combining them. - Reject mixed, duplicate, or malformed filter definitions in enabled layers, config writes, and reloads, while retaining the previous session configuration when a reload is invalid. - Make config-manager reads and writes representation-aware and preserve existing formatting when updating individual policy values. ## Testing Add coverage for parsing, schema constraints, layered merging, validation and diagnostics, config writes, override metadata, and invalid session reloads. GitOrigin-RevId: e15464bca53e6e7ef31fcc97537fda60ed5d670a
This commit is contained in:
@@ -182,7 +182,7 @@ impl ConfigManager {
|
||||
&user_config_path,
|
||||
self.loader_overrides.user_config_profile.as_ref(),
|
||||
TomlValue::Table(toml::map::Map::new()),
|
||||
);
|
||||
)?;
|
||||
}
|
||||
self.apply_runtime_feature_enablement(&mut config);
|
||||
self.apply_arg0_paths(&mut config);
|
||||
|
||||
@@ -18,8 +18,11 @@ use codex_config::ConfigLayerSource;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::ConfigLayerStackOrdering;
|
||||
use codex_config::ConfigRequirementsToml;
|
||||
use codex_config::ShellEnvironmentPolicyFilterRepresentation;
|
||||
use codex_config::config_toml::ConfigToml;
|
||||
use codex_config::merge_toml_values;
|
||||
use codex_config::shell_environment_filter_entry;
|
||||
use codex_config::validate_shell_environment_policy_filter_config;
|
||||
use codex_core::config::deserialize_config_toml_with_base;
|
||||
use codex_core::config::edit::ConfigEdit;
|
||||
use codex_core::config::edit::ConfigEditsBuilder;
|
||||
@@ -277,9 +280,15 @@ impl ConfigManager {
|
||||
let mut config_edits = Vec::new();
|
||||
|
||||
for (key_path, value, strategy) in edits.into_iter() {
|
||||
let segments = parse_key_path(&key_path).map_err(|message| {
|
||||
let mut segments = parse_key_path(&key_path).map_err(|message| {
|
||||
ConfigManagerError::write(ConfigWriteErrorCode::ConfigValidationError, message)
|
||||
})?;
|
||||
if (value.is_null() || matches!(strategy, MergeStrategy::Upsert))
|
||||
&& let Some(pattern) = shell_environment_filter_entry(&user_config, &segments)
|
||||
.map(|(pattern, _)| pattern.clone())
|
||||
{
|
||||
segments[2] = pattern;
|
||||
}
|
||||
if !value.is_null() {
|
||||
match segments.as_slice() {
|
||||
[segment] if segment == "profile" => {
|
||||
@@ -297,10 +306,31 @@ impl ConfigManager {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let original_value = value_at_path(&user_config, &segments).cloned();
|
||||
let parsed_value = parse_value(value).map_err(|message| {
|
||||
ConfigManagerError::write(ConfigWriteErrorCode::ConfigValidationError, message)
|
||||
})?;
|
||||
if matches!(strategy, MergeStrategy::Upsert)
|
||||
&& let Some(value) = parsed_value.as_ref()
|
||||
&& matches!(segments.as_slice(), [policy, ..] if policy == "shell_environment_policy")
|
||||
{
|
||||
validate_shell_environment_policy_filter_config(&sparse_overlay(&segments, value))
|
||||
.map_err(|err| {
|
||||
ConfigManagerError::write(
|
||||
ConfigWriteErrorCode::ConfigValidationError,
|
||||
format!("Invalid configuration: {err}"),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
let persist_segments = if matches!(strategy, MergeStrategy::Upsert)
|
||||
&& parsed_value.as_ref().is_some_and(|value| {
|
||||
shell_environment_policy_representation_switch(&user_config, &segments, value)
|
||||
}) {
|
||||
vec!["shell_environment_policy".to_string()]
|
||||
} else {
|
||||
segments.clone()
|
||||
};
|
||||
let original_value = value_at_path(&user_config, &persist_segments).cloned();
|
||||
|
||||
apply_merge(&mut user_config, &segments, parsed_value.as_ref(), strategy).map_err(
|
||||
|err| match err {
|
||||
@@ -311,20 +341,19 @@ impl ConfigManager {
|
||||
},
|
||||
)?;
|
||||
|
||||
let updated_value = value_at_path(&user_config, &segments).cloned();
|
||||
let updated_value = value_at_path(&user_config, &persist_segments).cloned();
|
||||
if original_value != updated_value {
|
||||
let edit = match updated_value {
|
||||
config_edits.push(match updated_value {
|
||||
Some(value) => ConfigEdit::SetPath {
|
||||
segments: segments.clone(),
|
||||
segments: persist_segments,
|
||||
value: toml_value_to_item(&value).map_err(|err| {
|
||||
ConfigManagerError::anyhow("failed to build config edits", err)
|
||||
})?,
|
||||
},
|
||||
None => ConfigEdit::ClearPath {
|
||||
segments: segments.clone(),
|
||||
segments: persist_segments,
|
||||
},
|
||||
};
|
||||
config_edits.push(edit);
|
||||
});
|
||||
}
|
||||
|
||||
parsed_segments.push(segments);
|
||||
@@ -355,7 +384,14 @@ impl ConfigManager {
|
||||
format!("Invalid configuration: {err}"),
|
||||
)
|
||||
})?;
|
||||
let updated_layers = layers.with_user_config(&provided_path, user_config.clone());
|
||||
let updated_layers = layers
|
||||
.with_user_config(&provided_path, user_config.clone())
|
||||
.map_err(|err| {
|
||||
ConfigManagerError::write(
|
||||
ConfigWriteErrorCode::ConfigValidationError,
|
||||
format!("Invalid configuration: {err}"),
|
||||
)
|
||||
})?;
|
||||
let effective = updated_layers.effective_config();
|
||||
validate_config(&effective).map_err(|err| {
|
||||
ConfigManagerError::write(
|
||||
@@ -526,6 +562,16 @@ fn apply_merge(
|
||||
));
|
||||
};
|
||||
|
||||
if matches!(strategy, MergeStrategy::Upsert)
|
||||
&& (shell_environment_policy_representation_switch(root, segments, value)
|
||||
|| (matches!(value_at_path(root, segments), Some(TomlValue::Table(_)))
|
||||
&& matches!(value, TomlValue::Table(_))))
|
||||
{
|
||||
let overlay = sparse_overlay(segments, value);
|
||||
merge_toml_values(root, &overlay);
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let mut current = root;
|
||||
|
||||
for segment in parents {
|
||||
@@ -550,15 +596,6 @@ fn apply_merge(
|
||||
MergeError::Validation("cannot set value on non-table parent".to_string())
|
||||
})?;
|
||||
|
||||
if matches!(strategy, MergeStrategy::Upsert)
|
||||
&& let Some(existing) = table.get_mut(last)
|
||||
&& matches!(existing, TomlValue::Table(_))
|
||||
&& matches!(value, TomlValue::Table(_))
|
||||
{
|
||||
merge_toml_values(existing, value);
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let changed = table
|
||||
.get(last)
|
||||
.map(|existing| Some(existing) != Some(value))
|
||||
@@ -567,6 +604,26 @@ fn apply_merge(
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
fn sparse_overlay(path: &[String], value: &TomlValue) -> TomlValue {
|
||||
path.iter().rev().fold(value.clone(), |value, segment| {
|
||||
TomlValue::Table(toml::map::Map::from_iter([(segment.clone(), value)]))
|
||||
})
|
||||
}
|
||||
|
||||
fn shell_environment_policy_representation_switch(
|
||||
root: &TomlValue,
|
||||
segments: &[String],
|
||||
value: &TomlValue,
|
||||
) -> bool {
|
||||
let current = root
|
||||
.get("shell_environment_policy")
|
||||
.and_then(ShellEnvironmentPolicyFilterRepresentation::from_policy);
|
||||
let edited = ShellEnvironmentPolicyFilterRepresentation::from_edit(segments, value);
|
||||
current
|
||||
.zip(edited)
|
||||
.is_some_and(|(current, edited)| current != edited)
|
||||
}
|
||||
|
||||
fn clear_path(root: &mut TomlValue, segments: &[String]) -> Result<bool, MergeError> {
|
||||
let Some((last, parents)) = segments.split_last() else {
|
||||
return Err(MergeError::Validation(
|
||||
@@ -659,6 +716,12 @@ fn value_at_path<'a>(root: &'a TomlValue, segments: &[String]) -> Option<&'a Tom
|
||||
Some(current)
|
||||
}
|
||||
|
||||
fn value_at_semantic_path<'a>(root: &'a TomlValue, segments: &[String]) -> Option<&'a TomlValue> {
|
||||
shell_environment_filter_entry(root, segments)
|
||||
.map(|(_, value)| value)
|
||||
.or_else(|| value_at_path(root, segments))
|
||||
}
|
||||
|
||||
fn override_message(layer: &ConfigLayerSource) -> String {
|
||||
match layer {
|
||||
ConfigLayerSource::Mdm { domain, key: _ } => {
|
||||
@@ -696,10 +759,10 @@ fn compute_override_metadata(
|
||||
segments: &[String],
|
||||
) -> Option<OverriddenMetadata> {
|
||||
let user_value = match layers.get_active_user_layer() {
|
||||
Some(user_layer) => value_at_path(&user_layer.config, segments),
|
||||
Some(user_layer) => value_at_semantic_path(&user_layer.config, segments),
|
||||
None => return None,
|
||||
};
|
||||
let effective_value = value_at_path(effective, segments);
|
||||
let effective_value = value_at_semantic_path(effective, segments);
|
||||
|
||||
if user_value.is_some() && user_value == effective_value {
|
||||
return None;
|
||||
@@ -739,8 +802,21 @@ fn find_effective_layer(
|
||||
segments: &[String],
|
||||
) -> Option<ConfigLayerMetadata> {
|
||||
for layer in layers.layers_high_to_low() {
|
||||
if let Some(meta) = value_at_path(&layer.config, segments).map(|_| layer.metadata()) {
|
||||
return Some(meta);
|
||||
if value_at_semantic_path(&layer.config, segments).is_some() {
|
||||
return Some(layer.metadata());
|
||||
}
|
||||
|
||||
let Some(layer_representation) = layer
|
||||
.config
|
||||
.get("shell_environment_policy")
|
||||
.and_then(ShellEnvironmentPolicyFilterRepresentation::from_policy)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if ShellEnvironmentPolicyFilterRepresentation::from_path(segments)
|
||||
.is_some_and(|edit_representation| edit_representation != layer_representation)
|
||||
{
|
||||
return Some(layer.metadata());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -965,3 +965,382 @@ beta = "b"
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_writes_apply_path_sensitive_merge_rules() -> Result<()> {
|
||||
let cases = [
|
||||
(
|
||||
r#"[shell_environment_policy]
|
||||
exclude = ["AWS_*"]
|
||||
"#,
|
||||
"shell_environment_policy",
|
||||
serde_json::json!({"filters": {"AWS_*": "include"}}),
|
||||
r#"[shell_environment_policy.filters]
|
||||
"AWS_*" = "include"
|
||||
"#,
|
||||
),
|
||||
(
|
||||
r#"[shell_environment_policy]
|
||||
inherit = "core"
|
||||
exclude = ["AWS_*"]
|
||||
"#,
|
||||
"shell_environment_policy.filters",
|
||||
serde_json::json!({"AWS_*": "include"}),
|
||||
r#"[shell_environment_policy]
|
||||
inherit = "core"
|
||||
|
||||
[shell_environment_policy.filters]
|
||||
"AWS_*" = "include"
|
||||
"#,
|
||||
),
|
||||
(
|
||||
r#"[shell_environment_policy.filters]
|
||||
"AWS_*" = "include"
|
||||
"#,
|
||||
"shell_environment_policy.exclude",
|
||||
serde_json::json!(["AWS_*"]),
|
||||
r#"[shell_environment_policy]
|
||||
exclude = ["AWS_*"]
|
||||
"#,
|
||||
),
|
||||
(
|
||||
r#"[shell_environment_policy]
|
||||
exclude = ["AWS_*"]
|
||||
include_only = ["PATH"]
|
||||
"#,
|
||||
"shell_environment_policy.filters",
|
||||
serde_json::json!({}),
|
||||
r#"[shell_environment_policy.filters]
|
||||
"#,
|
||||
),
|
||||
(
|
||||
r#"[shell_environment_policy.filters]
|
||||
"AWS_*" = "include"
|
||||
"#,
|
||||
"shell_environment_policy.exclude",
|
||||
serde_json::json!([]),
|
||||
r#"[shell_environment_policy]
|
||||
exclude = []
|
||||
"#,
|
||||
),
|
||||
(
|
||||
r#"[shell_environment_policy.filters]
|
||||
"aws_*" = "exclude"
|
||||
"#,
|
||||
"shell_environment_policy.filters",
|
||||
serde_json::json!({"AWS_*": "include"}),
|
||||
r#"[shell_environment_policy.filters]
|
||||
"aws_*" = "include"
|
||||
"#,
|
||||
),
|
||||
(
|
||||
r#"[shell_environment_policy.filters]
|
||||
"aws_*" = "exclude"
|
||||
"#,
|
||||
"shell_environment_policy.filters.AWS_*",
|
||||
serde_json::json!("include"),
|
||||
r#"[shell_environment_policy.filters]
|
||||
"aws_*" = "include"
|
||||
"#,
|
||||
),
|
||||
(
|
||||
r#"[shell_environment_policy.filters]
|
||||
"секрет_*" = "exclude"
|
||||
"#,
|
||||
"shell_environment_policy.filters.СЕКРЕТ_*",
|
||||
serde_json::json!("include"),
|
||||
r#"[shell_environment_policy.filters]
|
||||
"секрет_*" = "include"
|
||||
"#,
|
||||
),
|
||||
(
|
||||
r#"[permissions.dev.network.domains]
|
||||
"example.com" = "deny"
|
||||
"#,
|
||||
"permissions.dev.network.domains",
|
||||
serde_json::json!({"EXAMPLE.COM": "allow"}),
|
||||
r#"[permissions.dev.network.domains]
|
||||
"example.com" = "allow"
|
||||
"#,
|
||||
),
|
||||
(
|
||||
r#"[memories]
|
||||
no_memories_if_mcp_or_web_search = false
|
||||
"#,
|
||||
"memories",
|
||||
serde_json::json!({"disable_on_external_context": true}),
|
||||
r#"[memories]
|
||||
disable_on_external_context = true
|
||||
"#,
|
||||
),
|
||||
];
|
||||
|
||||
for (base, key_path, value, expected) in cases {
|
||||
let tmp = tempdir()?;
|
||||
let path = tmp.path().join(CONFIG_TOML_FILE);
|
||||
std::fs::write(&path, base)?;
|
||||
|
||||
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
|
||||
service
|
||||
.write_value(ConfigValueWriteParams {
|
||||
file_path: Some(path.display().to_string()),
|
||||
key_path: key_path.to_string(),
|
||||
value,
|
||||
merge_strategy: MergeStrategy::Upsert,
|
||||
expected_version: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let updated: TomlValue = toml::from_str(&std::fs::read_to_string(&path)?)?;
|
||||
let expected: TomlValue = toml::from_str(expected)?;
|
||||
assert_eq!(updated, expected);
|
||||
|
||||
service
|
||||
.read(ConfigReadParams {
|
||||
include_layers: false,
|
||||
cwd: None,
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clear_shell_environment_filter_ignores_ascii_case() -> Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let path = tmp.path().join(CONFIG_TOML_FILE);
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"[shell_environment_policy.filters]
|
||||
"aws_*" = "exclude"
|
||||
"keep_*" = "include"
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
|
||||
let response = service
|
||||
.write_value(ConfigValueWriteParams {
|
||||
file_path: Some(path.display().to_string()),
|
||||
key_path: "shell_environment_policy.filters.AWS_*".to_string(),
|
||||
value: serde_json::Value::Null,
|
||||
merge_strategy: MergeStrategy::Upsert,
|
||||
expected_version: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
assert_eq!(response.status, WriteStatus::Ok);
|
||||
assert_eq!(response.overridden_metadata, None);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&path)?,
|
||||
r#"[shell_environment_policy.filters]
|
||||
"keep_*" = "include"
|
||||
"#
|
||||
);
|
||||
service
|
||||
.read(ConfigReadParams {
|
||||
include_layers: false,
|
||||
cwd: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upsert_shell_environment_scalar_preserves_unrelated_formatting() -> Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let path = tmp.path().join(CONFIG_TOML_FILE);
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"[shell_environment_policy]
|
||||
inherit = "all"
|
||||
exclude = [
|
||||
"AWS_*", # keep this comment
|
||||
]
|
||||
set = { KEEP = "1", OTHER = "2" } # keep this inline table
|
||||
"#,
|
||||
)?;
|
||||
|
||||
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
|
||||
service
|
||||
.write_value(ConfigValueWriteParams {
|
||||
file_path: Some(path.display().to_string()),
|
||||
key_path: "shell_environment_policy.inherit".to_string(),
|
||||
value: serde_json::json!("core"),
|
||||
merge_strategy: MergeStrategy::Upsert,
|
||||
expected_version: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&path)?,
|
||||
r#"[shell_environment_policy]
|
||||
inherit = "core"
|
||||
exclude = [
|
||||
"AWS_*", # keep this comment
|
||||
]
|
||||
set = { KEEP = "1", OTHER = "2" } # keep this inline table
|
||||
"#
|
||||
);
|
||||
service
|
||||
.read(ConfigReadParams {
|
||||
include_layers: false,
|
||||
cwd: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upsert_shell_environment_filter_scalar_preserves_formatting_and_version() -> Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let path = tmp.path().join(CONFIG_TOML_FILE);
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"[shell_environment_policy]
|
||||
set = { KEEP = "1", OTHER = "2" } # keep this inline table
|
||||
|
||||
[shell_environment_policy.filters]
|
||||
"AWS_*" = "exclude" # keep this edited comment
|
||||
"KEEP_*" = "include" # keep this untouched comment
|
||||
"#,
|
||||
)?;
|
||||
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
|
||||
|
||||
let response = service
|
||||
.write_value(ConfigValueWriteParams {
|
||||
file_path: Some(path.display().to_string()),
|
||||
key_path: "shell_environment_policy.filters.aws_*".to_string(),
|
||||
value: serde_json::json!("include"),
|
||||
merge_strategy: MergeStrategy::Upsert,
|
||||
expected_version: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&path)?,
|
||||
r#"[shell_environment_policy]
|
||||
set = { KEEP = "1", OTHER = "2" } # keep this inline table
|
||||
|
||||
[shell_environment_policy.filters]
|
||||
"AWS_*" = "include" # keep this edited comment
|
||||
"KEEP_*" = "include" # keep this untouched comment
|
||||
"#
|
||||
);
|
||||
service
|
||||
.write_value(ConfigValueWriteParams {
|
||||
file_path: Some(path.display().to_string()),
|
||||
key_path: "shell_environment_policy.filters.AWS_*".to_string(),
|
||||
value: serde_json::json!("exclude"),
|
||||
merge_strategy: MergeStrategy::Upsert,
|
||||
expected_version: Some(response.version),
|
||||
})
|
||||
.await?;
|
||||
service
|
||||
.read(ConfigReadParams {
|
||||
include_layers: false,
|
||||
cwd: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shell_environment_upsert_rejects_case_variant_filters_in_one_edit() -> Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let path = tmp.path().join(CONFIG_TOML_FILE);
|
||||
let initial = r#"[shell_environment_policy.filters]
|
||||
"KEEP_*" = "include"
|
||||
"#;
|
||||
std::fs::write(&path, initial)?;
|
||||
let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf());
|
||||
|
||||
let error = service
|
||||
.write_value(ConfigValueWriteParams {
|
||||
file_path: Some(path.display().to_string()),
|
||||
key_path: "shell_environment_policy.filters".to_string(),
|
||||
value: serde_json::json!({"AWS_*": "include", "aws_*": "exclude"}),
|
||||
merge_strategy: MergeStrategy::Upsert,
|
||||
expected_version: None,
|
||||
})
|
||||
.await
|
||||
.expect_err("one filter-map edit must not contain case-variant keys");
|
||||
|
||||
assert_eq!(
|
||||
error.write_error_code(),
|
||||
Some(ConfigWriteErrorCode::ConfigValidationError)
|
||||
);
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("duplicate shell environment filter")
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(&path)?, initial);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shell_environment_representation_switch_reports_managed_override() -> Result<()> {
|
||||
let cases = [
|
||||
(
|
||||
r#"[shell_environment_policy]
|
||||
exclude = ["AWS_*"]
|
||||
"#,
|
||||
"shell_environment_policy.filters.AWS_*",
|
||||
serde_json::json!("include"),
|
||||
),
|
||||
(
|
||||
r#"[shell_environment_policy.filters]
|
||||
"AWS_*" = "include"
|
||||
"#,
|
||||
"shell_environment_policy.exclude",
|
||||
serde_json::json!(["AWS_*"]),
|
||||
),
|
||||
];
|
||||
|
||||
for (managed, key_path, value) in cases {
|
||||
let tmp = tempdir()?;
|
||||
let path = tmp.path().join(CONFIG_TOML_FILE);
|
||||
std::fs::write(&path, "")?;
|
||||
let managed_path = tmp.path().join("managed_config.toml");
|
||||
std::fs::write(&managed_path, managed)?;
|
||||
let managed_file = AbsolutePathBuf::try_from(managed_path.clone())?;
|
||||
let service = ConfigManager::new_for_tests(
|
||||
tmp.path().to_path_buf(),
|
||||
vec![],
|
||||
LoaderOverrides::with_managed_config_path_for_tests(managed_path),
|
||||
CloudConfigBundleLoader::default(),
|
||||
);
|
||||
|
||||
let response = service
|
||||
.write_value(ConfigValueWriteParams {
|
||||
file_path: Some(path.display().to_string()),
|
||||
key_path: key_path.to_string(),
|
||||
value,
|
||||
merge_strategy: MergeStrategy::Upsert,
|
||||
expected_version: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
assert_eq!(response.status, WriteStatus::OkOverridden);
|
||||
let overridden = response
|
||||
.overridden_metadata
|
||||
.expect("managed representation should override the user edit");
|
||||
assert_eq!(
|
||||
overridden.overriding_layer.name,
|
||||
ApiConfigLayerSource::LegacyManagedConfigTomlFromFile { file: managed_file }
|
||||
);
|
||||
assert_eq!(overridden.effective_value, serde_json::Value::Null);
|
||||
service
|
||||
.read(ConfigReadParams {
|
||||
include_layers: false,
|
||||
cwd: None,
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -200,6 +200,9 @@ where
|
||||
I: IntoIterator<Item = &'a ConfigLayerEntry>,
|
||||
{
|
||||
for layer in layers {
|
||||
if layer.is_disabled() {
|
||||
continue;
|
||||
}
|
||||
if let Some(contents) = layer.raw_toml() {
|
||||
let source_name = format_config_layer_source(&layer.name, config_toml_file);
|
||||
let Some(base_dir) = layer.raw_toml_base_dir() else {
|
||||
|
||||
@@ -23,6 +23,7 @@ mod project_root_markers;
|
||||
mod requirements_exec_policy;
|
||||
mod requirements_layers;
|
||||
pub mod schema;
|
||||
mod shell_environment_policy;
|
||||
mod skills_config;
|
||||
mod state;
|
||||
mod strict_config;
|
||||
@@ -128,7 +129,9 @@ pub use mcp_types::McpServerOAuthConfig;
|
||||
pub use mcp_types::McpServerToolConfig;
|
||||
pub use mcp_types::McpServerTransportConfig;
|
||||
pub use mcp_types::RawMcpServerConfig;
|
||||
pub use merge::ShellEnvironmentPolicyFilterRepresentation;
|
||||
pub use merge::merge_toml_values;
|
||||
pub use merge::shell_environment_filter_entry;
|
||||
pub use overrides::build_cli_overrides_layer;
|
||||
pub use plugin_edit::PluginConfigEdit;
|
||||
pub use plugin_edit::apply_user_plugin_config_edits;
|
||||
@@ -144,6 +147,7 @@ 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 shell_environment_policy::validate_shell_environment_policy_filter_config;
|
||||
pub use skills_config::BundledSkillsConfig;
|
||||
pub use skills_config::SkillConfig;
|
||||
pub use skills_config::SkillsConfig;
|
||||
|
||||
@@ -23,10 +23,12 @@ use crate::merge::merge_toml_values;
|
||||
use crate::overrides::build_cli_overrides_layer;
|
||||
use crate::project_root_markers::default_project_root_markers;
|
||||
use crate::project_root_markers::project_root_markers_from_config;
|
||||
use crate::shell_environment_policy::ShellEnvironmentPolicyFilterConfigToml;
|
||||
use crate::state::ConfigLayerEntry;
|
||||
use crate::state::ConfigLayerStack;
|
||||
use crate::state::ConfigLoadOptions;
|
||||
use crate::state::LoaderOverrides;
|
||||
use crate::state::validate_enabled_config_layers;
|
||||
use crate::strict_config::config_error_from_ignored_toml_value_fields;
|
||||
use crate::strict_config::ignored_toml_value_field;
|
||||
use crate::strict_config::unknown_feature_toml_value_field;
|
||||
@@ -408,6 +410,21 @@ pub async fn load_config_layers_state(
|
||||
));
|
||||
}
|
||||
|
||||
if let Err(err) = validate_enabled_config_layers(&layers) {
|
||||
if let Some(config_error) = typed_first_layer_config_error_from_entries::<
|
||||
ShellEnvironmentPolicyFilterConfigToml,
|
||||
>(&layers, CONFIG_TOML_FILE)
|
||||
.await
|
||||
{
|
||||
return Err(io_error_from_config_error(
|
||||
io::ErrorKind::InvalidData,
|
||||
config_error,
|
||||
/*source*/ None,
|
||||
));
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let config_layer_stack = ConfigLayerStack::new(
|
||||
layers,
|
||||
config_requirements_toml.clone().try_into()?,
|
||||
|
||||
@@ -3,12 +3,64 @@ use crate::key_aliases::normalized_with_key_aliases;
|
||||
use codex_network_proxy::normalize_host;
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
/// The mutually exclusive shell-environment filter representations.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ShellEnvironmentPolicyFilterRepresentation {
|
||||
Filters,
|
||||
Legacy,
|
||||
}
|
||||
|
||||
impl ShellEnvironmentPolicyFilterRepresentation {
|
||||
/// Returns the representation selected by a policy table, including empty fields.
|
||||
pub fn from_policy(policy: &TomlValue) -> Option<Self> {
|
||||
let policy = policy.as_table()?;
|
||||
if policy.contains_key("filters") {
|
||||
Some(Self::Filters)
|
||||
} else if policy.contains_key("exclude") || policy.contains_key("include_only") {
|
||||
Some(Self::Legacy)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the representation addressed by a dotted config path.
|
||||
pub fn from_path(path: &[String]) -> Option<Self> {
|
||||
match path {
|
||||
[policy, field, ..] if policy == "shell_environment_policy" => match field.as_str() {
|
||||
"filters" => Some(Self::Filters),
|
||||
"exclude" | "include_only" => Some(Self::Legacy),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the representation selected by an edit at `path`.
|
||||
pub fn from_edit(path: &[String], value: &TomlValue) -> Option<Self> {
|
||||
if matches!(path, [policy] if policy == "shell_environment_policy") {
|
||||
Self::from_policy(value)
|
||||
} else {
|
||||
Self::from_path(path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the policy fields that must be removed when this representation is selected.
|
||||
pub fn displaced_fields(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Self::Filters => &["exclude", "include_only"],
|
||||
Self::Legacy => &["filters"],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge config `overlay` into `base`, giving `overlay` precedence.
|
||||
pub fn merge_toml_values(base: &mut TomlValue, overlay: &TomlValue) {
|
||||
merge_toml_values_at_path(base, overlay, &mut Vec::new());
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
@@ -19,6 +71,10 @@ fn merge_toml_values_at_path(base: &mut TomlValue, overlay: &TomlValue, path: &m
|
||||
normalize_network_domain_keys(base_table);
|
||||
normalize_network_domain_keys(&mut overlay_table);
|
||||
}
|
||||
if is_shell_environment_filters_path(path) {
|
||||
normalize_case_insensitive_keys(base_table);
|
||||
normalize_case_insensitive_keys(&mut overlay_table);
|
||||
}
|
||||
|
||||
for (key, value) in overlay_table {
|
||||
path.push(key.clone());
|
||||
@@ -34,6 +90,60 @@ fn merge_toml_values_at_path(base: &mut TomlValue, overlay: &TomlValue, path: &m
|
||||
}
|
||||
}
|
||||
|
||||
fn is_shell_environment_filters_path(path: &[String]) -> bool {
|
||||
matches!(
|
||||
path,
|
||||
[policy, filters]
|
||||
if policy == "shell_environment_policy" && filters == "filters"
|
||||
)
|
||||
}
|
||||
|
||||
/// Switching between legacy arrays and keyed filters replaces lower filter
|
||||
/// fields instead of attempting to reconcile the two representations. Legacy
|
||||
/// arrays already replace wholesale, so reconciling them would add merge
|
||||
/// semantics that the legacy representation never supported.
|
||||
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 Some(overlay_representation) =
|
||||
ShellEnvironmentPolicyFilterRepresentation::from_policy(overlay)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let TomlValue::Table(base) = base else {
|
||||
return;
|
||||
};
|
||||
|
||||
for field in overlay_representation.displaced_fields() {
|
||||
base.remove(*field);
|
||||
}
|
||||
}
|
||||
|
||||
/// Looks up a shell-environment filter pattern while ignoring case.
|
||||
pub fn shell_environment_filter_entry<'a>(
|
||||
root: &'a TomlValue,
|
||||
path: &[String],
|
||||
) -> Option<(&'a String, &'a TomlValue)> {
|
||||
let [policy, filters, pattern] = path else {
|
||||
return None;
|
||||
};
|
||||
if policy != "shell_environment_policy" || filters != "filters" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let pattern = pattern.to_lowercase();
|
||||
root.get(policy)?
|
||||
.get(filters)?
|
||||
.as_table()?
|
||||
.iter()
|
||||
.find(|(candidate, _)| candidate.to_lowercase() == pattern)
|
||||
}
|
||||
|
||||
fn is_permission_network_domains_path(path: &[String]) -> bool {
|
||||
matches!(
|
||||
path,
|
||||
@@ -49,6 +159,13 @@ fn normalize_network_domain_keys(table: &mut toml::map::Map<String, TomlValue>)
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_case_insensitive_keys(table: &mut toml::map::Map<String, TomlValue>) {
|
||||
let entries = std::mem::take(table);
|
||||
for (key, value) in entries {
|
||||
table.insert(key.to_lowercase(), value);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "merge_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -186,3 +186,177 @@ 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_key_case_insensitively() {
|
||||
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_*" = "include"
|
||||
"keep_*" = "include"
|
||||
"#,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_environment_policy_filters_overlay_merges_unicode_keys_case_insensitively() {
|
||||
let mut base = parse_toml(
|
||||
r#"
|
||||
[shell_environment_policy.filters]
|
||||
"СЕКРЕТ_*" = "exclude"
|
||||
"#,
|
||||
);
|
||||
let overlay = parse_toml(
|
||||
r#"
|
||||
[shell_environment_policy.filters]
|
||||
"секрет_*" = "include"
|
||||
"#,
|
||||
);
|
||||
|
||||
merge_toml_values(&mut base, &overlay);
|
||||
|
||||
assert_eq!(base, overlay);
|
||||
}
|
||||
|
||||
#[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"]
|
||||
"#,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_shell_environment_filter_representations_replace_the_other_form() {
|
||||
let cases = [
|
||||
(
|
||||
r#"[shell_environment_policy]
|
||||
exclude = ["AWS_*"]
|
||||
include_only = ["PATH"]
|
||||
"#,
|
||||
r#"[shell_environment_policy.filters]
|
||||
"#,
|
||||
),
|
||||
(
|
||||
r#"[shell_environment_policy.filters]
|
||||
"AWS_*" = "include"
|
||||
"#,
|
||||
r#"[shell_environment_policy]
|
||||
exclude = []
|
||||
"#,
|
||||
),
|
||||
];
|
||||
|
||||
for (base, overlay) in cases {
|
||||
let mut base = parse_toml(base);
|
||||
let overlay = parse_toml(overlay);
|
||||
|
||||
merge_toml_values(&mut base, &overlay);
|
||||
|
||||
assert_eq!(base, overlay);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,12 +144,41 @@ pub fn mcp_servers_schema(schema_gen: &mut SchemaGenerator) -> Schema {
|
||||
|
||||
/// Build the config schema for `config.toml`.
|
||||
pub fn config_schema() -> RootSchema {
|
||||
SchemaSettings::draft07()
|
||||
let mut schema = SchemaSettings::draft07()
|
||||
.with(|settings| {
|
||||
settings.option_add_null_type = false;
|
||||
})
|
||||
.into_generator()
|
||||
.into_root_schema_for::<ConfigToml>()
|
||||
.into_root_schema_for::<ConfigToml>();
|
||||
add_shell_environment_policy_constraints(&mut schema);
|
||||
schema
|
||||
}
|
||||
|
||||
fn add_shell_environment_policy_constraints(schema: &mut RootSchema) {
|
||||
let Some(Schema::Object(policy)) = schema.definitions.get_mut("ShellEnvironmentPolicyToml")
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let all_of = policy
|
||||
.subschemas
|
||||
.get_or_insert_default()
|
||||
.all_of
|
||||
.get_or_insert_default();
|
||||
for fields in [["exclude", "filters"], ["filters", "include_only"]] {
|
||||
all_of.push(Schema::Object(SchemaObject {
|
||||
subschemas: Some(Box::new(SubschemaValidation {
|
||||
not: Some(Box::new(Schema::Object(SchemaObject {
|
||||
object: Some(Box::new(ObjectValidation {
|
||||
required: fields.into_iter().map(str::to_string).collect(),
|
||||
..Default::default()
|
||||
})),
|
||||
..Default::default()
|
||||
}))),
|
||||
..Default::default()
|
||||
})),
|
||||
..Default::default()
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonicalize a JSON value by sorting its keys.
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
use codex_protocol::config_types::EnvironmentVariablePattern;
|
||||
use codex_protocol::config_types::ShellEnvironmentPolicy;
|
||||
use codex_protocol::config_types::ShellEnvironmentPolicyFilter;
|
||||
use codex_protocol::config_types::ShellEnvironmentPolicyInherit;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
/// Policy for building the `env` when spawning a process via shell-like tools.
|
||||
#[derive(Serialize, Debug, Clone, PartialEq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ShellEnvironmentPolicyToml {
|
||||
pub inherit: Option<ShellEnvironmentPolicyInherit>,
|
||||
|
||||
pub ignore_default_excludes: Option<bool>,
|
||||
|
||||
/// Legacy list of regular expressions to exclude.
|
||||
pub exclude: Option<Vec<String>>,
|
||||
|
||||
pub r#set: Option<HashMap<String, String>>,
|
||||
|
||||
/// 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-insensitively across config layers, matching how
|
||||
/// the resulting patterns match environment variable names.
|
||||
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>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct ShellEnvironmentPolicyFilterConfigToml {
|
||||
#[serde(
|
||||
default,
|
||||
rename = "shell_environment_policy",
|
||||
deserialize_with = "deserialize_shell_environment_policy_filters"
|
||||
)]
|
||||
_shell_environment_policy: (),
|
||||
}
|
||||
|
||||
/// Validates only the shell-environment filter representation in a raw config overlay.
|
||||
pub fn validate_shell_environment_policy_filter_config(
|
||||
value: &TomlValue,
|
||||
) -> Result<(), toml::de::Error> {
|
||||
let _: ShellEnvironmentPolicyFilterConfigToml = value.clone().try_into()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn deserialize_shell_environment_policy_filters<'de, D>(deserializer: D) -> Result<(), D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let value = TomlValue::deserialize(deserializer)?;
|
||||
let Some(policy) = value.as_table() else {
|
||||
return Ok(());
|
||||
};
|
||||
let filter_fields = ["exclude", "include_only", "filters"]
|
||||
.into_iter()
|
||||
.filter_map(|field| {
|
||||
policy
|
||||
.get(field)
|
||||
.cloned()
|
||||
.map(|value| (field.to_string(), value))
|
||||
})
|
||||
.collect();
|
||||
let _: ShellEnvironmentPolicyToml = TomlValue::Table(filter_fields)
|
||||
.try_into()
|
||||
.map_err(|error: toml::de::Error| serde::de::Error::custom(error.message()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ShellEnvironmentPolicyToml {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let ShellEnvironmentPolicyTomlRaw {
|
||||
inherit,
|
||||
ignore_default_excludes,
|
||||
exclude,
|
||||
r#set,
|
||||
include_only,
|
||||
filters,
|
||||
experimental_use_profile,
|
||||
} = ShellEnvironmentPolicyTomlRaw::deserialize(deserializer)?;
|
||||
if filters.is_some() && (exclude.is_some() || include_only.is_some()) {
|
||||
return Err(serde::de::Error::custom(
|
||||
"cannot mix `filters` with legacy `exclude` or `include_only`",
|
||||
));
|
||||
}
|
||||
if let Some(filters) = filters.as_ref() {
|
||||
let mut patterns = std::collections::HashSet::new();
|
||||
for pattern in filters.keys() {
|
||||
if !patterns.insert(pattern.to_lowercase()) {
|
||||
return Err(serde::de::Error::custom(format!(
|
||||
"duplicate shell environment filter `{pattern}` ignoring case"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
inherit,
|
||||
ignore_default_excludes,
|
||||
exclude,
|
||||
r#set,
|
||||
include_only,
|
||||
filters,
|
||||
experimental_use_profile,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ShellEnvironmentPolicyToml> for ShellEnvironmentPolicy {
|
||||
fn from(toml: ShellEnvironmentPolicyToml) -> Self {
|
||||
let inherit = toml.inherit.unwrap_or(ShellEnvironmentPolicyInherit::All);
|
||||
let ignore_default_excludes = toml.ignore_default_excludes.unwrap_or(true);
|
||||
let (exclude, include_only) = match toml.filters {
|
||||
Some(filters) => filters.into_iter().fold(
|
||||
(Vec::new(), Vec::new()),
|
||||
|(mut exclude, mut include_only), (pattern, filter)| {
|
||||
match filter {
|
||||
ShellEnvironmentPolicyFilter::Include => include_only.push(pattern),
|
||||
ShellEnvironmentPolicyFilter::Exclude => exclude.push(pattern),
|
||||
}
|
||||
(exclude, include_only)
|
||||
},
|
||||
),
|
||||
None => (
|
||||
toml.exclude.unwrap_or_default(),
|
||||
toml.include_only.unwrap_or_default(),
|
||||
),
|
||||
};
|
||||
|
||||
Self {
|
||||
inherit,
|
||||
ignore_default_excludes,
|
||||
exclude: exclude
|
||||
.into_iter()
|
||||
.map(|pattern| EnvironmentVariablePattern::new_case_insensitive(&pattern))
|
||||
.collect(),
|
||||
r#set: toml.r#set.unwrap_or_default(),
|
||||
include_only: include_only
|
||||
.into_iter()
|
||||
.map(|pattern| EnvironmentVariablePattern::new_case_insensitive(&pattern))
|
||||
.collect(),
|
||||
use_profile: toml.experimental_use_profile.unwrap_or(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "shell_environment_policy_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,109 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn shell_environment_policy_accepts_legacy_lists_or_filters() {
|
||||
let legacy: ShellEnvironmentPolicyToml = toml::from_str(
|
||||
r#"
|
||||
exclude = ["LEGACY_*", "SHARED_*"]
|
||||
include_only = ["PATH", "HOME"]
|
||||
"#,
|
||||
)
|
||||
.expect("legacy arrays should remain valid in config.toml");
|
||||
assert_eq!(
|
||||
legacy,
|
||||
ShellEnvironmentPolicyToml {
|
||||
exclude: Some(vec!["LEGACY_*".to_string(), "SHARED_*".to_string()]),
|
||||
include_only: Some(vec!["PATH".to_string(), "HOME".to_string()]),
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
|
||||
let filtered: ShellEnvironmentPolicyToml = toml::from_str(
|
||||
r#"
|
||||
[filters]
|
||||
"FLIP_TO_EXCLUDE" = "exclude"
|
||||
"FLIP_TO_INCLUDE" = "include"
|
||||
"#,
|
||||
)
|
||||
.expect("filters should be valid in config.toml");
|
||||
assert_eq!(
|
||||
filtered,
|
||||
ShellEnvironmentPolicyToml {
|
||||
filters: Some(BTreeMap::from([
|
||||
(
|
||||
"FLIP_TO_EXCLUDE".to_string(),
|
||||
ShellEnvironmentPolicyFilter::Exclude,
|
||||
),
|
||||
(
|
||||
"FLIP_TO_INCLUDE".to_string(),
|
||||
ShellEnvironmentPolicyFilter::Include,
|
||||
),
|
||||
])),
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
ShellEnvironmentPolicy::from(filtered),
|
||||
ShellEnvironmentPolicy::from(ShellEnvironmentPolicyToml {
|
||||
exclude: Some(vec!["FLIP_TO_EXCLUDE".to_string()]),
|
||||
include_only: Some(vec!["FLIP_TO_INCLUDE".to_string()]),
|
||||
..Default::default()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_environment_policy_rejects_mixed_legacy_lists_and_filters() {
|
||||
let error = toml::from_str::<ShellEnvironmentPolicyToml>(
|
||||
r#"
|
||||
exclude = ["LEGACY_*"]
|
||||
|
||||
[filters]
|
||||
"CANONICAL_*" = "include"
|
||||
"#,
|
||||
)
|
||||
.expect_err("one config layer must not mix legacy lists and filters");
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("cannot mix `filters` with legacy `exclude` or `include_only`")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_environment_policy_rejects_case_variant_filters_within_layer() {
|
||||
let error = toml::from_str::<ShellEnvironmentPolicyToml>(
|
||||
r#"
|
||||
[filters]
|
||||
"AWS_*" = "exclude"
|
||||
"aws_*" = "include"
|
||||
"#,
|
||||
)
|
||||
.expect_err("case-variant filters in one layer should be rejected");
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("duplicate shell environment filter")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_environment_policy_rejects_unicode_case_variant_filters_within_layer() {
|
||||
let error = toml::from_str::<ShellEnvironmentPolicyToml>(
|
||||
r#"
|
||||
[filters]
|
||||
"СЕКРЕТ_*" = "exclude"
|
||||
"секрет_*" = "include"
|
||||
"#,
|
||||
)
|
||||
.expect_err("Unicode case-variant filters in one layer should be rejected");
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("duplicate shell environment filter")
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
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;
|
||||
@@ -10,6 +12,7 @@ use crate::ConfigLayer;
|
||||
use crate::ConfigLayerMetadata;
|
||||
use crate::ConfigLayerSource;
|
||||
use crate::ProfileV2Name;
|
||||
use crate::shell_environment_policy::validate_shell_environment_policy_filter_config;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::collections::HashMap;
|
||||
@@ -275,6 +278,7 @@ impl ConfigLayerStack {
|
||||
requirements: ConfigRequirements,
|
||||
requirements_toml: ConfigRequirementsToml,
|
||||
) -> std::io::Result<Self> {
|
||||
validate_enabled_config_layers(&layers)?;
|
||||
let user_layer_index = verify_layer_ordering(&layers)?;
|
||||
Ok(Self {
|
||||
layers,
|
||||
@@ -376,7 +380,11 @@ impl ConfigLayerStack {
|
||||
/// based on precedence rules. When the stack has both base and profile-v2
|
||||
/// user layers, this updates only the layer whose file matches
|
||||
/// `config_toml`.
|
||||
pub fn with_user_config(&self, config_toml: &AbsolutePathBuf, user_config: TomlValue) -> Self {
|
||||
pub fn with_user_config(
|
||||
&self,
|
||||
config_toml: &AbsolutePathBuf,
|
||||
user_config: TomlValue,
|
||||
) -> std::io::Result<Self> {
|
||||
let profile = self.layers.iter().find_map(|layer| match &layer.name {
|
||||
ConfigLayerSource::User { file, profile } if file == config_toml => profile
|
||||
.as_deref()
|
||||
@@ -391,7 +399,7 @@ impl ConfigLayerStack {
|
||||
config_toml: &AbsolutePathBuf,
|
||||
profile: Option<&ProfileV2Name>,
|
||||
user_config: TomlValue,
|
||||
) -> Self {
|
||||
) -> std::io::Result<Self> {
|
||||
let user_layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::User {
|
||||
file: config_toml.clone(),
|
||||
@@ -399,6 +407,7 @@ impl ConfigLayerStack {
|
||||
},
|
||||
user_config,
|
||||
);
|
||||
validate_enabled_config_layers(std::slice::from_ref(&user_layer))?;
|
||||
|
||||
let mut layers = self.layers.clone();
|
||||
if let Some(index) = layers.iter().position(|layer| {
|
||||
@@ -423,7 +432,7 @@ impl ConfigLayerStack {
|
||||
None
|
||||
}
|
||||
});
|
||||
Self {
|
||||
Ok(Self {
|
||||
layers,
|
||||
user_layer_index,
|
||||
requirements: self.requirements.clone(),
|
||||
@@ -431,7 +440,7 @@ impl ConfigLayerStack {
|
||||
ignore_user_and_project_exec_policy_rules: self
|
||||
.ignore_user_and_project_exec_policy_rules,
|
||||
startup_warnings: self.startup_warnings.clone(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns a new stack with the user layer copied from `other`, preserving
|
||||
@@ -539,6 +548,22 @@ impl ConfigLayerStack {
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates before merging so mixed forms and malformed filter entries cannot be normalized away.
|
||||
pub(crate) fn validate_enabled_config_layers(layers: &[ConfigLayerEntry]) -> std::io::Result<()> {
|
||||
for layer in layers.iter().filter(|layer| !layer.is_disabled()) {
|
||||
validate_shell_environment_policy_filter_config(&layer.config).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)
|
||||
),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensures precedence ordering of config layers is correct. Returns the index
|
||||
/// of the active user config layer, if any.
|
||||
fn verify_layer_ordering(layers: &[ConfigLayerEntry]) -> std::io::Result<Option<usize>> {
|
||||
|
||||
@@ -39,6 +39,127 @@ no_memories_if_mcp_or_web_search = true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_layers_validate_shell_environment_policy() {
|
||||
let layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::SessionFlags,
|
||||
toml::from_str(
|
||||
r#"
|
||||
[shell_environment_policy]
|
||||
exclude = ["LEGACY_*"]
|
||||
|
||||
[shell_environment_policy.filters]
|
||||
"CANONICAL_*" = "include"
|
||||
"#,
|
||||
)
|
||||
.expect("session config"),
|
||||
);
|
||||
|
||||
let error = ConfigLayerStack::new(
|
||||
vec![layer],
|
||||
ConfigRequirements::default(),
|
||||
ConfigRequirementsToml::default(),
|
||||
)
|
||||
.expect_err("enabled layers should be validated");
|
||||
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("cannot mix `filters` with legacy `exclude` or `include_only`")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 enabled_layers_only_validate_representation_sensitive_shell_policy_fields() {
|
||||
let cases = [
|
||||
r#"shell_environment_policy = 17"#,
|
||||
r#"
|
||||
[shell_environment_policy]
|
||||
inherit = "invalid"
|
||||
set = ["invalid"]
|
||||
"#,
|
||||
];
|
||||
|
||||
for contents in cases {
|
||||
let layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::SessionFlags,
|
||||
toml::from_str(contents).expect("session config"),
|
||||
);
|
||||
|
||||
ConfigLayerStack::new(
|
||||
vec![layer],
|
||||
ConfigRequirements::default(),
|
||||
ConfigRequirementsToml::default(),
|
||||
)
|
||||
.expect("unrelated shell policy fields should retain normal overlay semantics");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_user_config_rejects_malformed_shell_policy_filter_fields() {
|
||||
let temp_dir = TempDir::new().expect("tempdir");
|
||||
let config_file = test_user_config_path(&temp_dir, "config.toml");
|
||||
let cases = [
|
||||
r#"
|
||||
[shell_environment_policy]
|
||||
exclude = ["SECRET_*", 17]
|
||||
"#,
|
||||
r#"
|
||||
[shell_environment_policy.filters]
|
||||
"SECRET_*" = "keep"
|
||||
"#,
|
||||
r#"
|
||||
[shell_environment_policy]
|
||||
exclude = ["SECRET_*"]
|
||||
|
||||
[shell_environment_policy.filters]
|
||||
"PATH" = "include"
|
||||
"#,
|
||||
r#"
|
||||
[shell_environment_policy.filters]
|
||||
"SECRET_*" = "exclude"
|
||||
"secret_*" = "include"
|
||||
"#,
|
||||
];
|
||||
|
||||
for contents in cases {
|
||||
let error = ConfigLayerStack::default()
|
||||
.with_user_config(&config_file, toml::from_str(contents).expect("user config"))
|
||||
.expect_err("malformed shell policy filter fields should be rejected");
|
||||
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_user_layer_is_highest_precedence_user_layer() {
|
||||
let temp_dir = TempDir::new().expect("tempdir");
|
||||
@@ -116,10 +237,12 @@ fn with_user_config_updates_matching_user_layer_without_replacing_active_profile
|
||||
)
|
||||
.expect("multiple user layers should be valid");
|
||||
|
||||
let updated = stack.with_user_config(
|
||||
&base_file,
|
||||
toml::from_str(r#"model = "updated-base""#).expect("updated base config"),
|
||||
);
|
||||
let updated = stack
|
||||
.with_user_config(
|
||||
&base_file,
|
||||
toml::from_str(r#"model = "updated-base""#).expect("updated base config"),
|
||||
)
|
||||
.expect("updated user layer should be valid");
|
||||
|
||||
assert_eq!(updated.get_user_config_file(), Some(&profile_file));
|
||||
assert_eq!(
|
||||
|
||||
@@ -12,14 +12,12 @@ pub use crate::mcp_types::McpServerOAuthConfig;
|
||||
pub use crate::mcp_types::McpServerToolConfig;
|
||||
pub use crate::mcp_types::McpServerTransportConfig;
|
||||
pub use crate::mcp_types::RawMcpServerConfig;
|
||||
pub use crate::shell_environment_policy::ShellEnvironmentPolicyToml;
|
||||
pub use codex_protocol::config_types::AltScreenMode;
|
||||
pub use codex_protocol::config_types::ApprovalsReviewer;
|
||||
use codex_protocol::config_types::EnvironmentVariablePattern;
|
||||
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::ShellEnvironmentPolicyInherit;
|
||||
pub use codex_protocol::config_types::WebSearchMode;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use std::collections::BTreeMap;
|
||||
@@ -935,56 +933,6 @@ pub struct SandboxWorkspaceWrite {
|
||||
pub exclude_slash_tmp: bool,
|
||||
}
|
||||
|
||||
/// Policy for building the `env` when spawning a process via shell-like tools.
|
||||
#[derive(Serialize, Deserialize, 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.
|
||||
pub exclude: Option<Vec<String>>,
|
||||
|
||||
pub r#set: Option<HashMap<String, String>>,
|
||||
|
||||
/// List of regular expressions.
|
||||
pub include_only: Option<Vec<String>>,
|
||||
|
||||
pub experimental_use_profile: Option<bool>,
|
||||
}
|
||||
|
||||
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()
|
||||
.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()
|
||||
.into_iter()
|
||||
.map(|s| EnvironmentVariablePattern::new_case_insensitive(&s))
|
||||
.collect();
|
||||
let use_profile = toml.experimental_use_profile.unwrap_or(false);
|
||||
|
||||
Self {
|
||||
inherit,
|
||||
ignore_default_excludes,
|
||||
exclude,
|
||||
r#set,
|
||||
include_only,
|
||||
use_profile,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "types_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -712,7 +712,9 @@ fn policy_from_config_parts(
|
||||
let config_toml_path =
|
||||
AbsolutePathBuf::try_from(std::env::temp_dir().join(CONFIG_TOML_FILE))
|
||||
.expect("absolute config path");
|
||||
config_layer_stack.with_user_config(&config_toml_path, user_config)
|
||||
config_layer_stack
|
||||
.with_user_config(&config_toml_path, user_config)
|
||||
.expect("apps user config should be valid")
|
||||
} else {
|
||||
config_layer_stack
|
||||
};
|
||||
|
||||
@@ -2849,6 +2849,14 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ShellEnvironmentPolicyFilter": {
|
||||
"description": "Assigns a shell environment variable pattern to the include-only or exclude set. Includes do not re-add variables removed by another exclude pattern.",
|
||||
"enum": [
|
||||
"include",
|
||||
"exclude"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ShellEnvironmentPolicyInherit": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -2876,10 +2884,28 @@
|
||||
},
|
||||
"ShellEnvironmentPolicyToml": {
|
||||
"additionalProperties": false,
|
||||
"allOf": [
|
||||
{
|
||||
"not": {
|
||||
"required": [
|
||||
"exclude",
|
||||
"filters"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"not": {
|
||||
"required": [
|
||||
"filters",
|
||||
"include_only"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": "Policy for building the `env` when spawning a process via shell-like tools.",
|
||||
"properties": {
|
||||
"exclude": {
|
||||
"description": "List of regular expressions.",
|
||||
"description": "Legacy list of regular expressions to exclude.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -2888,11 +2914,18 @@
|
||||
"experimental_use_profile": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"filters": {
|
||||
"additionalProperties": {
|
||||
"$ref": "#/definitions/ShellEnvironmentPolicyFilter"
|
||||
},
|
||||
"description": "Pattern actions used by the canonical table representation.\n\nOrdinary 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-insensitively across config layers, matching how the resulting patterns match environment variable names.",
|
||||
"type": "object"
|
||||
},
|
||||
"ignore_default_excludes": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"include_only": {
|
||||
"description": "List of regular expressions.",
|
||||
"description": "Legacy list of regular expressions to include.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -5541,6 +5574,7 @@
|
||||
"default": {
|
||||
"exclude": null,
|
||||
"experimental_use_profile": null,
|
||||
"filters": null,
|
||||
"ignore_default_excludes": null,
|
||||
"include_only": null,
|
||||
"inherit": null,
|
||||
|
||||
@@ -25,12 +25,14 @@ use codex_config::ThreadConfigSource;
|
||||
use codex_config::compose_requirements;
|
||||
use codex_config::config_error_from_ignored_toml_fields;
|
||||
use codex_config::config_error_from_toml;
|
||||
use codex_config::config_error_from_typed_toml;
|
||||
use codex_config::config_toml::ConfigToml;
|
||||
use codex_config::config_toml::ProjectConfig;
|
||||
use codex_config::loader::load_config_layers_state;
|
||||
use codex_config::loader::load_requirements_toml;
|
||||
use codex_config::test_support::CloudConfigBundleFixture;
|
||||
use codex_exec_server::LOCAL_FS;
|
||||
use codex_protocol::config_types::EnvironmentVariablePattern;
|
||||
use codex_protocol::config_types::TrustLevel;
|
||||
use codex_protocol::config_types::WebSearchMode;
|
||||
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS;
|
||||
@@ -381,6 +383,236 @@ unknown_key = true"#;
|
||||
assert_eq!(config_error, &expected_config_error);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_strict_config_rejects_mixed_shell_environment_policy_before_higher_layer_merge() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let contents = r#"
|
||||
[shell_environment_policy]
|
||||
exclude = ["LEGACY_*"]
|
||||
|
||||
[shell_environment_policy.filters]
|
||||
"CANONICAL_*" = "include"
|
||||
"#;
|
||||
let config_path = tmp.path().join(CONFIG_TOML_FILE);
|
||||
std::fs::write(&config_path, contents).expect("write config");
|
||||
|
||||
let err = ConfigBuilder::default()
|
||||
.codex_home(tmp.path().to_path_buf())
|
||||
.fallback_cwd(Some(tmp.path().to_path_buf()))
|
||||
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
|
||||
.cli_overrides(vec![(
|
||||
"shell_environment_policy.filters.HIGHER_*".to_string(),
|
||||
TomlValue::String("exclude".to_string()),
|
||||
)])
|
||||
.strict_config(/*strict_config*/ false)
|
||||
.build()
|
||||
.await
|
||||
.expect_err("one config layer must not mix legacy lists and filters");
|
||||
|
||||
assert_eq!(
|
||||
config_error_from_io(&err),
|
||||
&config_error_from_typed_toml::<ConfigToml>(&config_path, contents)
|
||||
.expect("mixed shell policy should produce a typed config error")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shell_environment_policy_unknown_fields_follow_strict_config() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let contents = r#"
|
||||
[shell_environment_policy]
|
||||
future_field = true
|
||||
"#;
|
||||
let config_path = tmp.path().join(CONFIG_TOML_FILE);
|
||||
std::fs::write(&config_path, contents).expect("write config");
|
||||
|
||||
ConfigBuilder::default()
|
||||
.codex_home(tmp.path().to_path_buf())
|
||||
.fallback_cwd(Some(tmp.path().to_path_buf()))
|
||||
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
|
||||
.strict_config(/*strict_config*/ false)
|
||||
.build()
|
||||
.await
|
||||
.expect("non-strict config should ignore unknown fields");
|
||||
|
||||
let err = ConfigBuilder::default()
|
||||
.codex_home(tmp.path().to_path_buf())
|
||||
.fallback_cwd(Some(tmp.path().to_path_buf()))
|
||||
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
|
||||
.strict_config(/*strict_config*/ true)
|
||||
.build()
|
||||
.await
|
||||
.expect_err("strict config should reject unknown fields");
|
||||
|
||||
assert_eq!(
|
||||
config_error_from_io(&err).message,
|
||||
"unknown configuration field `shell_environment_policy.future_field`"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_strict_config_merges_shell_filter_case_variants_across_layers() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let contents = r#"
|
||||
[shell_environment_policy.filters]
|
||||
"SECRET_TOKEN" = "exclude"
|
||||
"#;
|
||||
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), contents).expect("write config");
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(tmp.path().to_path_buf())
|
||||
.fallback_cwd(Some(tmp.path().to_path_buf()))
|
||||
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
|
||||
.cli_overrides(vec![(
|
||||
"shell_environment_policy.filters.secret_token".to_string(),
|
||||
TomlValue::String("include".to_string()),
|
||||
)])
|
||||
.strict_config(/*strict_config*/ false)
|
||||
.build()
|
||||
.await
|
||||
.expect("higher-priority case-variant filter should override the lower layer");
|
||||
|
||||
assert!(
|
||||
config
|
||||
.permissions
|
||||
.shell_environment_policy
|
||||
.exclude
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
config.permissions.shell_environment_policy.include_only,
|
||||
vec![EnvironmentVariablePattern::new_case_insensitive(
|
||||
"secret_token"
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_strict_config_rejects_malformed_shell_policy_before_representation_conversion() {
|
||||
let cases = [
|
||||
(
|
||||
r#"
|
||||
[shell_environment_policy]
|
||||
exclude = ["SECRET_*", 17]
|
||||
"#,
|
||||
vec![(
|
||||
"shell_environment_policy.filters.PATH".to_string(),
|
||||
TomlValue::String("include".to_string()),
|
||||
)],
|
||||
),
|
||||
(
|
||||
r#"
|
||||
[shell_environment_policy.filters]
|
||||
"SECRET_*" = "keep"
|
||||
"#,
|
||||
vec![(
|
||||
"shell_environment_policy.exclude".to_string(),
|
||||
TomlValue::Array(vec![TomlValue::String("PATH".to_string())]),
|
||||
)],
|
||||
),
|
||||
];
|
||||
|
||||
for (contents, cli_overrides) in cases {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), contents).expect("write config");
|
||||
|
||||
ConfigBuilder::default()
|
||||
.codex_home(tmp.path().to_path_buf())
|
||||
.fallback_cwd(Some(tmp.path().to_path_buf()))
|
||||
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
|
||||
.cli_overrides(cli_overrides)
|
||||
.strict_config(/*strict_config*/ false)
|
||||
.build()
|
||||
.await
|
||||
.expect_err("malformed shell policy should be rejected before conversion");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_strict_config_allows_replaced_shell_policy_fields_outside_filter_representation() {
|
||||
let cases = [
|
||||
(
|
||||
r#"
|
||||
[shell_environment_policy]
|
||||
inherit = "invalid"
|
||||
set = ["invalid"]
|
||||
"#,
|
||||
vec![
|
||||
(
|
||||
"shell_environment_policy.inherit".to_string(),
|
||||
TomlValue::String("core".to_string()),
|
||||
),
|
||||
(
|
||||
"shell_environment_policy.set.PATH".to_string(),
|
||||
TomlValue::String("/bin".to_string()),
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
r#"shell_environment_policy = 17"#,
|
||||
vec![(
|
||||
"shell_environment_policy.inherit".to_string(),
|
||||
TomlValue::String("core".to_string()),
|
||||
)],
|
||||
),
|
||||
];
|
||||
|
||||
for (contents, cli_overrides) in cases {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), contents).expect("write config");
|
||||
|
||||
ConfigBuilder::default()
|
||||
.codex_home(tmp.path().to_path_buf())
|
||||
.fallback_cwd(Some(tmp.path().to_path_buf()))
|
||||
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
|
||||
.cli_overrides(cli_overrides)
|
||||
.strict_config(/*strict_config*/ false)
|
||||
.build()
|
||||
.await
|
||||
.expect("replaced shell policy fields should preserve normal overlay behavior");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn malformed_higher_shell_filter_reports_its_layer_when_lower_fields_are_replaced() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let managed_path = tmp.path().join("managed_config.toml");
|
||||
std::fs::write(
|
||||
tmp.path().join(CONFIG_TOML_FILE),
|
||||
r#"[shell_environment_policy]
|
||||
inherit = "invalid"
|
||||
set = ["invalid"]
|
||||
"#,
|
||||
)
|
||||
.expect("write user config");
|
||||
std::fs::write(
|
||||
&managed_path,
|
||||
r#"[shell_environment_policy]
|
||||
inherit = "core"
|
||||
set = { PATH = "/bin" }
|
||||
|
||||
[shell_environment_policy.filters]
|
||||
"SECRET_*" = "keep"
|
||||
"#,
|
||||
)
|
||||
.expect("write managed config");
|
||||
|
||||
let err = ConfigBuilder::default()
|
||||
.codex_home(tmp.path().to_path_buf())
|
||||
.fallback_cwd(Some(tmp.path().to_path_buf()))
|
||||
.loader_overrides(LoaderOverrides::with_managed_config_path_for_tests(
|
||||
managed_path.clone(),
|
||||
))
|
||||
.strict_config(/*strict_config*/ false)
|
||||
.build()
|
||||
.await
|
||||
.expect_err("malformed shell filter should be rejected");
|
||||
|
||||
let config_error = config_error_from_io(&err);
|
||||
assert_eq!(config_error.path, managed_path);
|
||||
assert!(config_error.message.contains("unknown variant `keep`"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn strict_config_rejects_unknown_cli_override_key() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
|
||||
@@ -73,3 +73,31 @@ fn config_schema_hides_unsupported_inline_mcp_bearer_token() {
|
||||
(false, true),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_environment_policy_schema_rejects_mixed_filter_representations() {
|
||||
let schema_json = config_schema_json().expect("serialize config schema");
|
||||
let schema_value: serde_json::Value =
|
||||
serde_json::from_slice(&schema_json).expect("decode schema json");
|
||||
let constraints = schema_value
|
||||
.pointer("/definitions/ShellEnvironmentPolicyToml/allOf")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.expect("shell environment policy constraints should be an array");
|
||||
let required_pairs = constraints
|
||||
.iter()
|
||||
.map(|constraint| {
|
||||
constraint
|
||||
.pointer("/not/required")
|
||||
.expect("constraint should prohibit a required-field pair")
|
||||
.clone()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
required_pairs,
|
||||
vec![
|
||||
serde_json::json!(["exclude", "filters"]),
|
||||
serde_json::json!(["filters", "include_only"]),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1709,9 +1709,17 @@ impl Session {
|
||||
let state = self.state.lock().await;
|
||||
let mut config = (*state.session_configuration.original_config_do_not_use).clone();
|
||||
for (config_toml_path, user_config) in reloaded_user_configs {
|
||||
config.config_layer_stack = config
|
||||
let config_layer_stack = match config
|
||||
.config_layer_stack
|
||||
.with_user_config(&config_toml_path, user_config);
|
||||
.with_user_config(&config_toml_path, user_config)
|
||||
{
|
||||
Ok(config_layer_stack) => config_layer_stack,
|
||||
Err(err) => {
|
||||
warn!("failed to validate user config while reloading layer: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
config.config_layer_stack = config_layer_stack;
|
||||
}
|
||||
config.tool_suggest =
|
||||
resolve_tool_suggest_config_from_layer_stack(&config.config_layer_stack);
|
||||
|
||||
@@ -1333,6 +1333,45 @@ async fn reload_user_config_layer_updates_effective_apps_config() {
|
||||
assert_eq!(app.destructive_enabled, Some(false));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reload_user_config_layer_keeps_previous_config_for_malformed_shell_policy() {
|
||||
let (session, _turn_context) = make_session_and_context().await;
|
||||
let codex_home = session.codex_home().await;
|
||||
std::fs::create_dir_all(&codex_home).expect("create codex home");
|
||||
let config_toml_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
std::fs::write(&config_toml_path, "[apps.calendar]\nenabled = false\n")
|
||||
.expect("write valid user config");
|
||||
session.reload_user_config_layer().await;
|
||||
let previous_config = session
|
||||
.get_config()
|
||||
.await
|
||||
.config_layer_stack
|
||||
.effective_user_config()
|
||||
.expect("previous user config");
|
||||
|
||||
std::fs::write(
|
||||
&config_toml_path,
|
||||
r#"
|
||||
[apps.calendar]
|
||||
enabled = true
|
||||
|
||||
[shell_environment_policy]
|
||||
exclude = ["SECRET_*", 17]
|
||||
"#,
|
||||
)
|
||||
.expect("write malformed user config");
|
||||
|
||||
session.reload_user_config_layer().await;
|
||||
|
||||
let current_config = session
|
||||
.get_config()
|
||||
.await
|
||||
.config_layer_stack
|
||||
.effective_user_config()
|
||||
.expect("current user config");
|
||||
assert_eq!(current_config, previous_config);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reload_user_config_layer_updates_base_and_selected_profile_layers() {
|
||||
let (session, _turn_context) = make_session_and_context().await;
|
||||
@@ -1438,7 +1477,8 @@ async fn reload_user_config_layer_refreshes_hooks() -> anyhow::Result<()> {
|
||||
config_layer_stack: Some(
|
||||
config
|
||||
.config_layer_stack
|
||||
.with_user_config(&config_toml_path, user_config.clone()),
|
||||
.with_user_config(&config_toml_path, user_config.clone())
|
||||
.expect("hook user config should be valid"),
|
||||
),
|
||||
..codex_hooks::HooksConfig::default()
|
||||
});
|
||||
|
||||
@@ -528,7 +528,7 @@ async fn execve_permission_request_hook_short_circuits_prompt() -> anyhow::Resul
|
||||
},
|
||||
}))
|
||||
.context("build trusted hook state")?,
|
||||
);
|
||||
)?;
|
||||
|
||||
let mut hook_shell_argv = session
|
||||
.user_shell()
|
||||
|
||||
@@ -63,5 +63,7 @@ pub fn trusted_config_layer_stack(
|
||||
state_table.insert(hook.key, hook_state);
|
||||
}
|
||||
|
||||
config_layer_stack.with_user_config(&codex_home.join(CONFIG_TOML_FILE), user_config)
|
||||
config_layer_stack
|
||||
.with_user_config(&codex_home.join(CONFIG_TOML_FILE), user_config)
|
||||
.expect("hook user config should be valid")
|
||||
}
|
||||
|
||||
@@ -2298,10 +2298,12 @@ async fn skills_use_aliases_in_developer_message_under_budget_pressure() {
|
||||
.with_config(move |config| {
|
||||
config.cwd = codex_home_path.abs();
|
||||
let user_config_path = codex_home_path.join("config.toml").abs();
|
||||
config.config_layer_stack = ConfigLayerStack::default().with_user_config(
|
||||
&user_config_path,
|
||||
toml! { skills = { bundled = { enabled = false } } }.into(),
|
||||
);
|
||||
config.config_layer_stack = ConfigLayerStack::default()
|
||||
.with_user_config(
|
||||
&user_config_path,
|
||||
toml! { skills = { bundled = { enabled = false } } }.into(),
|
||||
)
|
||||
.expect("skills user config should be valid");
|
||||
config.model_context_window = Some(12_000);
|
||||
});
|
||||
let codex = builder
|
||||
|
||||
@@ -129,7 +129,8 @@ default_tools_approval_mode = "auto"
|
||||
.expect("apps config should parse");
|
||||
config.config_layer_stack = config
|
||||
.config_layer_stack
|
||||
.with_user_config(&user_config_path, user_config);
|
||||
.with_user_config(&user_config_path, user_config)
|
||||
.expect("apps user config should be valid");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
|
||||
@@ -59,7 +59,8 @@ default_tools_approval_mode = "{approval_mode}"
|
||||
.expect("apps config should parse");
|
||||
config.config_layer_stack = config
|
||||
.config_layer_stack
|
||||
.with_user_config(&user_config_path, user_config);
|
||||
.with_user_config(&user_config_path, user_config)
|
||||
.expect("apps user config should be valid");
|
||||
}
|
||||
|
||||
fn set_default_app_approval_mode_and_reviewer(
|
||||
@@ -84,7 +85,8 @@ default_tools_approval_mode = "{approval_mode}"
|
||||
.expect("apps config should parse");
|
||||
config.config_layer_stack = config
|
||||
.config_layer_stack
|
||||
.with_user_config(&user_config_path, user_config);
|
||||
.with_user_config(&user_config_path, user_config)
|
||||
.expect("apps user config should be valid");
|
||||
}
|
||||
|
||||
async fn submit_user_turn(
|
||||
|
||||
@@ -26,10 +26,13 @@ async fn emits_warning_when_unstable_features_enabled_via_config() {
|
||||
let user_config_path =
|
||||
AbsolutePathBuf::from_absolute_path(config.codex_home.join(CONFIG_TOML_FILE))
|
||||
.expect("absolute user config path");
|
||||
config.config_layer_stack = config.config_layer_stack.with_user_config(
|
||||
&user_config_path,
|
||||
toml! { features = { apply_patch_streaming_events = true } }.into(),
|
||||
);
|
||||
config.config_layer_stack = config
|
||||
.config_layer_stack
|
||||
.with_user_config(
|
||||
&user_config_path,
|
||||
toml! { features = { apply_patch_streaming_events = true } }.into(),
|
||||
)
|
||||
.expect("feature user config should be valid");
|
||||
|
||||
let thread_manager = codex_core::test_support::thread_manager_with_models_provider(
|
||||
CodexAuth::from_api_key("test"),
|
||||
@@ -73,10 +76,13 @@ async fn suppresses_warning_when_configured() {
|
||||
let user_config_path =
|
||||
AbsolutePathBuf::from_absolute_path(config.codex_home.join(CONFIG_TOML_FILE))
|
||||
.expect("absolute user config path");
|
||||
config.config_layer_stack = config.config_layer_stack.with_user_config(
|
||||
&user_config_path,
|
||||
toml! { features = { apply_patch_streaming_events = true } }.into(),
|
||||
);
|
||||
config.config_layer_stack = config
|
||||
.config_layer_stack
|
||||
.with_user_config(
|
||||
&user_config_path,
|
||||
toml! { features = { apply_patch_streaming_events = true } }.into(),
|
||||
)
|
||||
.expect("feature user config should be valid");
|
||||
|
||||
let thread_manager = codex_core::test_support::thread_manager_with_models_provider(
|
||||
CodexAuth::from_api_key("test"),
|
||||
|
||||
@@ -199,6 +199,16 @@ pub enum ShellEnvironmentPolicyInherit {
|
||||
None,
|
||||
}
|
||||
|
||||
/// Assigns a shell environment variable pattern to the include-only or exclude
|
||||
/// set. Includes do not re-add variables removed by another exclude pattern.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub enum ShellEnvironmentPolicyFilter {
|
||||
Include,
|
||||
Exclude,
|
||||
}
|
||||
|
||||
pub type EnvironmentVariablePattern = WildMatchPattern<'*', '?'>;
|
||||
|
||||
/// Deriving the `env` based on this policy works as follows:
|
||||
|
||||
@@ -2512,7 +2512,7 @@ async fn update_feature_flags_disabling_guardian_clears_review_policy_and_restor
|
||||
app.config.config_layer_stack = app
|
||||
.config
|
||||
.config_layer_stack
|
||||
.with_user_config(&config_toml_path, user_config);
|
||||
.with_user_config(&config_toml_path, user_config)?;
|
||||
app.config
|
||||
.features
|
||||
.set_enabled(Feature::GuardianApproval, /*enabled*/ true)?;
|
||||
@@ -2608,7 +2608,7 @@ async fn update_feature_flags_enabling_guardian_overrides_explicit_manual_review
|
||||
app.config.config_layer_stack = app
|
||||
.config
|
||||
.config_layer_stack
|
||||
.with_user_config(&config_toml_path, user_config);
|
||||
.with_user_config(&config_toml_path, user_config)?;
|
||||
app.config.approvals_reviewer = ApprovalsReviewer::User;
|
||||
app.chat_widget
|
||||
.set_approvals_reviewer(ApprovalsReviewer::User);
|
||||
@@ -2677,7 +2677,7 @@ async fn update_feature_flags_disabling_guardian_clears_manual_review_policy_wit
|
||||
app.config.config_layer_stack = app
|
||||
.config
|
||||
.config_layer_stack
|
||||
.with_user_config(&config_toml_path, user_config);
|
||||
.with_user_config(&config_toml_path, user_config)?;
|
||||
app.config
|
||||
.features
|
||||
.set_enabled(Feature::GuardianApproval, /*enabled*/ true)?;
|
||||
|
||||
@@ -355,7 +355,8 @@ async fn plugins_popup_upgrades_user_configured_git_marketplace_from_marketplace
|
||||
"[marketplaces.repo]\nsource_type = \"git\"\nsource = \"https://github.com/owner/repo.git\"\n",
|
||||
)
|
||||
.expect("marketplace config"),
|
||||
);
|
||||
)
|
||||
.expect("marketplace user config should be valid");
|
||||
|
||||
render_loaded_plugins_popup(
|
||||
&mut chat,
|
||||
@@ -426,7 +427,8 @@ async fn marketplace_add_success_refreshes_to_new_marketplace_tab() {
|
||||
"[marketplaces.debug]\nsource_type = \"git\"\nsource = \"https://github.com/owner/debug.git\"\n",
|
||||
)
|
||||
.expect("marketplace config"),
|
||||
);
|
||||
)
|
||||
.expect("marketplace user config should be valid");
|
||||
render_loaded_plugins_popup(
|
||||
&mut chat,
|
||||
plugins_test_response(vec![plugins_test_curated_marketplace(Vec::new())]),
|
||||
@@ -515,7 +517,8 @@ async fn plugins_popup_removes_user_configured_marketplace_flow() {
|
||||
"[marketplaces.repo]\nsource_type = \"git\"\nsource = \"https://github.com/owner/repo.git\"\n",
|
||||
)
|
||||
.expect("marketplace config"),
|
||||
);
|
||||
)
|
||||
.expect("marketplace user config should be valid");
|
||||
|
||||
render_loaded_plugins_popup(
|
||||
&mut chat,
|
||||
|
||||
Reference in New Issue
Block a user