mod layer_io; #[cfg(target_os = "macos")] mod macos; #[cfg(test)] mod tests; use self::layer_io::LoadedConfigLayers; use crate::CONFIG_OVERRIDE_TOML_FILE; use crate::CONFIG_TOML_FILE; use crate::ProfileV2Name; use crate::cloud_requirements::CloudRequirementsLoader; use crate::config_requirements::ConfigRequirementsToml; use crate::config_requirements::ConfigRequirementsWithSources; use crate::config_requirements::RequirementSource; use crate::config_requirements::SandboxModeRequirement; use crate::config_toml::ConfigToml; use crate::config_toml::ProjectConfig; use crate::diagnostics::ConfigError; use crate::diagnostics::config_error_from_toml; use crate::diagnostics::first_layer_config_error_from_entries as typed_first_layer_config_error_from_entries; use crate::diagnostics::io_error_from_config_error; 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::state::ConfigLayerEntry; use crate::state::ConfigLayerStack; use crate::state::ConfigLoadOptions; use crate::state::LoaderOverrides; 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; use crate::thread_config::ThreadConfigContext; use crate::thread_config::ThreadConfigLoader; use codex_app_server_protocol::ConfigLayerSource; use codex_file_system::ExecutorFileSystem; use codex_git_utils::resolve_root_git_project_for_trust; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::TrustLevel; use codex_protocol::protocol::AskForApproval; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBufGuard; use dunce::canonicalize as normalize_path; use serde::Deserialize; use std::io; use std::path::Path; #[cfg(windows)] use std::path::PathBuf; use toml::Value as TomlValue; #[cfg(unix)] 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", "apps_mcp_product_sku", "model_provider", "model_providers", "notify", "profile", "profiles", "experimental_realtime_ws_base_url", "otel", ]; async fn first_layer_config_error_from_entries(layers: &[ConfigLayerEntry]) -> Option { typed_first_layer_config_error_from_entries::(layers, CONFIG_TOML_FILE).await } /// To build up the set of admin-enforced constraints, we build up from multiple /// configuration layers in the following order, but a constraint defined in an /// earlier layer cannot be overridden by a later layer: /// /// - cloud: managed cloud requirements /// - admin: managed preferences (*) /// - system `/etc/codex/requirements.toml` (Unix) or /// `%ProgramData%\OpenAI\Codex\requirements.toml` (Windows) /// /// For backwards compatibility, we also load from /// `managed_config.toml` and map it to `requirements.toml`. /// /// Configuration is built up from multiple layers in the following order: /// /// - admin: managed preferences (*) /// - system `/etc/codex/config.toml` (Unix) or /// `%ProgramData%\OpenAI\Codex\config.toml` (Windows) /// - user `${CODEX_HOME}/config.toml` /// - profile `${CODEX_HOME}/.config.toml`, when selected /// - cwd `${PWD}/config.toml` (loaded but disabled when the directory is untrusted) /// - tree parent directories up to root looking for `./.codex/config.toml` (loaded but disabled when untrusted) /// - repo `$(git rev-parse --show-toplevel)/.codex/config.toml` (loaded but disabled when untrusted) /// - runtime e.g., --config flags, model selector in UI /// /// (*) Only available on macOS via managed device profiles. /// /// See https://developers.openai.com/codex/security for details. /// /// When loading the config stack for a thread, there should be a `cwd` /// associated with it such that `cwd` should be `Some(...)`. Only for /// thread-agnostic config loading (e.g., for the app server's `/config` /// endpoint) should `cwd` be `None`. #[allow(clippy::too_many_arguments)] pub async fn load_config_layers_state( fs: &dyn ExecutorFileSystem, codex_home: &Path, cwd: Option, cli_overrides: &[(String, TomlValue)], options: impl Into, cloud_requirements: CloudRequirementsLoader, thread_config_loader: &dyn ThreadConfigLoader, ) -> io::Result { let ConfigLoadOptions { loader_overrides: overrides, strict_config, } = options.into(); let active_user_profile = overrides.user_config_profile.clone(); let ignore_managed_requirements = overrides.ignore_managed_requirements; let ignore_user_config = overrides.ignore_user_config; let ignore_user_and_project_exec_policy_rules = overrides.ignore_user_and_project_exec_policy_rules; let mut config_requirements_toml = ConfigRequirementsWithSources::default(); if !ignore_managed_requirements { if let Some(requirements) = cloud_requirements.get().await.map_err(io::Error::other)? { merge_requirements_with_remote_sandbox_config( &mut config_requirements_toml, RequirementSource::CloudRequirements, requirements, ); } #[cfg(target_os = "macos")] macos::load_managed_admin_requirements_toml( &mut config_requirements_toml, overrides .macos_managed_config_requirements_base64 .as_deref(), ) .await?; // Honor the system requirements.toml location. let requirements_toml_file = system_requirements_toml_file_with_overrides(&overrides)?; load_requirements_toml(fs, &mut config_requirements_toml, &requirements_toml_file).await?; } // Make a best-effort to support the legacy `managed_config.toml` as a // requirements specification. let loaded_config_layers = layer_io::load_config_layers_internal(fs, codex_home, overrides.clone(), strict_config) .await?; if !ignore_managed_requirements { load_requirements_from_legacy_scheme( &mut config_requirements_toml, loaded_config_layers.clone(), ) .await?; } let thread_config_context = ThreadConfigContext { thread_id: None, cwd: cwd.clone(), }; let thread_config_layers = thread_config_loader .load_config_layers(thread_config_context) .await .map_err(io::Error::other)?; let mut layers = Vec::::new(); let cli_overrides_layer = if cli_overrides.is_empty() { None } else { let cli_overrides_layer = build_cli_overrides_layer(cli_overrides); let base_dir = cwd .as_ref() .map(AbsolutePathBuf::as_path) .unwrap_or(codex_home); if strict_config { validate_cli_overrides_strictly(&cli_overrides_layer, base_dir)?; } Some(resolve_relative_paths_in_config_toml( cli_overrides_layer, base_dir, )?) }; // Include an entry for the "system" config folder, loading its config.toml, // if it exists. let system_config_toml_file = system_config_toml_file_with_overrides(&overrides)?; let system_layer = load_config_toml_for_required_layer( fs, &system_config_toml_file, strict_config, |config_toml| { ConfigLayerEntry::new( ConfigLayerSource::System { file: system_config_toml_file.clone(), }, config_toml, ) }, ) .await?; layers.push(system_layer); if let Some(system_override_layer) = load_optional_override_layer( fs, &system_config_toml_file, strict_config, |override_file, config_toml| { ConfigLayerEntry::new( ConfigLayerSource::SystemOverride { file: override_file, }, config_toml, ) }, ) .await? { layers.push(system_override_layer); } // Add the base user config layer. When profile-v2 is selected, add the // profile config as a second user layer on top so the profile only needs to // contain overrides. let active_user_file = overrides.user_config_path(codex_home)?; let base_user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home); let base_user_layer = load_user_config_layer( fs, &base_user_file, /*profile*/ None, ignore_user_config, strict_config, ) .await?; if let Some(active_user_profile) = active_user_profile.as_ref() && base_user_layer.config.as_table().is_some_and(|config| { config .get("profiles") .and_then(TomlValue::as_table) .is_some_and(|profiles| profiles.contains_key(active_user_profile.as_str())) }) { return Err(io::Error::new( io::ErrorKind::InvalidData, format!( "--profile-v2 `{active_user_profile}` cannot be used while {} contains legacy `[profiles.{active_user_profile}]` config; move those settings into {} or remove `[profiles.{active_user_profile}]`", base_user_file.as_path().display(), active_user_file.as_path().display() ), )); } layers.push(base_user_layer); if !ignore_user_config && let Some(user_override_layer) = load_optional_override_layer( fs, &base_user_file, strict_config, |override_file, config_toml| { ConfigLayerEntry::new( ConfigLayerSource::UserOverride { file: override_file, }, config_toml, ) }, ) .await? { layers.push(user_override_layer); } if active_user_file != base_user_file { layers.push( load_user_config_layer( fs, &active_user_file, active_user_profile.as_ref(), ignore_user_config, strict_config, ) .await?, ); } 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 { merge_toml_values(&mut merged_so_far, &layer.config); } if let Some(cli_overrides_layer) = cli_overrides_layer.as_ref() { merge_toml_values(&mut merged_so_far, cli_overrides_layer); } let project_root_markers = match project_root_markers_from_config(&merged_so_far) { Ok(markers) => markers.unwrap_or_else(default_project_root_markers), Err(err) => { if let Some(config_error) = first_layer_config_error_from_entries(&layers).await { return Err(io_error_from_config_error( io::ErrorKind::InvalidData, config_error, /*source*/ None, )); } return Err(err); } }; let project_trust_context = match project_trust_context( fs, &merged_so_far, &cwd, &project_root_markers, codex_home, &active_user_file, ) .await { Ok(context) => context, Err(err) => { let source = err .get_ref() .and_then(|err| err.downcast_ref::()) .cloned(); if let Some(config_error) = first_layer_config_error_from_entries(&layers).await { return Err(io_error_from_config_error( io::ErrorKind::InvalidData, config_error, source, )); } return Err(err); } }; let project_layers = load_project_layers( fs, &cwd, &project_trust_context.project_root, &project_trust_context, codex_home, strict_config, ) .await?; 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. if let Some(cli_overrides_layer) = cli_overrides_layer { layers.push(ConfigLayerEntry::new( ConfigLayerSource::SessionFlags, cli_overrides_layer, )); } for thread_config_layer in thread_config_layers { insert_layer_by_precedence(&mut layers, thread_config_layer); } // Make a best-effort to support the legacy `managed_config.toml` as a // config layer on top of everything else. For fields in // `managed_config.toml` that do not have an equivalent in // `ConfigRequirements`, note users can still override these values on a // per-turn basis in the TUI and VS Code. let LoadedConfigLayers { managed_config, managed_config_from_mdm, } = loaded_config_layers; if let Some(config) = managed_config { let managed_parent = config.file.as_path().parent().ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidData, format!( "Managed config file {} has no parent directory", config.file.as_path().display() ), ) })?; let managed_config = resolve_relative_paths_in_config_toml(config.managed_config, managed_parent)?; layers.push(ConfigLayerEntry::new( ConfigLayerSource::LegacyManagedConfigTomlFromFile { file: config.file }, managed_config, )); } if let Some(config) = managed_config_from_mdm { // As a general rule, config from MDM should _not_ include relative // paths, starting with `./`, but a path starting with `~/` _is_ a // supported use case. Because resolve_relative_paths_in_config_toml() // relies on AbsolutePathBufGuard to resolve `~/`, we must supply a // value for base_dir, so codex_home is as good a value as any. let managed_config = resolve_relative_paths_in_config_toml(config.managed_config, codex_home)?; layers.push(ConfigLayerEntry::new_with_raw_toml( ConfigLayerSource::LegacyManagedConfigTomlFromMdm, managed_config, config.raw_toml, )); } 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); Ok(match startup_warnings { Some(startup_warnings) => config_layer_stack.with_startup_warnings(startup_warnings), None => config_layer_stack, }) } async fn load_user_config_layer( fs: &dyn ExecutorFileSystem, user_file: &AbsolutePathBuf, profile: Option<&ProfileV2Name>, ignore_user_config: bool, strict_config: bool, ) -> io::Result { let profile = profile.map(ToString::to_string); if ignore_user_config { return Ok(ConfigLayerEntry::new( ConfigLayerSource::User { file: user_file.clone(), profile, }, TomlValue::Table(toml::map::Map::new()), )); } load_config_toml_for_required_layer(fs, user_file, strict_config, |config_toml| { ConfigLayerEntry::new( ConfigLayerSource::User { file: user_file.clone(), profile: profile.clone(), }, config_toml, ) }) .await } fn insert_layer_by_precedence(layers: &mut Vec, layer: ConfigLayerEntry) { match layers .iter() .position(|existing| existing.name.precedence() > layer.name.precedence()) { Some(index) => layers.insert(index, layer), None => layers.push(layer), } } /// Attempts to load a config.toml file from `config_toml`. /// - If the file exists and is valid TOML, passes the parsed `toml::Value` to /// `create_entry` and returns the resulting layer entry. /// - If the file does not exist, uses an empty `Table` with `create_entry` and /// returns the resulting layer entry. /// - If there is an error reading the file or parsing the TOML, returns an /// error. async fn load_config_toml_for_required_layer( fs: &dyn ExecutorFileSystem, toml_file: &AbsolutePathBuf, strict_config: bool, create_entry: impl FnOnce(TomlValue) -> ConfigLayerEntry, ) -> io::Result { let toml_value = match fs.read_file_text(toml_file, /*sandbox*/ None).await { Ok(contents) => { let config_parent = toml_file.as_path().parent().ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidData, format!( "Config file {} has no parent directory", toml_file.as_path().display() ), ) })?; let config: TomlValue = toml::from_str(&contents).map_err(|err| { let config_error = config_error_from_toml(toml_file.as_path(), &contents, err.clone()); io_error_from_config_error(io::ErrorKind::InvalidData, config_error, Some(err)) })?; if strict_config { validate_config_toml_strictly( toml_file.as_path(), &contents, &config, config_parent, )?; } resolve_relative_paths_in_config_toml(config, config_parent) } Err(e) => { if e.kind() == io::ErrorKind::NotFound { Ok(TomlValue::Table(toml::map::Map::new())) } else { Err(io::Error::new( e.kind(), format!( "Failed to read config file {}: {e}", toml_file.as_path().display() ), )) } } }?; Ok(create_entry(toml_value)) } async fn load_optional_override_layer( fs: &dyn ExecutorFileSystem, config_toml_file: &AbsolutePathBuf, strict_config: bool, create_entry: impl FnOnce(AbsolutePathBuf, TomlValue) -> ConfigLayerEntry, ) -> io::Result> { let parent = config_toml_file.parent().ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidData, format!( "Config file {} has no parent directory", config_toml_file.as_path().display() ), ) })?; let override_file = parent.join(CONFIG_OVERRIDE_TOML_FILE); let Some(config_toml) = layer_io::read_config_from_path( fs, &override_file, /*log_missing_as_info*/ false, strict_config, ) .await? else { return Ok(None); }; let config_toml = resolve_relative_paths_in_config_toml(config_toml, parent.as_path())?; Ok(Some(create_entry(override_file, config_toml))) } fn validate_config_toml_strictly( toml_file: &Path, contents: &str, value: &TomlValue, base_dir: &Path, ) -> io::Result<()> { let _guard = AbsolutePathBufGuard::new(base_dir); if let Some(config_error) = config_error_from_ignored_toml_value_fields::( toml_file, contents, value.clone(), ) { Err(io_error_from_config_error( io::ErrorKind::InvalidData, config_error, /*source*/ None, )) } else { Ok(()) } } fn validate_cli_overrides_strictly( cli_overrides_layer: &TomlValue, base_dir: &Path, ) -> io::Result<()> { let _guard = AbsolutePathBufGuard::new(base_dir); if let Some(ignored_path) = ignored_toml_value_field::(cli_overrides_layer.clone()) { return Err(io::Error::new( io::ErrorKind::InvalidData, format!("unknown configuration field `{ignored_path}` in -c/--config override"), )); } if let Some(ignored_path) = unknown_feature_toml_value_field(cli_overrides_layer) { return Err(io::Error::new( io::ErrorKind::InvalidData, format!("unknown configuration field `{ignored_path}` in -c/--config override"), )); } Ok(()) } /// If available, apply requirements from the platform system /// `requirements.toml` location to `config_requirements_toml` by filling in /// any unset fields. #[doc(hidden)] pub async fn load_requirements_toml( fs: &dyn ExecutorFileSystem, config_requirements_toml: &mut ConfigRequirementsWithSources, requirements_toml_file: &AbsolutePathBuf, ) -> io::Result<()> { match fs .read_file_text(requirements_toml_file, /*sandbox*/ None) .await { Ok(contents) => { let requirements_parent = requirements_toml_file.parent().ok_or_else(|| { io::Error::new( io::ErrorKind::InvalidData, format!( "Requirements file {} has no parent directory", requirements_toml_file.as_ref().display() ), ) })?; let _guard = AbsolutePathBufGuard::new(requirements_parent.as_path()); let requirements_config: ConfigRequirementsToml = toml::from_str(&contents).map_err(|e| { io::Error::new( io::ErrorKind::InvalidData, format!( "Error parsing requirements file {}: {e}", requirements_toml_file.as_path().display(), ), ) })?; merge_requirements_with_remote_sandbox_config( config_requirements_toml, RequirementSource::SystemRequirementsToml { file: requirements_toml_file.clone(), }, requirements_config, ); } Err(e) => { if e.kind() != io::ErrorKind::NotFound { return Err(io::Error::new( e.kind(), format!( "Failed to read requirements file {}: {e}", requirements_toml_file.as_path().display(), ), )); } } } Ok(()) } #[cfg(unix)] fn system_requirements_toml_file() -> io::Result { AbsolutePathBuf::from_absolute_path(Path::new("/etc/codex/requirements.toml")) } #[cfg(windows)] fn system_requirements_toml_file() -> io::Result { windows_system_requirements_toml_file() } fn system_requirements_toml_file_with_overrides( overrides: &LoaderOverrides, ) -> io::Result { match &overrides.system_requirements_path { Some(path) => AbsolutePathBuf::from_absolute_path(path), None => system_requirements_toml_file(), } } #[cfg(unix)] pub fn system_config_toml_file() -> io::Result { AbsolutePathBuf::from_absolute_path(Path::new(SYSTEM_CONFIG_TOML_FILE_UNIX)) } #[cfg(windows)] pub fn system_config_toml_file() -> io::Result { windows_system_config_toml_file() } fn system_config_toml_file_with_overrides( overrides: &LoaderOverrides, ) -> io::Result { match &overrides.system_config_path { Some(path) => AbsolutePathBuf::from_absolute_path(path), None => system_config_toml_file(), } } #[cfg(windows)] fn windows_codex_system_dir() -> PathBuf { let program_data = windows_program_data_dir_from_known_folder().unwrap_or_else(|err| { tracing::warn!( error = %err, "Failed to resolve ProgramData known folder; using default path" ); PathBuf::from(DEFAULT_PROGRAM_DATA_DIR_WINDOWS) }); program_data.join("OpenAI").join("Codex") } #[cfg(windows)] fn windows_system_requirements_toml_file() -> io::Result { let requirements_toml_file = windows_codex_system_dir().join("requirements.toml"); AbsolutePathBuf::try_from(requirements_toml_file) } #[cfg(windows)] fn windows_system_config_toml_file() -> io::Result { let config_toml_file = windows_codex_system_dir().join("config.toml"); AbsolutePathBuf::try_from(config_toml_file) } #[cfg(windows)] fn windows_program_data_dir_from_known_folder() -> io::Result { use std::ffi::OsString; use std::os::windows::ffi::OsStringExt; use windows_sys::Win32::System::Com::CoTaskMemFree; use windows_sys::Win32::UI::Shell::FOLDERID_ProgramData; use windows_sys::Win32::UI::Shell::KF_FLAG_DEFAULT; use windows_sys::Win32::UI::Shell::SHGetKnownFolderPath; let mut path_ptr = std::ptr::null_mut::(); let known_folder_flags = u32::try_from(KF_FLAG_DEFAULT).map_err(|_| { io::Error::other(format!( "KF_FLAG_DEFAULT did not fit in u32: {KF_FLAG_DEFAULT}" )) })?; // Known folder IDs reference: // https://learn.microsoft.com/en-us/windows/win32/shell/knownfolderid // SAFETY: SHGetKnownFolderPath initializes path_ptr with a CoTaskMem-allocated, // null-terminated UTF-16 string on success. let hr = unsafe { SHGetKnownFolderPath(&FOLDERID_ProgramData, known_folder_flags, 0, &mut path_ptr) }; if hr != 0 { return Err(io::Error::other(format!( "SHGetKnownFolderPath(FOLDERID_ProgramData) failed with HRESULT {hr:#010x}" ))); } if path_ptr.is_null() { return Err(io::Error::other( "SHGetKnownFolderPath(FOLDERID_ProgramData) returned a null pointer", )); } // SAFETY: path_ptr is a valid null-terminated UTF-16 string allocated by // SHGetKnownFolderPath and must be freed with CoTaskMemFree. let path = unsafe { let mut len = 0usize; while *path_ptr.add(len) != 0 { len += 1; } let wide = std::slice::from_raw_parts(path_ptr, len); let path = PathBuf::from(OsString::from_wide(wide)); CoTaskMemFree(path_ptr.cast()); path }; Ok(path) } async fn load_requirements_from_legacy_scheme( config_requirements_toml: &mut ConfigRequirementsWithSources, loaded_config_layers: LoadedConfigLayers, ) -> io::Result<()> { // In this implementation, earlier layers cannot be overwritten by later // layers, so list managed_config_from_mdm first because it has the highest // precedence. let LoadedConfigLayers { managed_config, managed_config_from_mdm, } = loaded_config_layers; for (source, config) in managed_config_from_mdm .map(|config| { ( RequirementSource::LegacyManagedConfigTomlFromMdm, config.managed_config, ) }) .into_iter() .chain(managed_config.map(|c| { ( RequirementSource::LegacyManagedConfigTomlFromFile { file: c.file }, c.managed_config, ) })) { let legacy_config: LegacyManagedConfigToml = config.try_into().map_err(|err: toml::de::Error| { io::Error::new( io::ErrorKind::InvalidData, format!("Failed to parse config requirements as TOML: {err}"), ) })?; merge_requirements_with_remote_sandbox_config( config_requirements_toml, source, ConfigRequirementsToml::from(legacy_config), ); } Ok(()) } pub(super) fn merge_requirements_with_remote_sandbox_config( target: &mut ConfigRequirementsWithSources, source: RequirementSource, mut requirements: ConfigRequirementsToml, ) { if requirements.remote_sandbox_config.is_some() { let host_name = crate::host_name(); requirements.apply_remote_sandbox_config(host_name.as_deref()); } target.merge_unset_fields(source, requirements); } struct ProjectTrustContext { project_root: AbsolutePathBuf, project_root_key: String, project_root_lookup_keys: Vec, checkout_root: Option, repo_root: Option, repo_root_key: Option, repo_root_lookup_keys: Option>, projects_trust: std::collections::HashMap, user_config_file: AbsolutePathBuf, } #[derive(Deserialize)] struct ProjectTrustConfigToml { projects: Option>, } struct ProjectTrustDecision { trust_level: Option, trust_key: String, } impl ProjectTrustDecision { fn is_trusted(&self) -> bool { matches!(self.trust_level, Some(TrustLevel::Trusted)) } } impl ProjectTrustContext { fn decision_for_dir(&self, dir: &AbsolutePathBuf) -> ProjectTrustDecision { for dir_key in normalized_project_trust_keys(dir.as_path()) { if let Some((trust_key, trust_level)) = project_trust_for_lookup_key(&self.projects_trust, &dir_key) { return ProjectTrustDecision { trust_level: Some(trust_level), trust_key, }; } } for project_root_key in &self.project_root_lookup_keys { if let Some((trust_key, trust_level)) = project_trust_for_lookup_key(&self.projects_trust, project_root_key) { return ProjectTrustDecision { trust_level: Some(trust_level), trust_key, }; } } if let Some(repo_root_lookup_keys) = self.repo_root_lookup_keys.as_ref() { for repo_root_key in repo_root_lookup_keys { if let Some((trust_key, trust_level)) = project_trust_for_lookup_key(&self.projects_trust, repo_root_key) { return ProjectTrustDecision { trust_level: Some(trust_level), trust_key, }; } } } ProjectTrustDecision { trust_level: None, trust_key: self .repo_root_key .clone() .unwrap_or_else(|| self.project_root_key.clone()), } } fn disabled_reason_for_decision(&self, decision: &ProjectTrustDecision) -> Option { if decision.is_trusted() { return None; } let gated_features = "project-local config, hooks, and exec policies"; let trust_key = decision.trust_key.as_str(); let user_config_file = self.user_config_file.as_path().display(); match decision.trust_level { Some(TrustLevel::Untrusted) => Some(format!( "{trust_key} is marked as untrusted in {user_config_file}. To load {gated_features}, mark it trusted." )), _ => Some(format!( "To load {gated_features}, add {trust_key} as a trusted project in {user_config_file}." )), } } fn root_checkout_hooks_folder_for_dir(&self, dir: &AbsolutePathBuf) -> Option { let checkout_root = self.checkout_root.as_ref()?; let repo_root = self.repo_root.as_ref()?; // Regular checkouts resolve both paths to the same root; linked worktrees do not. if checkout_root == repo_root { return None; } let relative_dir = dir.as_path().strip_prefix(checkout_root.as_path()).ok()?; Some(repo_root.join(relative_dir).join(".codex")) } } fn project_layer_entry( dot_codex_folder: &AbsolutePathBuf, config: TomlValue, disabled_reason: Option, hooks_config_folder_override: Option, ) -> ConfigLayerEntry { project_config_layer_entry( ConfigLayerSource::Project { dot_codex_folder: dot_codex_folder.clone(), }, config, disabled_reason, hooks_config_folder_override, ) } fn project_override_layer_entry( dot_codex_folder: &AbsolutePathBuf, config: TomlValue, disabled_reason: Option, hooks_config_folder_override: Option, ) -> ConfigLayerEntry { project_config_layer_entry( ConfigLayerSource::ProjectOverride { dot_codex_folder: dot_codex_folder.clone(), }, config, disabled_reason, hooks_config_folder_override, ) } fn project_config_layer_entry( source: ConfigLayerSource, config: TomlValue, disabled_reason: Option, hooks_config_folder_override: Option, ) -> ConfigLayerEntry { let entry = if let Some(reason) = disabled_reason { ConfigLayerEntry::new_disabled(source, config, reason) } else { ConfigLayerEntry::new(source, config) }; entry.with_hooks_config_folder_override(hooks_config_folder_override) } fn sanitize_project_config(config: &mut TomlValue) -> Vec { 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( config_path: AbsolutePathBuf, ignored_keys: &[String], ) -> String { 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, cwd: &AbsolutePathBuf, project_root_markers: &[String], config_base_dir: &Path, user_config_file: &AbsolutePathBuf, ) -> io::Result { let project_trust_config: ProjectTrustConfigToml = { let _guard = AbsolutePathBufGuard::new(config_base_dir); merged_config .clone() .try_into() .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))? }; let project_root = find_project_root(fs, cwd, project_root_markers).await?; let projects = project_trust_config.projects.unwrap_or_default(); let project_root_lookup_keys = normalized_project_trust_keys(project_root.as_path()); let project_root_key = project_root_lookup_keys .first() .cloned() .unwrap_or_else(|| project_trust_key(project_root.as_path())); let checkout_root = find_git_checkout_root(fs, cwd).await; let repo_root = resolve_root_git_project_for_trust(fs, cwd).await; let repo_root_lookup_keys = repo_root .as_ref() .map(|root| normalized_project_trust_keys(root.as_path())); let repo_root_key = repo_root_lookup_keys .as_ref() .and_then(|keys| keys.first().cloned()); let projects_trust = projects .into_iter() .filter_map(|(key, project)| project.trust_level.map(|trust_level| (key, trust_level))) .collect(); Ok(ProjectTrustContext { project_root, project_root_key, project_root_lookup_keys, checkout_root, repo_root, repo_root_key, repo_root_lookup_keys, projects_trust, user_config_file: user_config_file.clone(), }) } /// Canonicalize the path and convert it to a string to be used as a key in the /// projects trust map. On Windows, strips UNC, when possible, to try to ensure /// that different paths that point to the same location have the same key. pub fn project_trust_key(path: &Path) -> String { normalized_project_trust_keys(path) .into_iter() .next() .unwrap_or_else(|| normalize_project_trust_lookup_key(path.to_string_lossy().to_string())) } fn normalized_project_trust_keys(path: &Path) -> Vec { let normalized_path = normalize_project_trust_lookup_key(path.to_string_lossy().to_string()); let normalized_canonical_path = normalize_project_trust_lookup_key( normalize_path(path) .unwrap_or_else(|_| path.to_path_buf()) .to_string_lossy() .to_string(), ); if normalized_path == normalized_canonical_path { vec![normalized_canonical_path] } else { vec![normalized_canonical_path, normalized_path] } } fn normalize_project_trust_lookup_key(key: String) -> String { if cfg!(windows) { key.to_ascii_lowercase() } else { key } } fn project_trust_for_lookup_key( projects_trust: &std::collections::HashMap, lookup_key: &str, ) -> Option<(String, TrustLevel)> { if let Some(trust_level) = projects_trust.get(lookup_key).copied() { return Some((lookup_key.to_string(), trust_level)); } let mut normalized_matches: Vec<_> = projects_trust .iter() .filter(|(key, _)| normalize_project_trust_lookup_key((*key).clone()) == lookup_key) .collect(); normalized_matches.sort_by(|(left, _), (right, _)| left.cmp(right)); normalized_matches .first() .map(|(key, trust_level)| ((**key).clone(), **trust_level)) } /// Takes a `toml::Value` parsed from a config.toml file and walks through it, /// resolving any `AbsolutePathBuf` fields against `base_dir`, returning a new /// `toml::Value` with the same shape but with paths resolved. /// /// This ensures that multiple config layers can be merged together correctly /// even if they were loaded from different directories. #[doc(hidden)] pub fn resolve_relative_paths_in_config_toml( value_from_config_toml: TomlValue, base_dir: &Path, ) -> io::Result { // Use the serialize/deserialize round-trip to convert the // `toml::Value` into a `ConfigToml` with `AbsolutePath let _guard = AbsolutePathBufGuard::new(base_dir); let Ok(resolved) = value_from_config_toml.clone().try_into::() else { return Ok(value_from_config_toml); }; drop(_guard); let resolved_value = TomlValue::try_from(resolved).map_err(|e| { io::Error::new( io::ErrorKind::InvalidData, format!("Failed to serialize resolved config: {e}"), ) })?; Ok(copy_shape_from_original( &value_from_config_toml, &resolved_value, )) } /// Ensure that every field in `original` is present in the returned /// `toml::Value`, taking the value from `resolved` where possible. This ensures /// the fields that we "removed" during the serialize/deserialize round-trip in /// `resolve_config_paths` are preserved, out of an abundance of caution. fn copy_shape_from_original(original: &TomlValue, resolved: &TomlValue) -> TomlValue { match (original, resolved) { (TomlValue::Table(original_table), TomlValue::Table(resolved_table)) => { let mut table = toml::map::Map::new(); for (key, original_value) in original_table { let resolved_value = resolved_table.get(key).unwrap_or(original_value); table.insert( key.clone(), copy_shape_from_original(original_value, resolved_value), ); } TomlValue::Table(table) } (TomlValue::Array(original_array), TomlValue::Array(resolved_array)) => { let mut items = Vec::new(); for (index, original_value) in original_array.iter().enumerate() { let resolved_value = resolved_array.get(index).unwrap_or(original_value); items.push(copy_shape_from_original(original_value, resolved_value)); } TomlValue::Array(items) } (_, resolved_value) => resolved_value.clone(), } } async fn find_project_root( fs: &dyn ExecutorFileSystem, cwd: &AbsolutePathBuf, project_root_markers: &[String], ) -> io::Result { if project_root_markers.is_empty() { return Ok(cwd.clone()); } for ancestor in cwd.ancestors() { for marker in project_root_markers { let marker_path = ancestor.join(marker); if fs .get_metadata(&marker_path, /*sandbox*/ None) .await .is_ok() { return Ok(ancestor); } } } Ok(cwd.clone()) } async fn find_git_checkout_root( fs: &dyn ExecutorFileSystem, cwd: &AbsolutePathBuf, ) -> Option { let base = match fs.get_metadata(cwd, /*sandbox*/ None).await { Ok(metadata) if metadata.is_directory => cwd.clone(), _ => cwd.parent()?, }; for dir in base.ancestors() { let dot_git = dir.join(".git"); if fs.get_metadata(&dot_git, /*sandbox*/ None).await.is_ok() { return Some(dir); } } None } struct LoadedProjectLayers { layers: Vec, startup_warnings: Vec, } #[derive(Clone, Copy)] enum ProjectConfigFileKind { Base, Override, } impl ProjectConfigFileKind { fn file_name(self) -> &'static str { match self { Self::Base => CONFIG_TOML_FILE, Self::Override => CONFIG_OVERRIDE_TOML_FILE, } } fn parse_error_label(self) -> &'static str { match self { Self::Base => "project config file", Self::Override => "project override config file", } } } /// 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, strict_config: bool, ) -> io::Result { 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()); let mut dirs = cwd .ancestors() .scan(false, |done, a| { if *done { None } else { if &a == project_root { *done = true; } Some(a) } }) .collect::>(); 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 .get_metadata(&dot_codex_abs, /*sandbox*/ None) .await .map(|metadata| metadata.is_directory) .unwrap_or(false) { continue; } let decision = trust_context.decision_for_dir(&dir); let disabled_reason = trust_context.disabled_reason_for_decision(&decision); let hooks_config_folder_override = trust_context.root_checkout_hooks_folder_for_dir(&dir); let dot_codex_normalized = normalize_path(dot_codex_abs.as_path()).unwrap_or_else(|_| dot_codex_abs.to_path_buf()); if dot_codex_abs == codex_home_abs || dot_codex_normalized == codex_home_normalized { continue; } let config_file = dot_codex_abs.join(ProjectConfigFileKind::Base.file_name()); match fs.read_file_text(&config_file, /*sandbox*/ None).await { Ok(contents) => { let entry = load_project_layer_from_contents( fs, ProjectConfigFileKind::Base, &config_file, &contents, &dot_codex_abs, disabled_reason.clone(), hooks_config_folder_override.clone(), decision.is_trusted(), strict_config, disabled_reason.is_none(), &mut startup_warnings, ) .await?; layers.push(entry); } Err(err) if err.kind() == io::ErrorKind::NotFound => { // If there is no config.toml file, record an empty entry // for this project layer, as this may still have subfolders // that are significant in the overall ConfigLayerStack. let config = merge_root_checkout_project_hooks( fs, TomlValue::Table(toml::map::Map::new()), hooks_config_folder_override.as_ref(), decision.is_trusted(), ProjectConfigFileKind::Base.file_name(), ) .await?; layers.push(project_layer_entry( &dot_codex_abs, config, disabled_reason.clone(), hooks_config_folder_override.clone(), )); } Err(err) => { let config_file_display = config_file.as_path().display(); return Err(io::Error::new( err.kind(), format!( "Failed to read {} {config_file_display}: {err}", ProjectConfigFileKind::Base.parse_error_label() ), )); } } let override_file = dot_codex_abs.join(ProjectConfigFileKind::Override.file_name()); match fs.read_file_text(&override_file, /*sandbox*/ None).await { Ok(contents) => { let entry = load_project_layer_from_contents( fs, ProjectConfigFileKind::Override, &override_file, &contents, &dot_codex_abs, disabled_reason.clone(), hooks_config_folder_override.clone(), decision.is_trusted(), strict_config, disabled_reason.is_none(), &mut startup_warnings, ) .await?; layers.push(entry); } Err(err) if err.kind() == io::ErrorKind::NotFound => { if hooks_config_folder_override.is_some() { let config = merge_root_checkout_project_hooks( fs, TomlValue::Table(toml::map::Map::new()), hooks_config_folder_override.as_ref(), decision.is_trusted(), ProjectConfigFileKind::Override.file_name(), ) .await?; layers.push(project_override_layer_entry( &dot_codex_abs, config, disabled_reason.clone(), hooks_config_folder_override.clone(), )); } } Err(err) => { let override_file_display = override_file.as_path().display(); return Err(io::Error::new( err.kind(), format!( "Failed to read {} {override_file_display}: {err}", ProjectConfigFileKind::Override.parse_error_label() ), )); } } } Ok(LoadedProjectLayers { layers, startup_warnings, }) } #[allow(clippy::too_many_arguments)] async fn load_project_layer_from_contents( fs: &dyn ExecutorFileSystem, kind: ProjectConfigFileKind, config_file: &AbsolutePathBuf, contents: &str, dot_codex_abs: &AbsolutePathBuf, disabled_reason: Option, hooks_config_folder_override: Option, is_trusted: bool, strict_config: bool, is_enabled: bool, startup_warnings: &mut Vec, ) -> io::Result { let config: TomlValue = match toml::from_str(contents) { Ok(config) => config, Err(err) => { if is_trusted { let config_file_display = config_file.as_path().display(); return Err(io::Error::new( io::ErrorKind::InvalidData, format!( "Error parsing {} {config_file_display}: {err}", kind.parse_error_label() ), )); } return Ok(project_layer_entry_for_kind( kind, dot_codex_abs, TomlValue::Table(toml::map::Map::new()), disabled_reason, hooks_config_folder_override, )); } }; let config = prepare_project_layer_config( fs, config_file, kind.file_name(), contents, config, dot_codex_abs, hooks_config_folder_override.as_ref(), is_trusted, strict_config, is_enabled, startup_warnings, ) .await?; Ok(project_layer_entry_for_kind( kind, dot_codex_abs, config, disabled_reason, hooks_config_folder_override, )) } fn project_layer_entry_for_kind( kind: ProjectConfigFileKind, dot_codex_abs: &AbsolutePathBuf, config: TomlValue, disabled_reason: Option, hooks_config_folder_override: Option, ) -> ConfigLayerEntry { match kind { ProjectConfigFileKind::Base => project_layer_entry( dot_codex_abs, config, disabled_reason, hooks_config_folder_override, ), ProjectConfigFileKind::Override => project_override_layer_entry( dot_codex_abs, config, disabled_reason, hooks_config_folder_override, ), } } #[allow(clippy::too_many_arguments)] async fn prepare_project_layer_config( fs: &dyn ExecutorFileSystem, config_file: &AbsolutePathBuf, config_file_name: &str, contents: &str, mut config: TomlValue, dot_codex_abs: &AbsolutePathBuf, hooks_config_folder_override: Option<&AbsolutePathBuf>, is_trusted: bool, strict_config: bool, is_enabled: bool, startup_warnings: &mut Vec, ) -> io::Result { if is_enabled && strict_config { validate_config_toml_strictly( config_file.as_path(), contents, &config, dot_codex_abs.as_path(), )?; } let ignored_project_config_keys = sanitize_project_config(&mut config); let config = resolve_relative_paths_in_config_toml(config, dot_codex_abs.as_path())?; let config = merge_root_checkout_project_hooks( fs, config, hooks_config_folder_override, is_trusted, config_file_name, ) .await?; if is_enabled && !ignored_project_config_keys.is_empty() { startup_warnings.push(project_ignored_config_keys_warning( config_file.clone(), &ignored_project_config_keys, )); } Ok(config) } /// For linked worktrees, preserve ordinary worktree-local project config while /// replacing only hook declarations with the matching root-checkout layer. async fn merge_root_checkout_project_hooks( fs: &dyn ExecutorFileSystem, mut config: TomlValue, hooks_config_folder_override: Option<&AbsolutePathBuf>, is_trusted: bool, config_toml_file: &str, ) -> io::Result { let Some(hooks_config_folder) = hooks_config_folder_override else { return Ok(config); }; let hooks_config_file = hooks_config_folder.join(config_toml_file); let root_config = match fs .read_file_text(&hooks_config_file, /*sandbox*/ None) .await { Ok(contents) => { let parsed: TomlValue = match toml::from_str(&contents) { Ok(parsed) => parsed, Err(err) => { if is_trusted { return Err(io::Error::new( io::ErrorKind::InvalidData, format!( "Error parsing project hooks config file {}: {err}", hooks_config_file.as_path().display() ), )); } TomlValue::Table(toml::map::Map::new()) } }; resolve_relative_paths_in_config_toml(parsed, hooks_config_folder.as_path())? } Err(err) if err.kind() == io::ErrorKind::NotFound => { TomlValue::Table(toml::map::Map::new()) } Err(err) => { return Err(io::Error::new( err.kind(), format!( "Failed to read project hooks config file {}: {err}", hooks_config_file.as_path().display() ), )); } }; let Some(config_table) = config.as_table_mut() else { return Ok(config); }; config_table.remove("hooks"); if let Some(hooks) = root_config.get("hooks") { config_table.insert("hooks".to_string(), hooks.clone()); } Ok(config) } /// The legacy mechanism for specifying admin-enforced configuration is to read /// from a file like `/etc/codex/managed_config.toml` that has the same /// structure as `config.toml` where fields like `approval_policy` can specify /// exactly one value rather than a list of allowed values. /// /// If present, re-interpret `managed_config.toml` as a `requirements.toml` /// where each specified field is treated as a constraint. Most fields allow /// only the specified value. `approvals_reviewer = "auto_review"` also allows /// `user` so people can opt out of the auto-reviewer. #[derive(Deserialize, Debug, Clone, Default, PartialEq)] struct LegacyManagedConfigToml { approval_policy: Option, approvals_reviewer: Option, sandbox_mode: Option, } impl From for ConfigRequirementsToml { fn from(legacy: LegacyManagedConfigToml) -> Self { let mut config_requirements_toml = ConfigRequirementsToml::default(); let LegacyManagedConfigToml { approval_policy, approvals_reviewer, sandbox_mode, } = legacy; if let Some(approval_policy) = approval_policy { config_requirements_toml.allowed_approval_policies = Some(vec![approval_policy]); } if let Some(approvals_reviewer) = approvals_reviewer { let mut allowed_reviewers = vec![approvals_reviewer]; if approvals_reviewer == ApprovalsReviewer::AutoReview { allowed_reviewers.push(ApprovalsReviewer::User); } config_requirements_toml.allowed_approvals_reviewers = Some(allowed_reviewers); } if let Some(sandbox_mode) = sandbox_mode { let required_mode: SandboxModeRequirement = sandbox_mode.into(); // Allowing read-only is a requirement for Codex to function correctly. // So in this backfill path, we append read-only if it's not already specified. let mut allowed_modes = vec![SandboxModeRequirement::ReadOnly]; if required_mode != SandboxModeRequirement::ReadOnly { allowed_modes.push(required_mode); } config_requirements_toml.allowed_sandbox_modes = Some(allowed_modes); } config_requirements_toml } } // Cannot name this `mod tests` because of tests.rs in this folder. #[cfg(test)] mod unit_tests { use super::*; #[cfg(windows)] use std::path::Path; use tempfile::tempdir; #[test] fn ensure_resolve_relative_paths_in_config_toml_preserves_all_fields() -> anyhow::Result<()> { let tmp = tempdir()?; let base_dir = tmp.path(); let contents = r#" # This is a field recognized by config.toml that is an AbsolutePathBuf in # the ConfigToml struct. model_instructions_file = "./some_file.md" # This is a field recognized by config.toml. model = "gpt-1000" # This is a field not recognized by config.toml. foo = "xyzzy" "#; let user_config: TomlValue = toml::from_str(contents)?; let normalized_toml_value = resolve_relative_paths_in_config_toml(user_config, base_dir)?; let mut expected_toml_value = toml::map::Map::new(); expected_toml_value.insert( "model_instructions_file".to_string(), TomlValue::String( AbsolutePathBuf::resolve_path_against_base("./some_file.md", base_dir) .as_path() .to_string_lossy() .to_string(), ), ); expected_toml_value.insert( "model".to_string(), TomlValue::String("gpt-1000".to_string()), ); expected_toml_value.insert("foo".to_string(), TomlValue::String("xyzzy".to_string())); assert_eq!(normalized_toml_value, TomlValue::Table(expected_toml_value)); Ok(()) } #[test] fn legacy_managed_config_backfill_includes_read_only_sandbox_mode() { let legacy = LegacyManagedConfigToml { approval_policy: None, approvals_reviewer: None, sandbox_mode: Some(SandboxMode::WorkspaceWrite), }; let requirements = ConfigRequirementsToml::from(legacy); assert_eq!( requirements.allowed_sandbox_modes, Some(vec![ SandboxModeRequirement::ReadOnly, SandboxModeRequirement::WorkspaceWrite ]) ); } #[test] fn legacy_managed_config_backfill_allows_user_when_guardian_is_required() { let legacy = LegacyManagedConfigToml { approval_policy: None, approvals_reviewer: Some(ApprovalsReviewer::AutoReview), sandbox_mode: None, }; let requirements = ConfigRequirementsToml::from(legacy); assert_eq!( requirements.allowed_approvals_reviewers, Some(vec![ApprovalsReviewer::AutoReview, ApprovalsReviewer::User,]) ); } #[test] fn legacy_managed_config_backfill_preserves_user_only_approvals_reviewer() { let legacy = LegacyManagedConfigToml { approval_policy: None, approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_mode: None, }; let requirements = ConfigRequirementsToml::from(legacy); assert_eq!( requirements.allowed_approvals_reviewers, Some(vec![ApprovalsReviewer::User]) ); } #[cfg(windows)] #[test] fn windows_system_requirements_toml_file_uses_expected_suffix() { let expected = windows_program_data_dir_from_known_folder() .unwrap_or_else(|_| PathBuf::from(DEFAULT_PROGRAM_DATA_DIR_WINDOWS)) .join("OpenAI") .join("Codex") .join("requirements.toml"); assert_eq!( windows_system_requirements_toml_file() .expect("requirements.toml path") .as_path(), expected.as_path() ); assert!( windows_system_requirements_toml_file() .expect("requirements.toml path") .as_path() .ends_with(Path::new("OpenAI").join("Codex").join("requirements.toml")) ); } #[cfg(windows)] #[test] fn windows_system_config_toml_file_uses_expected_suffix() { let expected = windows_program_data_dir_from_known_folder() .unwrap_or_else(|_| PathBuf::from(DEFAULT_PROGRAM_DATA_DIR_WINDOWS)) .join("OpenAI") .join("Codex") .join("config.toml"); assert_eq!( windows_system_config_toml_file() .expect("config.toml path") .as_path(), expected.as_path() ); assert!( windows_system_config_toml_file() .expect("config.toml path") .as_path() .ends_with(Path::new("OpenAI").join("Codex").join("config.toml")) ); } }