Skip to content

Commit 6ed0440

Browse files
authored
feat(cli): add explicit sandbox permission profiles (#20117)
## Why `codex sandbox` is useful for exercising sandbox behavior directly, but before this stack the CLI only picked up permission profiles indirectly from the active config. The existing debug-sandbox path already compiled `[permissions]` profiles through normal config loading, as covered by the existing profile tests in [`debug_sandbox.rs`](https://github.com/openai/codex/blob/de2ccf94735a3d8a2a7077e6a5292026413867cf/codex-rs/cli/src/debug_sandbox.rs#L715-L760). This adds the smallest stable entry point first: an explicit profile selector that reuses the same config machinery as normal Codex config, so standalone testing becomes possible without changing current no-selector behavior. ## What changed - Add additive `--permissions-profile NAME` support to `codex sandbox macos|linux|windows`. - Resolve built-in and user-defined profile names by feeding `default_permissions` through the existing config compilation path instead of inventing a sandbox-only parser. - Make an explicit selector win over an ambient active profile's legacy `sandbox_mode`. - Keep the existing no-selector behavior unchanged. ## Stack 1. #20117 `sandbox-ui-profile` --> this PR 2. #20118 `sandbox-ui-config` 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_parses_permissions_profile` - `cargo test -p codex-core cli_override_takes_precedence_over_profile_sandbox_mode` - macOS branch-binary smoke on the rebased top of stack: built-in `:workspace` and user-defined profiles both executed successfully through `--permissions-profile`. - Linux devbox branch-binary smoke on the rebased top of stack: built-in `:workspace` and user-defined profiles both executed successfully through `--permissions-profile`.
1 parent 3d10ba9 commit 6ed0440

4 files changed

Lines changed: 167 additions & 2 deletions

File tree

codex-rs/cli/src/debug_sandbox.rs

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,14 @@ pub async fn run_command_under_seatbelt(
4242
codex_linux_sandbox_exe: Option<PathBuf>,
4343
) -> anyhow::Result<()> {
4444
let SeatbeltCommand {
45+
permissions_profile,
4546
allow_unix_sockets,
4647
log_denials,
4748
config_overrides,
4849
command,
4950
} = command;
5051
run_command_under_sandbox(
52+
permissions_profile,
5153
command,
5254
config_overrides,
5355
codex_linux_sandbox_exe,
@@ -71,10 +73,12 @@ pub async fn run_command_under_landlock(
7173
codex_linux_sandbox_exe: Option<PathBuf>,
7274
) -> anyhow::Result<()> {
7375
let LandlockCommand {
76+
permissions_profile,
7477
config_overrides,
7578
command,
7679
} = command;
7780
run_command_under_sandbox(
81+
permissions_profile,
7882
command,
7983
config_overrides,
8084
codex_linux_sandbox_exe,
@@ -90,10 +94,12 @@ pub async fn run_command_under_windows(
9094
codex_linux_sandbox_exe: Option<PathBuf>,
9195
) -> anyhow::Result<()> {
9296
let WindowsCommand {
97+
permissions_profile,
9398
config_overrides,
9499
command,
95100
} = command;
96101
run_command_under_sandbox(
102+
permissions_profile,
97103
command,
98104
config_overrides,
99105
codex_linux_sandbox_exe,
@@ -112,6 +118,7 @@ enum SandboxType {
112118
}
113119

114120
async fn run_command_under_sandbox(
121+
permissions_profile: Option<String>,
115122
command: Vec<String>,
116123
config_overrides: CliConfigOverrides,
117124
codex_linux_sandbox_exe: Option<PathBuf>,
@@ -125,6 +132,7 @@ async fn run_command_under_sandbox(
125132
.parse_overrides()
126133
.map_err(anyhow::Error::msg)?,
127134
codex_linux_sandbox_exe,
135+
permissions_profile,
128136
)
129137
.await?;
130138

@@ -563,20 +571,30 @@ mod windows_stdio_bridge {
563571
async fn load_debug_sandbox_config(
564572
cli_overrides: Vec<(String, TomlValue)>,
565573
codex_linux_sandbox_exe: Option<PathBuf>,
574+
permissions_profile: Option<String>,
566575
) -> anyhow::Result<Config> {
567576
load_debug_sandbox_config_with_codex_home(
568577
cli_overrides,
569578
codex_linux_sandbox_exe,
579+
permissions_profile,
570580
/*codex_home*/ None,
571581
)
572582
.await
573583
}
574584

575585
async fn load_debug_sandbox_config_with_codex_home(
576-
cli_overrides: Vec<(String, TomlValue)>,
586+
mut cli_overrides: Vec<(String, TomlValue)>,
577587
codex_linux_sandbox_exe: Option<PathBuf>,
588+
permissions_profile: Option<String>,
578589
codex_home: Option<PathBuf>,
579590
) -> anyhow::Result<Config> {
591+
if let Some(permissions_profile) = permissions_profile {
592+
cli_overrides.push((
593+
"default_permissions".to_string(),
594+
TomlValue::String(permissions_profile),
595+
));
596+
}
597+
580598
// For legacy configs, `codex sandbox` historically defaulted to read-only
581599
// instead of inheriting ambient `sandbox_mode` settings from user/system
582600
// config. Keep that behavior unless this invocation explicitly passes a
@@ -698,6 +716,7 @@ mod tests {
698716
let config = load_debug_sandbox_config_with_codex_home(
699717
Vec::new(),
700718
/*codex_linux_sandbox_exe*/ None,
719+
/*permissions_profile*/ None,
701720
Some(codex_home_path),
702721
)
703722
.await?;
@@ -748,6 +767,7 @@ mod tests {
748767
let config = load_debug_sandbox_config_with_codex_home(
749768
cli_overrides,
750769
/*codex_linux_sandbox_exe*/ None,
770+
/*permissions_profile*/ None,
751771
Some(codex_home_path),
752772
)
753773
.await?;
@@ -797,6 +817,7 @@ mod tests {
797817
let config = load_debug_sandbox_config_with_codex_home(
798818
Vec::new(),
799819
/*codex_linux_sandbox_exe*/ None,
820+
/*permissions_profile*/ None,
800821
Some(codex_home_path),
801822
)
802823
.await?;
@@ -809,4 +830,88 @@ mod tests {
809830

810831
Ok(())
811832
}
833+
834+
#[tokio::test]
835+
async fn debug_sandbox_honors_explicit_builtin_permission_profile() -> anyhow::Result<()> {
836+
let codex_home = TempDir::new()?;
837+
838+
let config = load_debug_sandbox_config_with_codex_home(
839+
Vec::new(),
840+
/*codex_linux_sandbox_exe*/ None,
841+
Some(":workspace".to_string()),
842+
Some(codex_home.path().to_path_buf()),
843+
)
844+
.await?;
845+
846+
assert_eq!(
847+
config.permissions.file_system_sandbox_policy(),
848+
codex_protocol::models::PermissionProfile::workspace_write()
849+
.file_system_sandbox_policy()
850+
);
851+
852+
Ok(())
853+
}
854+
855+
#[tokio::test]
856+
async fn explicit_permission_profile_overrides_active_profile_sandbox_mode()
857+
-> anyhow::Result<()> {
858+
let codex_home = TempDir::new()?;
859+
std::fs::write(
860+
codex_home.path().join("config.toml"),
861+
"profile = \"legacy\"\n\
862+
\n\
863+
[profiles.legacy]\n\
864+
sandbox_mode = \"danger-full-access\"\n",
865+
)?;
866+
867+
let config = load_debug_sandbox_config_with_codex_home(
868+
Vec::new(),
869+
/*codex_linux_sandbox_exe*/ None,
870+
Some(":workspace".to_string()),
871+
Some(codex_home.path().to_path_buf()),
872+
)
873+
.await?;
874+
875+
assert_eq!(
876+
config.permissions.file_system_sandbox_policy(),
877+
codex_protocol::models::PermissionProfile::workspace_write()
878+
.file_system_sandbox_policy()
879+
);
880+
881+
Ok(())
882+
}
883+
884+
#[tokio::test]
885+
async fn debug_sandbox_honors_explicit_named_permission_profile() -> anyhow::Result<()> {
886+
let codex_home = TempDir::new()?;
887+
let sandbox_paths = TempDir::new()?;
888+
let docs = sandbox_paths.path().join("docs");
889+
let private = docs.join("private");
890+
write_permissions_profile_config(&codex_home, &docs, &private)?;
891+
892+
let config = load_debug_sandbox_config_with_codex_home(
893+
Vec::new(),
894+
/*codex_linux_sandbox_exe*/ None,
895+
Some("limited-read-test".to_string()),
896+
Some(codex_home.path().to_path_buf()),
897+
)
898+
.await?;
899+
900+
let expected = build_debug_sandbox_config(
901+
vec![(
902+
"default_permissions".to_string(),
903+
TomlValue::String("limited-read-test".to_string()),
904+
)],
905+
ConfigOverrides::default(),
906+
Some(codex_home.path().to_path_buf()),
907+
)
908+
.await?;
909+
910+
assert_eq!(
911+
config.permissions.file_system_sandbox_policy(),
912+
expected.permissions.file_system_sandbox_policy()
913+
);
914+
915+
Ok(())
916+
}
812917
}

codex-rs/cli/src/lib.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ pub use login::run_logout;
2121

2222
#[derive(Debug, Parser)]
2323
pub struct SeatbeltCommand {
24+
/// Named permissions profile to apply from the active configuration stack.
25+
#[arg(long = "permissions-profile", value_name = "NAME")]
26+
pub permissions_profile: Option<String>,
27+
2428
/// 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.
2529
#[arg(long = "allow-unix-socket", value_parser = parse_allow_unix_socket_path)]
2630
pub allow_unix_sockets: Vec<AbsolutePathBuf>,
@@ -44,6 +48,10 @@ fn parse_allow_unix_socket_path(raw: &str) -> Result<AbsolutePathBuf, String> {
4448

4549
#[derive(Debug, Parser)]
4650
pub struct LandlockCommand {
51+
/// Named permissions profile to apply from the active configuration stack.
52+
#[arg(long = "permissions-profile", value_name = "NAME")]
53+
pub permissions_profile: Option<String>,
54+
4755
#[clap(skip)]
4856
pub config_overrides: CliConfigOverrides,
4957

@@ -54,6 +62,10 @@ pub struct LandlockCommand {
5462

5563
#[derive(Debug, Parser)]
5664
pub struct WindowsCommand {
65+
/// Named permissions profile to apply from the active configuration stack.
66+
#[arg(long = "permissions-profile", value_name = "NAME")]
67+
pub permissions_profile: Option<String>,
68+
5769
#[clap(skip)]
5870
pub config_overrides: CliConfigOverrides,
5971

codex-rs/cli/src/main.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1922,6 +1922,30 @@ mod tests {
19221922
assert!(matches!(cli.subcommand, Some(Subcommand::Update)));
19231923
}
19241924

1925+
#[test]
1926+
fn sandbox_macos_parses_permissions_profile() {
1927+
let cli = MultitoolCli::try_parse_from([
1928+
"codex",
1929+
"sandbox",
1930+
"macos",
1931+
"--permissions-profile",
1932+
":workspace",
1933+
"--",
1934+
"echo",
1935+
])
1936+
.expect("parse");
1937+
1938+
let Some(Subcommand::Sandbox(SandboxArgs {
1939+
cmd: SandboxCommand::Macos(command),
1940+
})) = cli.subcommand
1941+
else {
1942+
panic!("expected sandbox macos command");
1943+
};
1944+
1945+
assert_eq!(command.permissions_profile.as_deref(), Some(":workspace"));
1946+
assert_eq!(command.command, vec!["echo"]);
1947+
}
1948+
19251949
#[test]
19261950
fn plugin_marketplace_remove_parses_under_plugin() {
19271951
let cli =

codex-rs/core/src/config/mod.rs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use crate::windows_sandbox::WindowsSandboxLevelExt;
88
use crate::windows_sandbox::resolve_windows_sandbox_mode;
99
use crate::windows_sandbox::resolve_windows_sandbox_private_desktop;
1010
use codex_config::CloudRequirementsLoader;
11+
use codex_config::ConfigLayerSource;
1112
use codex_config::ConfigLayerStack;
1213
use codex_config::ConfigLayerStackOrdering;
1314
use codex_config::ConfigRequirements;
@@ -1531,7 +1532,30 @@ fn resolve_permission_config_syntax(
15311532
sandbox_mode_override: Option<SandboxMode>,
15321533
profile_sandbox_mode: Option<SandboxMode>,
15331534
) -> Option<PermissionConfigSyntax> {
1534-
if sandbox_mode_override.is_some() || profile_sandbox_mode.is_some() {
1535+
if sandbox_mode_override.is_some() {
1536+
return Some(PermissionConfigSyntax::Legacy);
1537+
}
1538+
1539+
let session_flags_select_profiles = config_layer_stack
1540+
.get_layers(
1541+
ConfigLayerStackOrdering::HighestPrecedenceFirst,
1542+
/*include_disabled*/ false,
1543+
)
1544+
.into_iter()
1545+
.find(|layer| matches!(layer.name, ConfigLayerSource::SessionFlags))
1546+
.and_then(|layer| {
1547+
layer
1548+
.config
1549+
.clone()
1550+
.try_into::<PermissionSelectionToml>()
1551+
.ok()
1552+
})
1553+
.is_some_and(|selection| selection.default_permissions.is_some());
1554+
if session_flags_select_profiles {
1555+
return Some(PermissionConfigSyntax::Profiles);
1556+
}
1557+
1558+
if profile_sandbox_mode.is_some() {
15351559
return Some(PermissionConfigSyntax::Legacy);
15361560
}
15371561

0 commit comments

Comments
 (0)