From 5597925155ce96ecc5265b8967b2b1f9be50a087 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Tue, 28 Apr 2026 23:55:51 -0700 Subject: [PATCH] feat(cli): add sandbox profile config controls (#20118) ## Why The explicit profile path from #20117 is meant for standalone testing, but it still inherited the shell cwd and all managed requirements implicitly. The pre-existing launcher path even called out that it did not support a separate cwd yet in [`debug_sandbox.rs`](https://github.com/openai/codex/blob/509453f688a30929432be866402d1ea46aa12169/codex-rs/cli/src/debug_sandbox.rs#L174-L179). For a standalone command, the useful default is to let the caller choose the project directory being tested and to avoid administrator-provided constraints unless the caller explicitly wants to test those too. ## What changed - Add explicit-profile-only `-C/--cd DIR`, and use that cwd for both profile resolution and command execution. - Add explicit-profile-only `--include-managed-config`. - Make explicit profile mode skip managed requirement sources by default, including cloud requirements, MDM requirements, `/etc/codex/requirements.toml`, and the legacy managed-config requirements projection. - Preserve all existing invocations outside the explicit-profile path. ## Stack 1. #20117 `sandbox-ui-profile` 2. #20118 `sandbox-ui-config` --> this PR Both PRs are additive. Replay JSON is intentionally deferred to a follow-up design pass. ## Tests ran - `cargo test -p codex-cli debug_sandbox` - `cargo test -p codex-cli sandbox_macos_` - `cargo test -p codex-core load_config_layers_can_ignore_managed_requirements` - `cargo test -p codex-core load_config_layers_includes_cloud_requirements` - macOS branch-binary smoke on the rebased top of stack: `-C` changed execution cwd, explicit profile mode omitted managed proxy env under `env -i`, and `--include-managed-config` restored it. - Linux devbox branch-binary smoke on the rebased top of stack: `-C` changed execution cwd for built-in and user-defined explicit profiles. --- codex-rs/cli/src/debug_sandbox.rs | 154 ++++++++++++++++-- codex-rs/cli/src/lib.rs | 54 ++++++ codex-rs/cli/src/main.rs | 8 + codex-rs/config/src/loader/mod.rs | 51 +++--- codex-rs/config/src/state.rs | 2 + .../core/src/config/config_loader_tests.rs | 52 ++++++ 6 files changed, 284 insertions(+), 37 deletions(-) diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 852af4d216..e9bc6a046e 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -6,6 +6,7 @@ mod seatbelt; use std::path::PathBuf; use std::process::Stdio; +use codex_config::LoaderOverrides; use codex_core::config::Config; use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; @@ -43,13 +44,23 @@ pub async fn run_command_under_seatbelt( ) -> anyhow::Result<()> { let SeatbeltCommand { permissions_profile, + cwd, + include_managed_config, allow_unix_sockets, log_denials, config_overrides, command, } = command; + let managed_requirements_mode = ManagedRequirementsMode::for_profile_invocation( + &permissions_profile, + include_managed_config, + ); run_command_under_sandbox( - permissions_profile, + DebugSandboxConfigOptions { + permissions_profile, + cwd, + managed_requirements_mode, + }, command, config_overrides, codex_linux_sandbox_exe, @@ -74,11 +85,21 @@ pub async fn run_command_under_landlock( ) -> anyhow::Result<()> { let LandlockCommand { permissions_profile, + cwd, + include_managed_config, config_overrides, command, } = command; + let managed_requirements_mode = ManagedRequirementsMode::for_profile_invocation( + &permissions_profile, + include_managed_config, + ); run_command_under_sandbox( - permissions_profile, + DebugSandboxConfigOptions { + permissions_profile, + cwd, + managed_requirements_mode, + }, command, config_overrides, codex_linux_sandbox_exe, @@ -95,11 +116,21 @@ pub async fn run_command_under_windows( ) -> anyhow::Result<()> { let WindowsCommand { permissions_profile, + cwd, + include_managed_config, config_overrides, command, } = command; + let managed_requirements_mode = ManagedRequirementsMode::for_profile_invocation( + &permissions_profile, + include_managed_config, + ); run_command_under_sandbox( - permissions_profile, + DebugSandboxConfigOptions { + permissions_profile, + cwd, + managed_requirements_mode, + }, command, config_overrides, codex_linux_sandbox_exe, @@ -117,8 +148,34 @@ enum SandboxType { Windows, } -async fn run_command_under_sandbox( +#[derive(Debug)] +struct DebugSandboxConfigOptions { permissions_profile: Option, + cwd: Option, + managed_requirements_mode: ManagedRequirementsMode, +} + +#[derive(Debug, Clone, Copy)] +enum ManagedRequirementsMode { + Include, + Ignore, +} + +impl ManagedRequirementsMode { + fn for_profile_invocation( + permissions_profile: &Option, + include_managed_config: bool, + ) -> Self { + if permissions_profile.is_some() && !include_managed_config { + Self::Ignore + } else { + Self::Include + } + } +} + +async fn run_command_under_sandbox( + config_options: DebugSandboxConfigOptions, command: Vec, config_overrides: CliConfigOverrides, codex_linux_sandbox_exe: Option, @@ -132,7 +189,7 @@ async fn run_command_under_sandbox( .parse_overrides() .map_err(anyhow::Error::msg)?, codex_linux_sandbox_exe, - permissions_profile, + config_options, ) .await?; @@ -571,12 +628,12 @@ mod windows_stdio_bridge { async fn load_debug_sandbox_config( cli_overrides: Vec<(String, TomlValue)>, codex_linux_sandbox_exe: Option, - permissions_profile: Option, + options: DebugSandboxConfigOptions, ) -> anyhow::Result { load_debug_sandbox_config_with_codex_home( cli_overrides, codex_linux_sandbox_exe, - permissions_profile, + options, /*codex_home*/ None, ) .await @@ -585,9 +642,15 @@ async fn load_debug_sandbox_config( async fn load_debug_sandbox_config_with_codex_home( mut cli_overrides: Vec<(String, TomlValue)>, codex_linux_sandbox_exe: Option, - permissions_profile: Option, + options: DebugSandboxConfigOptions, codex_home: Option, ) -> anyhow::Result { + let DebugSandboxConfigOptions { + permissions_profile, + cwd, + managed_requirements_mode, + } = options; + if let Some(permissions_profile) = permissions_profile { cli_overrides.push(( "default_permissions".to_string(), @@ -604,10 +667,12 @@ async fn load_debug_sandbox_config_with_codex_home( let config = build_debug_sandbox_config( cli_overrides.clone(), ConfigOverrides { + cwd: cwd.clone(), codex_linux_sandbox_exe: codex_linux_sandbox_exe.clone(), ..Default::default() }, codex_home.clone(), + managed_requirements_mode, ) .await?; @@ -619,10 +684,12 @@ async fn load_debug_sandbox_config_with_codex_home( cli_overrides, ConfigOverrides { sandbox_mode: Some(SandboxMode::ReadOnly), + cwd, codex_linux_sandbox_exe, ..Default::default() }, codex_home, + managed_requirements_mode, ) .await .map_err(Into::into) @@ -632,10 +699,17 @@ async fn build_debug_sandbox_config( cli_overrides: Vec<(String, TomlValue)>, harness_overrides: ConfigOverrides, codex_home: Option, + managed_requirements_mode: ManagedRequirementsMode, ) -> std::io::Result { let mut builder = ConfigBuilder::default() .cli_overrides(cli_overrides) .harness_overrides(harness_overrides); + if let ManagedRequirementsMode::Ignore = managed_requirements_mode { + builder = builder.loader_overrides(LoaderOverrides { + ignore_managed_requirements: true, + ..Default::default() + }); + } if let Some(codex_home) = codex_home { builder = builder .codex_home(codex_home.clone()) @@ -701,6 +775,7 @@ mod tests { Vec::new(), ConfigOverrides::default(), Some(codex_home_path.clone()), + ManagedRequirementsMode::Include, ) .await?; let legacy_config = build_debug_sandbox_config( @@ -710,13 +785,18 @@ mod tests { ..Default::default() }, Some(codex_home_path.clone()), + ManagedRequirementsMode::Include, ) .await?; let config = load_debug_sandbox_config_with_codex_home( Vec::new(), /*codex_linux_sandbox_exe*/ None, - /*permissions_profile*/ None, + DebugSandboxConfigOptions { + permissions_profile: None, + cwd: None, + managed_requirements_mode: ManagedRequirementsMode::Include, + }, Some(codex_home_path), ) .await?; @@ -752,6 +832,7 @@ mod tests { cli_overrides.clone(), ConfigOverrides::default(), Some(codex_home_path.clone()), + ManagedRequirementsMode::Include, ) .await?; let read_only_config = build_debug_sandbox_config( @@ -761,13 +842,18 @@ mod tests { ..Default::default() }, Some(codex_home_path.clone()), + ManagedRequirementsMode::Include, ) .await?; let config = load_debug_sandbox_config_with_codex_home( cli_overrides, /*codex_linux_sandbox_exe*/ None, - /*permissions_profile*/ None, + DebugSandboxConfigOptions { + permissions_profile: None, + cwd: None, + managed_requirements_mode: ManagedRequirementsMode::Include, + }, Some(codex_home_path), ) .await?; @@ -811,13 +897,18 @@ mod tests { ..Default::default() }, Some(codex_home_path.clone()), + ManagedRequirementsMode::Include, ) .await?; let config = load_debug_sandbox_config_with_codex_home( Vec::new(), /*codex_linux_sandbox_exe*/ None, - /*permissions_profile*/ None, + DebugSandboxConfigOptions { + permissions_profile: None, + cwd: None, + managed_requirements_mode: ManagedRequirementsMode::Include, + }, Some(codex_home_path), ) .await?; @@ -838,7 +929,11 @@ mod tests { let config = load_debug_sandbox_config_with_codex_home( Vec::new(), /*codex_linux_sandbox_exe*/ None, - Some(":workspace".to_string()), + DebugSandboxConfigOptions { + permissions_profile: Some(":workspace".to_string()), + cwd: None, + managed_requirements_mode: ManagedRequirementsMode::Ignore, + }, Some(codex_home.path().to_path_buf()), ) .await?; @@ -867,7 +962,11 @@ mod tests { let config = load_debug_sandbox_config_with_codex_home( Vec::new(), /*codex_linux_sandbox_exe*/ None, - Some(":workspace".to_string()), + DebugSandboxConfigOptions { + permissions_profile: Some(":workspace".to_string()), + cwd: None, + managed_requirements_mode: ManagedRequirementsMode::Ignore, + }, Some(codex_home.path().to_path_buf()), ) .await?; @@ -892,7 +991,11 @@ mod tests { let config = load_debug_sandbox_config_with_codex_home( Vec::new(), /*codex_linux_sandbox_exe*/ None, - Some("limited-read-test".to_string()), + DebugSandboxConfigOptions { + permissions_profile: Some("limited-read-test".to_string()), + cwd: None, + managed_requirements_mode: ManagedRequirementsMode::Ignore, + }, Some(codex_home.path().to_path_buf()), ) .await?; @@ -904,6 +1007,7 @@ mod tests { )], ConfigOverrides::default(), Some(codex_home.path().to_path_buf()), + ManagedRequirementsMode::Include, ) .await?; @@ -914,4 +1018,26 @@ mod tests { Ok(()) } + + #[tokio::test] + async fn debug_sandbox_uses_explicit_profile_cwd() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + + let config = load_debug_sandbox_config_with_codex_home( + Vec::new(), + /*codex_linux_sandbox_exe*/ None, + DebugSandboxConfigOptions { + permissions_profile: Some(":workspace".to_string()), + cwd: Some(cwd.path().to_path_buf()), + managed_requirements_mode: ManagedRequirementsMode::Ignore, + }, + Some(codex_home.path().to_path_buf()), + ) + .await?; + + assert_eq!(config.cwd.as_path(), cwd.path()); + + Ok(()) + } } diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index f78afa0fa3..6750cbf39e 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -5,6 +5,7 @@ pub(crate) mod login; use clap::Parser; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::CliConfigOverrides; +use std::path::PathBuf; pub use debug_sandbox::run_command_under_landlock; pub use debug_sandbox::run_command_under_seatbelt; @@ -19,12 +20,31 @@ pub use login::run_login_with_device_code; pub use login::run_login_with_device_code_fallback_to_browser; pub use login::run_logout; +// TODO: Deduplicate these shared sandbox options if we remove the explicit +// `codex sandbox ` platform subcommands. #[derive(Debug, Parser)] pub struct SeatbeltCommand { /// Named permissions profile to apply from the active configuration stack. #[arg(long = "permissions-profile", value_name = "NAME")] pub permissions_profile: Option, + /// Working directory used for profile resolution and command execution. + #[arg( + short = 'C', + long = "cd", + value_name = "DIR", + requires = "permissions_profile" + )] + pub cwd: Option, + + /// Include managed requirements while resolving an explicit permissions profile. + #[arg( + long = "include-managed-config", + default_value_t = false, + requires = "permissions_profile" + )] + pub include_managed_config: bool, + /// Allow the sandboxed command to bind/connect AF_UNIX sockets rooted at this path. Relative paths are resolved against the current directory. Repeat to allow multiple paths. #[arg(long = "allow-unix-socket", value_parser = parse_allow_unix_socket_path)] pub allow_unix_sockets: Vec, @@ -52,6 +72,23 @@ pub struct LandlockCommand { #[arg(long = "permissions-profile", value_name = "NAME")] pub permissions_profile: Option, + /// Working directory used for profile resolution and command execution. + #[arg( + short = 'C', + long = "cd", + value_name = "DIR", + requires = "permissions_profile" + )] + pub cwd: Option, + + /// Include managed requirements while resolving an explicit permissions profile. + #[arg( + long = "include-managed-config", + default_value_t = false, + requires = "permissions_profile" + )] + pub include_managed_config: bool, + #[clap(skip)] pub config_overrides: CliConfigOverrides, @@ -66,6 +103,23 @@ pub struct WindowsCommand { #[arg(long = "permissions-profile", value_name = "NAME")] pub permissions_profile: Option, + /// Working directory used for profile resolution and command execution. + #[arg( + short = 'C', + long = "cd", + value_name = "DIR", + requires = "permissions_profile" + )] + pub cwd: Option, + + /// Include managed requirements while resolving an explicit permissions profile. + #[arg( + long = "include-managed-config", + default_value_t = false, + requires = "permissions_profile" + )] + pub include_managed_config: bool, + #[clap(skip)] pub config_overrides: CliConfigOverrides, diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index ab33d9f948..9881878554 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1946,6 +1946,14 @@ mod tests { assert_eq!(command.command, vec!["echo"]); } + #[test] + fn sandbox_macos_rejects_explicit_profile_controls_without_profile() { + let err = MultitoolCli::try_parse_from(["codex", "sandbox", "macos", "-C", "/tmp"]) + .expect_err("parse should fail"); + + assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument); + } + #[test] fn plugin_marketplace_remove_parses_under_plugin() { let cli = diff --git a/codex-rs/config/src/loader/mod.rs b/codex-rs/config/src/loader/mod.rs index 28e5ff3426..74dd258eb9 100644 --- a/codex-rs/config/src/loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -92,41 +92,46 @@ pub async fn load_config_layers_state( cloud_requirements: CloudRequirementsLoader, thread_config_loader: &dyn ThreadConfigLoader, ) -> io::Result { + 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 let Some(requirements) = cloud_requirements.get().await.map_err(io::Error::other)? { - merge_requirements_with_remote_sandbox_config( + 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, - RequirementSource::CloudRequirements, - requirements, - ); + 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?; } - #[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()).await?; - load_requirements_from_legacy_scheme( - &mut config_requirements_toml, - loaded_config_layers.clone(), - ) - .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, diff --git a/codex-rs/config/src/state.rs b/codex-rs/config/src/state.rs index 6bb846edd9..08ddbca95e 100644 --- a/codex-rs/config/src/state.rs +++ b/codex-rs/config/src/state.rs @@ -20,6 +20,7 @@ pub struct LoaderOverrides { pub managed_config_path: Option, pub system_config_path: Option, pub system_requirements_path: Option, + pub ignore_managed_requirements: bool, 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. @@ -38,6 +39,7 @@ impl LoaderOverrides { managed_config_path: Some(base.join("managed_config.toml")), system_config_path: Some(base.join("config.toml")), system_requirements_path: Some(base.join("requirements.toml")), + ignore_managed_requirements: false, ignore_user_config: false, ignore_user_and_project_exec_policy_rules: false, #[cfg(target_os = "macos")] diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index cc465d42b1..06d6f91294 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -1084,6 +1084,58 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> Ok(()) } +#[tokio::test] +async fn load_config_layers_can_ignore_managed_requirements() -> anyhow::Result<()> { + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; + + let managed_config_path = tmp.path().join("managed_config.toml"); + tokio::fs::write(&managed_config_path, "approval_policy = \"never\"\n").await?; + let system_requirements_path = tmp.path().join("requirements.toml"); + tokio::fs::write( + &system_requirements_path, + "allowed_sandbox_modes = [\"read-only\"]\n", + ) + .await?; + + let mut overrides = LoaderOverrides::with_managed_config_path_for_tests(managed_config_path); + overrides.system_requirements_path = Some(system_requirements_path); + overrides.ignore_managed_requirements = true; + + let cloud_requirements = CloudRequirementsLoader::new(async { + Ok(Some(ConfigRequirementsToml { + allowed_approval_policies: Some(vec![AskForApproval::Never]), + ..Default::default() + })) + }); + + let mut config = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(cwd.to_path_buf())) + .loader_overrides(overrides) + .cloud_requirements(cloud_requirements) + .build() + .await?; + + assert!( + config + .permissions + .approval_policy + .can_set(&AskForApproval::OnRequest) + .is_ok(), + "ignoring managed requirements should leave on-request approval allowed" + ); + config + .permissions + .approval_policy + .set(AskForApproval::OnRequest) + .expect("ignoring managed requirements should allow setting on-request approval"); + + Ok(()) +} + #[tokio::test] async fn load_config_layers_includes_cloud_hook_requirements() -> anyhow::Result<()> { let tmp = tempdir()?;