diff --git a/codex-rs/config/src/state.rs b/codex-rs/config/src/state.rs index f4f23880e..92f36509f 100644 --- a/codex-rs/config/src/state.rs +++ b/codex-rs/config/src/state.rs @@ -18,6 +18,8 @@ use toml::Value as TomlValue; #[derive(Debug, Default, Clone)] pub struct LoaderOverrides { pub managed_config_path: Option, + pub ignore_user_config: bool, + pub ignore_user_and_project_exec_policy_rules: bool, //TODO(gt): Add a macos_ prefix to this field and remove the target_os check. #[cfg(target_os = "macos")] pub managed_preferences_base64: Option, @@ -42,6 +44,8 @@ impl LoaderOverrides { pub fn with_managed_config_path_for_tests(managed_config_path: PathBuf) -> Self { Self { managed_config_path: Some(managed_config_path), + ignore_user_config: false, + ignore_user_and_project_exec_policy_rules: false, #[cfg(target_os = "macos")] managed_preferences_base64: Some(String::new()), macos_managed_config_requirements_base64: Some(String::new()), @@ -157,6 +161,9 @@ pub struct ConfigLayerStack { /// sources. This preserves the original allow-lists so they can be /// surfaced via APIs. requirements_toml: ConfigRequirementsToml, + + /// Whether execpolicy should skip `.rules` files from user and project config-layer folders. + ignore_user_and_project_exec_policy_rules: bool, } impl ConfigLayerStack { @@ -171,9 +178,22 @@ impl ConfigLayerStack { user_layer_index, requirements, requirements_toml, + ignore_user_and_project_exec_policy_rules: false, }) } + pub fn with_user_and_project_exec_policy_rules_ignored( + mut self, + ignore_user_and_project_exec_policy_rules: bool, + ) -> Self { + self.ignore_user_and_project_exec_policy_rules = ignore_user_and_project_exec_policy_rules; + self + } + + pub fn ignore_user_and_project_exec_policy_rules(&self) -> bool { + self.ignore_user_and_project_exec_policy_rules + } + /// Returns the raw user config layer, if any. /// /// This does not merge other config layers or apply any requirements. @@ -211,6 +231,8 @@ impl ConfigLayerStack { user_layer_index: self.user_layer_index, requirements: self.requirements.clone(), requirements_toml: self.requirements_toml.clone(), + ignore_user_and_project_exec_policy_rules: self + .ignore_user_and_project_exec_policy_rules, } } None => { @@ -232,6 +254,8 @@ impl ConfigLayerStack { user_layer_index: Some(user_layer_index), requirements: self.requirements.clone(), requirements_toml: self.requirements_toml.clone(), + ignore_user_and_project_exec_policy_rules: self + .ignore_user_and_project_exec_policy_rules, } } } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 7b5373f48..cd55e7e35 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -856,13 +856,28 @@ pub async fn load_config_as_toml_with_cli_overrides( codex_home: &Path, cwd: Option<&AbsolutePathBuf>, cli_overrides: Vec<(String, TomlValue)>, +) -> std::io::Result { + load_config_as_toml_with_cli_and_loader_overrides( + codex_home, + cwd, + cli_overrides, + LoaderOverrides::default(), + ) + .await +} + +pub async fn load_config_as_toml_with_cli_and_loader_overrides( + codex_home: &Path, + cwd: Option<&AbsolutePathBuf>, + cli_overrides: Vec<(String, TomlValue)>, + loader_overrides: LoaderOverrides, ) -> std::io::Result { let config_layer_stack = load_config_layers_state( LOCAL_FS.as_ref(), codex_home, cwd.cloned(), &cli_overrides, - LoaderOverrides::default(), + loader_overrides, CloudRequirementsLoader::default(), ) .await?; diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 9eaeb7149..436759c72 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -128,6 +128,9 @@ pub async fn load_config_layers_state( overrides: LoaderOverrides, cloud_requirements: CloudRequirementsLoader, ) -> io::Result { + 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 let Some(requirements) = cloud_requirements.get().await.map_err(io::Error::other)? { @@ -189,19 +192,28 @@ pub async fn load_config_layers_state( .await?; layers.push(system_layer); - // Add a layer for $CODEX_HOME/config.toml if it exists. Note if the file - // exists, but is malformed, then this error should be propagated to the - // user. + // Add a layer for $CODEX_HOME/config.toml so folder-derived resources such + // as rules/ can still be discovered. When user config is ignored, preserve + // the layer metadata without reading config.toml. let user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home); - let user_layer = load_config_toml_for_required_layer(fs, &user_file, |config_toml| { + let user_layer = if ignore_user_config { ConfigLayerEntry::new( ConfigLayerSource::User { file: user_file.clone(), }, - config_toml, + TomlValue::Table(toml::map::Map::new()), ) - }) - .await?; + } else { + load_config_toml_for_required_layer(fs, &user_file, |config_toml| { + ConfigLayerEntry::new( + ConfigLayerSource::User { + file: user_file.clone(), + }, + config_toml, + ) + }) + .await? + }; layers.push(user_layer); if let Some(cwd) = cwd { @@ -312,11 +324,12 @@ pub async fn load_config_layers_state( )); } - ConfigLayerStack::new( + Ok(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)) } /// Attempts to load a config.toml file from `config_toml`. diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index a8f4eda8e..edc642ec3 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -111,6 +111,63 @@ async fn returns_config_error_for_invalid_user_config_toml() { assert_eq!(config_error, &expected_config_error); } +#[tokio::test] +async fn ignore_user_config_keeps_empty_user_layer() -> std::io::Result<()> { + let tmp = tempdir().expect("tempdir"); + std::fs::write( + tmp.path().join(CONFIG_TOML_FILE), + "model = \"from-user-config\"\ninvalid = [", + ) + .expect("write config"); + + let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd"); + let layers = load_config_layers_state( + LOCAL_FS.as_ref(), + tmp.path(), + Some(cwd), + &[] as &[(String, TomlValue)], + LoaderOverrides { + ignore_user_config: true, + ..Default::default() + }, + CloudRequirementsLoader::default(), + ) + .await?; + + let user_layer = layers + .get_user_layer() + .expect("expected a user layer even when CODEX_HOME/config.toml is ignored"); + assert_eq!( + user_layer.config, + TomlValue::Table(toml::map::Map::new()), + "expected ignored user config to preserve only layer metadata" + ); + assert_eq!(layers.effective_config().get("model"), None); + Ok(()) +} + +#[tokio::test] +async fn ignore_rules_marks_config_stack_for_exec_policy_rule_skip() -> std::io::Result<()> { + let tmp = tempdir().expect("tempdir"); + let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd"); + + let layers = load_config_layers_state( + LOCAL_FS.as_ref(), + tmp.path(), + Some(cwd), + &[] as &[(String, TomlValue)], + LoaderOverrides { + ignore_user_and_project_exec_policy_rules: true, + ..Default::default() + }, + CloudRequirementsLoader::default(), + ) + .await?; + + assert!(layers.ignore_user_and_project_exec_policy_rules()); + Ok(()) +} + #[tokio::test] async fn returns_config_error_for_invalid_managed_config_toml() { let tmp = tempdir().expect("tempdir"); diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 8f0f076f0..dbc175deb 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -7,6 +7,7 @@ use arc_swap::ArcSwap; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigLayerStackOrdering; +use codex_app_server_protocol::ConfigLayerSource; use codex_execpolicy::AmendError; use codex_execpolicy::Decision; use codex_execpolicy::Error as ExecPolicyRuleError; @@ -505,6 +506,14 @@ pub async fn load_exec_policy(config_stack: &ConfigLayerStack) -> Result anyhow::Result<()> { let project_dir = tempdir()?; @@ -575,6 +644,45 @@ fn commands_for_exec_policy_falls_back_for_whitespace_shell_script() { assert_eq!(commands_for_exec_policy(&command), (vec![command], false)); } +#[tokio::test] +async fn ignore_user_config_keeps_user_policy_files() -> std::io::Result<()> { + let temp = tempdir()?; + let codex_home = temp.path().join("home_ignore_user_config"); + let rules_dir = codex_home.join(RULES_DIR_NAME); + fs::create_dir_all(&rules_dir)?; + fs::write( + codex_home.join(CONFIG_TOML_FILE), + "model = \"from-user-config\"\ninvalid = [", + )?; + fs::write( + rules_dir.join("deny-curl.rules"), + r#"prefix_rule(pattern=["curl"], decision="forbidden")"#, + )?; + + let config = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(temp.path().to_path_buf())) + .loader_overrides(LoaderOverrides { + ignore_user_config: true, + ..Default::default() + }) + .build() + .await?; + + let policy = load_exec_policy(&config.config_layer_stack) + .await + .map_err(std::io::Error::other)?; + + assert_eq!( + policy + .check_multiple([vec!["curl".to_string()]].iter(), &|_| Decision::Allow) + .decision, + Decision::Forbidden, + ); + + Ok(()) +} + #[tokio::test] async fn evaluates_heredoc_script_against_prefix_rules() { let command = vec![ diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 0225dc176..4d34e7f36 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -78,6 +78,14 @@ pub struct Cli { #[arg(long = "ephemeral", global = true, default_value_t = false)] pub ephemeral: bool, + /// Do not load `$CODEX_HOME/config.toml`; auth still uses `CODEX_HOME`. + #[arg(long = "ignore-user-config", global = true, default_value_t = false)] + pub ignore_user_config: bool, + + /// Do not load user or project execpolicy `.rules` files. + #[arg(long = "ignore-rules", global = true, default_value_t = false)] + pub ignore_rules: bool, + /// Path to a JSON Schema file describing the model's final response shape. #[arg(long = "output-schema", value_name = "FILE")] pub output_schema: Option, diff --git a/codex-rs/exec/src/cli_tests.rs b/codex-rs/exec/src/cli_tests.rs index bf4884f24..dfa202884 100644 --- a/codex-rs/exec/src/cli_tests.rs +++ b/codex-rs/exec/src/cli_tests.rs @@ -14,10 +14,14 @@ fn resume_parses_prompt_after_global_flags() { "--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check", "--ephemeral", + "--ignore-user-config", + "--ignore-rules", PROMPT, ]); assert!(cli.ephemeral); + assert!(cli.ignore_user_config); + assert!(cli.ignore_rules); let Some(Command::Resume(args)) = cli.command else { panic!("expected resume command"); }; @@ -53,3 +57,16 @@ fn resume_accepts_output_last_message_flag_after_subcommand() { assert_eq!(args.session_id.as_deref(), Some("session-123")); assert_eq!(args.prompt.as_deref(), Some(PROMPT)); } + +#[test] +fn parses_config_isolation_flags() { + let cli = Cli::parse_from([ + "codex-exec", + "--ignore-user-config", + "--ignore-rules", + "summarize", + ]); + + assert!(cli.ignore_user_config); + assert!(cli.ignore_rules); +} diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 35f2ae49a..6093e6e19 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -56,7 +56,7 @@ use codex_core::config::Config; use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_core::config::find_codex_home; -use codex_core::config::load_config_as_toml_with_cli_overrides; +use codex_core::config::load_config_as_toml_with_cli_and_loader_overrides; use codex_core::config::resolve_oss_provider; use codex_core::config_loader::ConfigLoadError; use codex_core::config_loader::LoaderOverrides; @@ -230,6 +230,8 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result skip_git_repo_check, add_dir, ephemeral, + ignore_user_config, + ignore_rules, color, last_message_file, json: json_mode, @@ -297,10 +299,17 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result }; #[allow(clippy::print_stderr)] - let config_toml = match load_config_as_toml_with_cli_overrides( + let loader_overrides = LoaderOverrides { + ignore_user_config, + ignore_user_and_project_exec_policy_rules: ignore_rules, + ..Default::default() + }; + + let config_toml = match load_config_as_toml_with_cli_and_loader_overrides( &codex_home, Some(&config_cwd), cli_kv_overrides.clone(), + loader_overrides.clone(), ) .await { @@ -334,7 +343,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result chatgpt_base_url, ); let run_cli_overrides = cli_kv_overrides.clone(); - let run_loader_overrides = LoaderOverrides::default(); + let run_loader_overrides = loader_overrides.clone(); let run_cloud_requirements = cloud_requirements.clone(); let model_provider = if oss { @@ -399,6 +408,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result let config = ConfigBuilder::default() .cli_overrides(cli_kv_overrides) .harness_overrides(overrides) + .loader_overrides(loader_overrides) .cloud_requirements(cloud_requirements) .build() .await?;