mirror of
https://github.com/openai/codex.git
synced 2026-05-23 12:34:25 +00:00
cli: support --profile for codex sandbox (#24110)
## Why `codex sandbox` now always runs the host sandbox backend, so it should accept the same profile selection mechanism as the rest of the runtime CLI surface. Without `--profile`, sandbox debugging can exercise only the default config stack unless users manually translate profile config into ad hoc `-c` overrides. Supporting `--profile` lets sandbox invocations load `$CODEX_HOME/<name>.config.toml`, including permission profile configuration, before resolving the sandbox policy for the command being run. ## What Changed - Added `--profile NAME` / `-p NAME` to the host-specific `codex sandbox` argument structs as `config_profile`. - Allowed root-level `codex --profile NAME sandbox ...` and made a sandbox-local `codex sandbox --profile NAME ...` override the root selection. - Threaded `LoaderOverrides` through sandbox config loading so selected config profile files participate in permission resolution before the legacy read-only fallback. - Documented the new sandbox flag in `codex-rs/README.md`. ## Verification - Added parser coverage for `codex sandbox --profile`. - Added sandbox config-loader coverage that verifies selected config profile loader overrides select the profile config rather than falling back to read-only. - Ran `cargo test -p codex-cli`.
This commit is contained in:
@@ -66,6 +66,10 @@ codex sandbox [COMMAND]...
|
||||
codex sandbox --log-denials [COMMAND]...
|
||||
```
|
||||
|
||||
`codex sandbox` also accepts `--profile NAME` (`-p NAME`) to layer
|
||||
`$CODEX_HOME/NAME.config.toml` onto the base user config for the sandboxed
|
||||
command.
|
||||
|
||||
### Selecting a sandbox policy via `--sandbox`
|
||||
|
||||
The Rust CLI exposes a dedicated `--sandbox` (`-s`) flag that lets you pick the sandbox policy **without** having to reach for the generic `-c/--config` option:
|
||||
|
||||
@@ -41,9 +41,11 @@ use seatbelt::DenialLogger;
|
||||
pub async fn run_command_under_seatbelt(
|
||||
command: SeatbeltCommand,
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
loader_overrides: LoaderOverrides,
|
||||
) -> anyhow::Result<()> {
|
||||
let SeatbeltCommand {
|
||||
permissions_profile,
|
||||
config_profile: _,
|
||||
cwd,
|
||||
include_managed_config,
|
||||
allow_unix_sockets,
|
||||
@@ -60,6 +62,7 @@ pub async fn run_command_under_seatbelt(
|
||||
permissions_profile,
|
||||
cwd,
|
||||
managed_requirements_mode,
|
||||
loader_overrides,
|
||||
},
|
||||
command,
|
||||
config_overrides,
|
||||
@@ -75,6 +78,7 @@ pub async fn run_command_under_seatbelt(
|
||||
pub async fn run_command_under_seatbelt(
|
||||
_command: SeatbeltCommand,
|
||||
_codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
_loader_overrides: LoaderOverrides,
|
||||
) -> anyhow::Result<()> {
|
||||
anyhow::bail!("Seatbelt sandbox is only available on macOS");
|
||||
}
|
||||
@@ -82,9 +86,11 @@ pub async fn run_command_under_seatbelt(
|
||||
pub async fn run_command_under_landlock(
|
||||
command: LandlockCommand,
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
loader_overrides: LoaderOverrides,
|
||||
) -> anyhow::Result<()> {
|
||||
let LandlockCommand {
|
||||
permissions_profile,
|
||||
config_profile: _,
|
||||
cwd,
|
||||
include_managed_config,
|
||||
config_overrides,
|
||||
@@ -99,6 +105,7 @@ pub async fn run_command_under_landlock(
|
||||
permissions_profile,
|
||||
cwd,
|
||||
managed_requirements_mode,
|
||||
loader_overrides,
|
||||
},
|
||||
command,
|
||||
config_overrides,
|
||||
@@ -113,9 +120,11 @@ pub async fn run_command_under_landlock(
|
||||
pub async fn run_command_under_windows_sandbox(
|
||||
command: WindowsCommand,
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
loader_overrides: LoaderOverrides,
|
||||
) -> anyhow::Result<()> {
|
||||
let WindowsCommand {
|
||||
permissions_profile,
|
||||
config_profile: _,
|
||||
cwd,
|
||||
include_managed_config,
|
||||
config_overrides,
|
||||
@@ -130,6 +139,7 @@ pub async fn run_command_under_windows_sandbox(
|
||||
permissions_profile,
|
||||
cwd,
|
||||
managed_requirements_mode,
|
||||
loader_overrides,
|
||||
},
|
||||
command,
|
||||
config_overrides,
|
||||
@@ -153,6 +163,7 @@ struct DebugSandboxConfigOptions {
|
||||
permissions_profile: Option<String>,
|
||||
cwd: Option<PathBuf>,
|
||||
managed_requirements_mode: ManagedRequirementsMode,
|
||||
loader_overrides: LoaderOverrides,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -650,7 +661,7 @@ async fn load_debug_sandbox_config(
|
||||
}
|
||||
|
||||
async fn load_debug_sandbox_config_with_codex_home(
|
||||
mut cli_overrides: Vec<(String, TomlValue)>,
|
||||
cli_overrides: Vec<(String, TomlValue)>,
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
options: DebugSandboxConfigOptions,
|
||||
codex_home: Option<PathBuf>,
|
||||
@@ -660,7 +671,9 @@ async fn load_debug_sandbox_config_with_codex_home(
|
||||
permissions_profile,
|
||||
cwd,
|
||||
managed_requirements_mode,
|
||||
loader_overrides,
|
||||
} = options;
|
||||
let mut cli_overrides = cli_overrides;
|
||||
|
||||
if let Some(permissions_profile) = permissions_profile {
|
||||
cli_overrides.push((
|
||||
@@ -674,7 +687,7 @@ async fn load_debug_sandbox_config_with_codex_home(
|
||||
// config. Keep that behavior unless this invocation explicitly passes a
|
||||
// legacy `sandbox_mode` CLI override for compatibility with older callers.
|
||||
let uses_legacy_sandbox_mode_override = cli_overrides_use_legacy_sandbox_mode(&cli_overrides);
|
||||
let config = build_debug_sandbox_config(
|
||||
let config = build_debug_sandbox_config_with_loader_overrides(
|
||||
cli_overrides.clone(),
|
||||
ConfigOverrides {
|
||||
cwd: cwd.clone(),
|
||||
@@ -683,6 +696,7 @@ async fn load_debug_sandbox_config_with_codex_home(
|
||||
},
|
||||
codex_home.clone(),
|
||||
managed_requirements_mode,
|
||||
loader_overrides.clone(),
|
||||
strict_config,
|
||||
)
|
||||
.await?;
|
||||
@@ -691,7 +705,7 @@ async fn load_debug_sandbox_config_with_codex_home(
|
||||
return Ok(config);
|
||||
}
|
||||
|
||||
build_debug_sandbox_config(
|
||||
build_debug_sandbox_config_with_loader_overrides(
|
||||
cli_overrides,
|
||||
ConfigOverrides {
|
||||
sandbox_mode: Some(SandboxMode::ReadOnly),
|
||||
@@ -701,17 +715,19 @@ async fn load_debug_sandbox_config_with_codex_home(
|
||||
},
|
||||
codex_home,
|
||||
managed_requirements_mode,
|
||||
loader_overrides,
|
||||
strict_config,
|
||||
)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
async fn build_debug_sandbox_config(
|
||||
async fn build_debug_sandbox_config_with_loader_overrides(
|
||||
cli_overrides: Vec<(String, TomlValue)>,
|
||||
harness_overrides: ConfigOverrides,
|
||||
codex_home: Option<PathBuf>,
|
||||
managed_requirements_mode: ManagedRequirementsMode,
|
||||
mut loader_overrides: LoaderOverrides,
|
||||
strict_config: bool,
|
||||
) -> std::io::Result<Config> {
|
||||
let mut builder = ConfigBuilder::default()
|
||||
@@ -719,11 +735,9 @@ async fn build_debug_sandbox_config(
|
||||
.harness_overrides(harness_overrides)
|
||||
.strict_config(strict_config);
|
||||
if matches!(managed_requirements_mode, ManagedRequirementsMode::Ignore) {
|
||||
builder = builder.loader_overrides(LoaderOverrides {
|
||||
ignore_managed_requirements: true,
|
||||
..LoaderOverrides::default()
|
||||
});
|
||||
loader_overrides.ignore_managed_requirements = true;
|
||||
}
|
||||
builder = builder.loader_overrides(loader_overrides);
|
||||
if let Some(codex_home) = codex_home {
|
||||
builder = builder
|
||||
.codex_home(codex_home.clone())
|
||||
@@ -750,6 +764,24 @@ mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
async fn build_debug_sandbox_config(
|
||||
cli_overrides: Vec<(String, TomlValue)>,
|
||||
harness_overrides: ConfigOverrides,
|
||||
codex_home: Option<PathBuf>,
|
||||
managed_requirements_mode: ManagedRequirementsMode,
|
||||
strict_config: bool,
|
||||
) -> std::io::Result<Config> {
|
||||
build_debug_sandbox_config_with_loader_overrides(
|
||||
cli_overrides,
|
||||
harness_overrides,
|
||||
codex_home,
|
||||
managed_requirements_mode,
|
||||
LoaderOverrides::default(),
|
||||
strict_config,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn escape_toml_path(path: &std::path::Path) -> String {
|
||||
path.display().to_string().replace('\\', "\\\\")
|
||||
}
|
||||
@@ -758,6 +790,18 @@ mod tests {
|
||||
codex_home: &TempDir,
|
||||
docs: &std::path::Path,
|
||||
private: &std::path::Path,
|
||||
) -> std::io::Result<()> {
|
||||
write_permissions_profile_config_to_path(
|
||||
&codex_home.path().join("config.toml"),
|
||||
docs,
|
||||
private,
|
||||
)
|
||||
}
|
||||
|
||||
fn write_permissions_profile_config_to_path(
|
||||
config_path: &std::path::Path,
|
||||
docs: &std::path::Path,
|
||||
private: &std::path::Path,
|
||||
) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(private)?;
|
||||
let config = format!(
|
||||
@@ -772,7 +816,7 @@ mod tests {
|
||||
escape_toml_path(docs),
|
||||
escape_toml_path(private),
|
||||
);
|
||||
std::fs::write(codex_home.path().join("config.toml"), config)?;
|
||||
std::fs::write(config_path, config)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -812,6 +856,7 @@ mod tests {
|
||||
permissions_profile: None,
|
||||
cwd: None,
|
||||
managed_requirements_mode: ManagedRequirementsMode::Include,
|
||||
loader_overrides: LoaderOverrides::default(),
|
||||
},
|
||||
Some(codex_home_path),
|
||||
/*strict_config*/ false,
|
||||
@@ -836,6 +881,70 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn debug_sandbox_honors_config_profile_loader_overrides() -> anyhow::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let sandbox_paths = TempDir::new()?;
|
||||
let docs = sandbox_paths.path().join("docs");
|
||||
let private = docs.join("private");
|
||||
let profile_path = codex_home.path().join("work.config.toml");
|
||||
write_permissions_profile_config_to_path(&profile_path, &docs, &private)?;
|
||||
let codex_home_path = codex_home.path().to_path_buf();
|
||||
let loader_overrides = LoaderOverrides {
|
||||
user_config_path: Some(AbsolutePathBuf::from_absolute_path(&profile_path)?),
|
||||
user_config_profile: Some("work".parse().expect("profile name should parse")),
|
||||
..LoaderOverrides::default()
|
||||
};
|
||||
|
||||
let profile_config = build_debug_sandbox_config_with_loader_overrides(
|
||||
Vec::new(),
|
||||
ConfigOverrides::default(),
|
||||
Some(codex_home_path.clone()),
|
||||
ManagedRequirementsMode::Include,
|
||||
loader_overrides.clone(),
|
||||
/*strict_config*/ false,
|
||||
)
|
||||
.await?;
|
||||
let read_only_config = build_debug_sandbox_config(
|
||||
Vec::new(),
|
||||
ConfigOverrides {
|
||||
sandbox_mode: Some(SandboxMode::ReadOnly),
|
||||
..Default::default()
|
||||
},
|
||||
Some(codex_home_path.clone()),
|
||||
ManagedRequirementsMode::Include,
|
||||
/*strict_config*/ false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let config = load_debug_sandbox_config_with_codex_home(
|
||||
Vec::new(),
|
||||
/*codex_linux_sandbox_exe*/ None,
|
||||
DebugSandboxConfigOptions {
|
||||
permissions_profile: None,
|
||||
cwd: None,
|
||||
managed_requirements_mode: ManagedRequirementsMode::Include,
|
||||
loader_overrides,
|
||||
},
|
||||
Some(codex_home_path),
|
||||
/*strict_config*/ false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert!(config_uses_permission_profiles(&config));
|
||||
assert_ne!(
|
||||
profile_config.permissions.file_system_sandbox_policy(),
|
||||
read_only_config.permissions.file_system_sandbox_policy(),
|
||||
"test fixture should distinguish the profile config from read-only"
|
||||
);
|
||||
assert_eq!(
|
||||
config.permissions.file_system_sandbox_policy(),
|
||||
profile_config.permissions.file_system_sandbox_policy(),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn debug_sandbox_honors_explicit_legacy_sandbox_mode() -> anyhow::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -872,6 +981,7 @@ mod tests {
|
||||
permissions_profile: None,
|
||||
cwd: None,
|
||||
managed_requirements_mode: ManagedRequirementsMode::Include,
|
||||
loader_overrides: LoaderOverrides::default(),
|
||||
},
|
||||
Some(codex_home_path),
|
||||
/*strict_config*/ false,
|
||||
@@ -929,6 +1039,7 @@ mod tests {
|
||||
permissions_profile: None,
|
||||
cwd: None,
|
||||
managed_requirements_mode: ManagedRequirementsMode::Include,
|
||||
loader_overrides: LoaderOverrides::default(),
|
||||
},
|
||||
Some(codex_home_path),
|
||||
/*strict_config*/ false,
|
||||
@@ -955,6 +1066,7 @@ mod tests {
|
||||
permissions_profile: Some(":workspace".to_string()),
|
||||
cwd: None,
|
||||
managed_requirements_mode: ManagedRequirementsMode::Ignore,
|
||||
loader_overrides: LoaderOverrides::default(),
|
||||
},
|
||||
Some(codex_home.path().to_path_buf()),
|
||||
/*strict_config*/ false,
|
||||
@@ -993,6 +1105,7 @@ mod tests {
|
||||
permissions_profile: Some("limited-read-test".to_string()),
|
||||
cwd: None,
|
||||
managed_requirements_mode: ManagedRequirementsMode::Ignore,
|
||||
loader_overrides: LoaderOverrides::default(),
|
||||
},
|
||||
Some(codex_home.path().to_path_buf()),
|
||||
/*strict_config*/ false,
|
||||
@@ -1031,6 +1144,7 @@ mod tests {
|
||||
permissions_profile: Some(":workspace".to_string()),
|
||||
cwd: Some(cwd.path().to_path_buf()),
|
||||
managed_requirements_mode: ManagedRequirementsMode::Ignore,
|
||||
loader_overrides: LoaderOverrides::default(),
|
||||
},
|
||||
Some(codex_home.path().to_path_buf()),
|
||||
/*strict_config*/ false,
|
||||
|
||||
@@ -5,6 +5,7 @@ pub(crate) mod login;
|
||||
use clap::Parser;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_cli::CliConfigOverrides;
|
||||
use codex_utils_cli::ProfileV2Name;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub use debug_sandbox::run_command_under_landlock;
|
||||
@@ -28,6 +29,10 @@ pub struct SeatbeltCommand {
|
||||
#[arg(long = "permissions-profile", value_name = "NAME")]
|
||||
pub permissions_profile: Option<String>,
|
||||
|
||||
/// Layer $CODEX_HOME/<name>.config.toml on top of the base user config.
|
||||
#[arg(long = "profile", short = 'p')]
|
||||
pub config_profile: Option<ProfileV2Name>,
|
||||
|
||||
/// Working directory used for profile resolution and command execution.
|
||||
#[arg(
|
||||
short = 'C',
|
||||
@@ -72,6 +77,10 @@ pub struct LandlockCommand {
|
||||
#[arg(long = "permissions-profile", value_name = "NAME")]
|
||||
pub permissions_profile: Option<String>,
|
||||
|
||||
/// Layer $CODEX_HOME/<name>.config.toml on top of the base user config.
|
||||
#[arg(long = "profile", short = 'p')]
|
||||
pub config_profile: Option<ProfileV2Name>,
|
||||
|
||||
/// Working directory used for profile resolution and command execution.
|
||||
#[arg(
|
||||
short = 'C',
|
||||
@@ -103,6 +112,10 @@ pub struct WindowsCommand {
|
||||
#[arg(long = "permissions-profile", value_name = "NAME")]
|
||||
pub permissions_profile: Option<String>,
|
||||
|
||||
/// Layer $CODEX_HOME/<name>.config.toml on top of the base user config.
|
||||
#[arg(long = "profile", short = 'p')]
|
||||
pub config_profile: Option<ProfileV2Name>,
|
||||
|
||||
/// Working directory used for profile resolution and command execution.
|
||||
#[arg(
|
||||
short = 'C',
|
||||
|
||||
@@ -353,6 +353,10 @@ type HostSandboxArgs = UnsupportedSandboxArgs;
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
#[derive(Debug, Parser)]
|
||||
struct UnsupportedSandboxArgs {
|
||||
/// Layer $CODEX_HOME/<name>.config.toml on top of the base user config.
|
||||
#[arg(long = "profile", short = 'p')]
|
||||
pub config_profile: Option<ProfileV2Name>,
|
||||
|
||||
#[clap(skip)]
|
||||
pub config_overrides: CliConfigOverrides,
|
||||
|
||||
@@ -1242,6 +1246,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
|
||||
root_remote_auth_token_env.as_deref(),
|
||||
"sandbox",
|
||||
)?;
|
||||
let config_profile = sandbox_cli
|
||||
.config_profile
|
||||
.as_ref()
|
||||
.or(interactive.config_profile_v2.as_ref());
|
||||
let loader_overrides = loader_overrides_for_profile(config_profile)?;
|
||||
prepend_config_flags(
|
||||
&mut sandbox_cli.config_overrides,
|
||||
root_config_overrides.clone(),
|
||||
@@ -1250,22 +1259,28 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
|
||||
codex_cli::run_command_under_seatbelt(
|
||||
sandbox_cli,
|
||||
arg0_paths.codex_linux_sandbox_exe.clone(),
|
||||
loader_overrides,
|
||||
)
|
||||
.await?;
|
||||
#[cfg(target_os = "linux")]
|
||||
codex_cli::run_command_under_landlock(
|
||||
sandbox_cli,
|
||||
arg0_paths.codex_linux_sandbox_exe.clone(),
|
||||
loader_overrides,
|
||||
)
|
||||
.await?;
|
||||
#[cfg(target_os = "windows")]
|
||||
codex_cli::run_command_under_windows_sandbox(
|
||||
sandbox_cli,
|
||||
arg0_paths.codex_linux_sandbox_exe.clone(),
|
||||
loader_overrides,
|
||||
)
|
||||
.await?;
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
anyhow::bail!("`codex sandbox` is not supported on this operating system");
|
||||
{
|
||||
let _ = loader_overrides;
|
||||
anyhow::bail!("`codex sandbox` is not supported on this operating system");
|
||||
}
|
||||
}
|
||||
Some(Subcommand::Debug(DebugCommand { subcommand })) => match subcommand {
|
||||
DebugSubcommand::Models(cmd) => {
|
||||
@@ -1441,11 +1456,12 @@ fn profile_v2_for_subcommand<'a>(
|
||||
| Subcommand::Resume(_)
|
||||
| Subcommand::Fork(_)
|
||||
| Subcommand::Mcp(_)
|
||||
| Subcommand::Sandbox(_)
|
||||
| Subcommand::Debug(DebugCommand {
|
||||
subcommand: DebugSubcommand::PromptInput(_),
|
||||
}) => Ok(Some(profile_v2)),
|
||||
_ => anyhow::bail!(
|
||||
"--profile only applies to runtime commands and `codex mcp`: `codex`, `codex exec`, `codex review`, `codex resume`, `codex fork`, `codex mcp`, and `codex debug prompt-input`."
|
||||
"--profile only applies to runtime commands and `codex mcp`: `codex`, `codex exec`, `codex review`, `codex resume`, `codex fork`, `codex mcp`, `codex sandbox`, and `codex debug prompt-input`."
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -2252,6 +2268,12 @@ mod tests {
|
||||
.as_deref(),
|
||||
Some("work")
|
||||
);
|
||||
assert_eq!(
|
||||
profile_v2_for_args(&["codex", "--profile", "work", "sandbox"])
|
||||
.expect("sandbox supports config profile")
|
||||
.as_deref(),
|
||||
Some("work")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2500,6 +2522,21 @@ mod tests {
|
||||
assert_eq!(command.command, vec!["echo"]);
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
|
||||
#[test]
|
||||
fn sandbox_parses_config_profile() {
|
||||
let cli =
|
||||
MultitoolCli::try_parse_from(["codex", "sandbox", "--profile", "work", "--", "echo"])
|
||||
.expect("parse");
|
||||
|
||||
let Some(Subcommand::Sandbox(command)) = cli.subcommand else {
|
||||
panic!("expected sandbox command");
|
||||
};
|
||||
|
||||
assert_eq!(command.config_profile.as_deref(), Some("work"));
|
||||
assert_eq!(command.command, vec!["echo"]);
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
|
||||
#[test]
|
||||
fn sandbox_rejects_explicit_profile_controls_without_profile() {
|
||||
|
||||
Reference in New Issue
Block a user