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:
Michael Bolin
2026-05-22 13:00:53 -07:00
committed by GitHub
parent acd851e89f
commit 36a71a88bf
4 changed files with 179 additions and 11 deletions

View File

@@ -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:

View File

@@ -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,

View File

@@ -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',

View File

@@ -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() {