diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index f4282343b..852af4d21 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -42,12 +42,14 @@ pub async fn run_command_under_seatbelt( codex_linux_sandbox_exe: Option, ) -> anyhow::Result<()> { let SeatbeltCommand { + permissions_profile, allow_unix_sockets, log_denials, config_overrides, command, } = command; run_command_under_sandbox( + permissions_profile, command, config_overrides, codex_linux_sandbox_exe, @@ -71,10 +73,12 @@ pub async fn run_command_under_landlock( codex_linux_sandbox_exe: Option, ) -> anyhow::Result<()> { let LandlockCommand { + permissions_profile, config_overrides, command, } = command; run_command_under_sandbox( + permissions_profile, command, config_overrides, codex_linux_sandbox_exe, @@ -90,10 +94,12 @@ pub async fn run_command_under_windows( codex_linux_sandbox_exe: Option, ) -> anyhow::Result<()> { let WindowsCommand { + permissions_profile, config_overrides, command, } = command; run_command_under_sandbox( + permissions_profile, command, config_overrides, codex_linux_sandbox_exe, @@ -112,6 +118,7 @@ enum SandboxType { } async fn run_command_under_sandbox( + permissions_profile: Option, command: Vec, config_overrides: CliConfigOverrides, codex_linux_sandbox_exe: Option, @@ -125,6 +132,7 @@ async fn run_command_under_sandbox( .parse_overrides() .map_err(anyhow::Error::msg)?, codex_linux_sandbox_exe, + permissions_profile, ) .await?; @@ -563,20 +571,30 @@ mod windows_stdio_bridge { async fn load_debug_sandbox_config( cli_overrides: Vec<(String, TomlValue)>, codex_linux_sandbox_exe: Option, + permissions_profile: Option, ) -> anyhow::Result { load_debug_sandbox_config_with_codex_home( cli_overrides, codex_linux_sandbox_exe, + permissions_profile, /*codex_home*/ None, ) .await } async fn load_debug_sandbox_config_with_codex_home( - cli_overrides: Vec<(String, TomlValue)>, + mut cli_overrides: Vec<(String, TomlValue)>, codex_linux_sandbox_exe: Option, + permissions_profile: Option, codex_home: Option, ) -> anyhow::Result { + if let Some(permissions_profile) = permissions_profile { + cli_overrides.push(( + "default_permissions".to_string(), + TomlValue::String(permissions_profile), + )); + } + // For legacy configs, `codex sandbox` historically defaulted to read-only // instead of inheriting ambient `sandbox_mode` settings from user/system // config. Keep that behavior unless this invocation explicitly passes a @@ -698,6 +716,7 @@ mod tests { let config = load_debug_sandbox_config_with_codex_home( Vec::new(), /*codex_linux_sandbox_exe*/ None, + /*permissions_profile*/ None, Some(codex_home_path), ) .await?; @@ -748,6 +767,7 @@ mod tests { let config = load_debug_sandbox_config_with_codex_home( cli_overrides, /*codex_linux_sandbox_exe*/ None, + /*permissions_profile*/ None, Some(codex_home_path), ) .await?; @@ -797,6 +817,7 @@ mod tests { let config = load_debug_sandbox_config_with_codex_home( Vec::new(), /*codex_linux_sandbox_exe*/ None, + /*permissions_profile*/ None, Some(codex_home_path), ) .await?; @@ -809,4 +830,88 @@ mod tests { Ok(()) } + + #[tokio::test] + async fn debug_sandbox_honors_explicit_builtin_permission_profile() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + let config = load_debug_sandbox_config_with_codex_home( + Vec::new(), + /*codex_linux_sandbox_exe*/ None, + Some(":workspace".to_string()), + Some(codex_home.path().to_path_buf()), + ) + .await?; + + assert_eq!( + config.permissions.file_system_sandbox_policy(), + codex_protocol::models::PermissionProfile::workspace_write() + .file_system_sandbox_policy() + ); + + Ok(()) + } + + #[tokio::test] + async fn explicit_permission_profile_overrides_active_profile_sandbox_mode() + -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + "profile = \"legacy\"\n\ + \n\ + [profiles.legacy]\n\ + sandbox_mode = \"danger-full-access\"\n", + )?; + + let config = load_debug_sandbox_config_with_codex_home( + Vec::new(), + /*codex_linux_sandbox_exe*/ None, + Some(":workspace".to_string()), + Some(codex_home.path().to_path_buf()), + ) + .await?; + + assert_eq!( + config.permissions.file_system_sandbox_policy(), + codex_protocol::models::PermissionProfile::workspace_write() + .file_system_sandbox_policy() + ); + + Ok(()) + } + + #[tokio::test] + async fn debug_sandbox_honors_explicit_named_permission_profile() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let sandbox_paths = TempDir::new()?; + let docs = sandbox_paths.path().join("docs"); + let private = docs.join("private"); + write_permissions_profile_config(&codex_home, &docs, &private)?; + + let config = load_debug_sandbox_config_with_codex_home( + Vec::new(), + /*codex_linux_sandbox_exe*/ None, + Some("limited-read-test".to_string()), + Some(codex_home.path().to_path_buf()), + ) + .await?; + + let expected = build_debug_sandbox_config( + vec![( + "default_permissions".to_string(), + TomlValue::String("limited-read-test".to_string()), + )], + ConfigOverrides::default(), + Some(codex_home.path().to_path_buf()), + ) + .await?; + + assert_eq!( + config.permissions.file_system_sandbox_policy(), + expected.permissions.file_system_sandbox_policy() + ); + + Ok(()) + } } diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index 1f3968545..f78afa0fa 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -21,6 +21,10 @@ pub use login::run_logout; #[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, + /// 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, @@ -44,6 +48,10 @@ fn parse_allow_unix_socket_path(raw: &str) -> Result { #[derive(Debug, Parser)] pub struct LandlockCommand { + /// Named permissions profile to apply from the active configuration stack. + #[arg(long = "permissions-profile", value_name = "NAME")] + pub permissions_profile: Option, + #[clap(skip)] pub config_overrides: CliConfigOverrides, @@ -54,6 +62,10 @@ pub struct LandlockCommand { #[derive(Debug, Parser)] pub struct WindowsCommand { + /// Named permissions profile to apply from the active configuration stack. + #[arg(long = "permissions-profile", value_name = "NAME")] + pub permissions_profile: Option, + #[clap(skip)] pub config_overrides: CliConfigOverrides, diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 01e44fd8c..ab33d9f94 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1922,6 +1922,30 @@ mod tests { assert!(matches!(cli.subcommand, Some(Subcommand::Update))); } + #[test] + fn sandbox_macos_parses_permissions_profile() { + let cli = MultitoolCli::try_parse_from([ + "codex", + "sandbox", + "macos", + "--permissions-profile", + ":workspace", + "--", + "echo", + ]) + .expect("parse"); + + let Some(Subcommand::Sandbox(SandboxArgs { + cmd: SandboxCommand::Macos(command), + })) = cli.subcommand + else { + panic!("expected sandbox macos command"); + }; + + assert_eq!(command.permissions_profile.as_deref(), Some(":workspace")); + assert_eq!(command.command, vec!["echo"]); + } + #[test] fn plugin_marketplace_remove_parses_under_plugin() { let cli = diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 7fce4833d..fe9a8e433 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -8,6 +8,7 @@ use crate::windows_sandbox::WindowsSandboxLevelExt; use crate::windows_sandbox::resolve_windows_sandbox_mode; use crate::windows_sandbox::resolve_windows_sandbox_private_desktop; use codex_config::CloudRequirementsLoader; +use codex_config::ConfigLayerSource; use codex_config::ConfigLayerStack; use codex_config::ConfigLayerStackOrdering; use codex_config::ConfigRequirements; @@ -1531,7 +1532,30 @@ fn resolve_permission_config_syntax( sandbox_mode_override: Option, profile_sandbox_mode: Option, ) -> Option { - if sandbox_mode_override.is_some() || profile_sandbox_mode.is_some() { + if sandbox_mode_override.is_some() { + return Some(PermissionConfigSyntax::Legacy); + } + + let session_flags_select_profiles = config_layer_stack + .get_layers( + ConfigLayerStackOrdering::HighestPrecedenceFirst, + /*include_disabled*/ false, + ) + .into_iter() + .find(|layer| matches!(layer.name, ConfigLayerSource::SessionFlags)) + .and_then(|layer| { + layer + .config + .clone() + .try_into::() + .ok() + }) + .is_some_and(|selection| selection.default_permissions.is_some()); + if session_flags_select_profiles { + return Some(PermissionConfigSyntax::Profiles); + } + + if profile_sandbox_mode.is_some() { return Some(PermissionConfigSyntax::Legacy); }