fix: ignore dangerous project-level config keys (#20098)

## Description
Ignore these top-level config keys when loading project-scoped
config.toml files:
```
    "openai_base_url",
    "chatgpt_base_url",
    "model_provider",
    "model_providers",
    "profile",
    "profiles",
    "experimental_realtime_ws_base_url",
```

## What changed

- Add a project-local config denylist for credential-routing fields such
as `openai_base_url`, `chatgpt_base_url`, `model_provider`,
`model_providers`, `profile`, `profiles`, and
`experimental_realtime_ws_base_url`.
- Strip those fields from project config layers before they participate
in effective config merging, while leaving safe project-local settings
intact.
- Track ignored project-local keys on config layers and surface a
startup warning telling users to move those settings to user-level
`config.toml` if they intentionally need them.
- Update profile behavior coverage so project-local `profile` /
`profiles` entries are ignored instead of overriding user-level profile
selection.

## Verification

- `cargo test -p codex-config`
- `cargo test -p codex-core
project_layer_ignores_unsupported_config_keys`
- `cargo test -p codex-core project_profiles_are_ignored`
- `cargo test -p codex-core config::config_loader_tests`
This commit is contained in:
Owen Lin authored and GitHub committed 2026-04-30 23:03:01 +00:00
1 parent 6014b6679f
commit 9ddb267e9c
5 files changed
+239 -10

No files matched your search

+76 -5
View File
@@ -47,6 +47,21 @@ const SYSTEM_CONFIG_TOML_FILE_UNIX: &str = "/etc/codex/config.toml";
#[cfg(windows)]
const DEFAULT_PROGRAM_DATA_DIR_WINDOWS: &str = r"C:\ProgramData";
// Project-local config comes from repository contents, so it should not get to
// choose where a user's credentials are sent or which local commands are run.
// These settings are still supported from user, system, managed, and runtime
// config layers.
const PROJECT_LOCAL_CONFIG_DENYLIST: &[&str] = &[
"openai_base_url",
"chatgpt_base_url",
"model_provider",
"model_providers",
"notify",
"profile",
"profiles",
"experimental_realtime_ws_base_url",
];
async fn first_layer_config_error_from_entries(layers: &[ConfigLayerEntry]) -> Option<ConfigError> {
typed_first_layer_config_error_from_entries::<ConfigToml>(layers, CONFIG_TOML_FILE).await
}
@@ -197,6 +212,7 @@ pub async fn load_config_layers_state(
};
layers.push(user_layer);
let mut startup_warnings = None;
if let Some(cwd) = cwd {
let mut merged_so_far = TomlValue::Table(toml::map::Map::new());
for layer in &layers {
@@ -253,7 +269,8 @@ pub async fn load_config_layers_state(
codex_home,
)
.await?;
layers.extend(project_layers);
layers.extend(project_layers.layers);
startup_warnings = Some(project_layers.startup_warnings);
}
// Add a layer for runtime overrides from the CLI or UI, if any exist.
@@ -309,12 +326,16 @@ pub async fn load_config_layers_state(
));
}
Ok(ConfigLayerStack::new(
let config_layer_stack = ConfigLayerStack::new(
layers,
config_requirements_toml.clone().try_into()?,
config_requirements_toml.into_toml(),
)?
.with_user_and_project_exec_policy_rules_ignored(ignore_user_and_project_exec_policy_rules))
.with_user_and_project_exec_policy_rules_ignored(ignore_user_and_project_exec_policy_rules);
Ok(match startup_warnings {
Some(startup_warnings) => config_layer_stack.with_startup_warnings(startup_warnings),
None => config_layer_stack,
})
}
fn insert_layer_by_precedence(layers: &mut Vec<ConfigLayerEntry>, layer: ConfigLayerEntry) {
@@ -708,6 +729,38 @@ fn project_layer_entry(
}
}
fn sanitize_project_config(config: &mut TomlValue) -> Vec<String> {
let Some(table) = config.as_table_mut() else {
return Vec::new();
};
let mut ignored_keys = Vec::new();
for key in PROJECT_LOCAL_CONFIG_DENYLIST {
if table.remove(*key).is_some() {
ignored_keys.push((*key).to_string());
}
}
ignored_keys
}
fn project_ignored_config_keys_warning(
dot_codex_folder: &AbsolutePathBuf,
ignored_keys: &[String],
) -> String {
let config_path = dot_codex_folder.join(CONFIG_TOML_FILE);
let ignored_keys = ignored_keys.join(", ");
format!(
concat!(
"Ignored unsupported project-local config keys in {config_path}: {ignored_keys}. ",
"If you want these settings to apply, manually set them in your ",
"user-level config.toml."
),
config_path = config_path.display(),
ignored_keys = ignored_keys,
)
}
async fn project_trust_context(
fs: &dyn ExecutorFileSystem,
merged_config: &TomlValue,
@@ -890,18 +943,24 @@ async fn find_project_root(
Ok(cwd.clone())
}
struct LoadedProjectLayers {
layers: Vec<ConfigLayerEntry>,
startup_warnings: Vec<String>,
}
/// Return the appropriate list of layers (each with
/// [ConfigLayerSource::Project] as the source) between `cwd` and
/// `project_root`, inclusive. The list is ordered in _increasing_ precdence,
/// starting from folders closest to `project_root` (which is the lowest
/// precedence) to those closest to `cwd` (which is the highest precedence).
/// Any warnings are stack-level startup messages, not additional config layers.
async fn load_project_layers(
fs: &dyn ExecutorFileSystem,
cwd: &AbsolutePathBuf,
project_root: &AbsolutePathBuf,
trust_context: &ProjectTrustContext,
codex_home: &Path,
) -> io::Result<Vec<ConfigLayerEntry>> {
) -> io::Result<LoadedProjectLayers> {
let codex_home_abs = AbsolutePathBuf::from_absolute_path(codex_home)?;
let codex_home_normalized =
normalize_path(codex_home_abs.as_path()).unwrap_or_else(|_| codex_home_abs.to_path_buf());
@@ -921,6 +980,7 @@ async fn load_project_layers(
dirs.reverse();
let mut layers = Vec::new();
let mut startup_warnings = Vec::new();
for dir in dirs {
let dot_codex_abs = dir.join(".codex");
if !fs
@@ -962,8 +1022,16 @@ async fn load_project_layers(
continue;
}
};
let mut config = config;
let ignored_project_config_keys = sanitize_project_config(&mut config);
let config =
resolve_relative_paths_in_config_toml(config, dot_codex_abs.as_path())?;
if disabled_reason.is_none() && !ignored_project_config_keys.is_empty() {
startup_warnings.push(project_ignored_config_keys_warning(
&dot_codex_abs,
&ignored_project_config_keys,
));
}
let entry = project_layer_entry(&dot_codex_abs, config, disabled_reason.clone());
layers.push(entry);
}
@@ -988,7 +1056,10 @@ async fn load_project_layers(
}
}
Ok(layers)
Ok(LoadedProjectLayers {
layers,
startup_warnings,
})
}
/// The legacy mechanism for specifying admin-enforced configuration is to read
/// from a file like `/etc/codex/managed_config.toml` that has the same
+18
View File
@@ -170,6 +170,12 @@ pub struct ConfigLayerStack {
/// Whether execpolicy should skip `.rules` files from user and project config-layer folders.
ignore_user_and_project_exec_policy_rules: bool,
/// Startup warnings discovered while building this stack.
///
/// `None` means the loader did not check for stack-level warnings, while
/// `Some(vec![])` means it checked and found nothing to report.
startup_warnings: Option<Vec<String>>,
}
impl ConfigLayerStack {
@@ -185,6 +191,7 @@ impl ConfigLayerStack {
requirements,
requirements_toml,
ignore_user_and_project_exec_policy_rules: false,
startup_warnings: None,
})
}
@@ -200,6 +207,15 @@ impl ConfigLayerStack {
self.ignore_user_and_project_exec_policy_rules
}
pub(crate) fn with_startup_warnings(mut self, startup_warnings: Vec<String>) -> Self {
self.startup_warnings = Some(startup_warnings);
self
}
pub fn startup_warnings(&self) -> Option<&[String]> {
self.startup_warnings.as_deref()
}
/// Returns the raw user config layer, if any.
///
/// This does not merge other config layers or apply any requirements.
@@ -239,6 +255,7 @@ impl ConfigLayerStack {
requirements_toml: self.requirements_toml.clone(),
ignore_user_and_project_exec_policy_rules: self
.ignore_user_and_project_exec_policy_rules,
startup_warnings: self.startup_warnings.clone(),
}
}
None => {
@@ -262,6 +279,7 @@ impl ConfigLayerStack {
requirements_toml: self.requirements_toml.clone(),
ignore_user_and_project_exec_policy_rules: self
.ignore_user_and_project_exec_policy_rules,
startup_warnings: self.startup_warnings.clone(),
}
}
}