Files
codex/codex-rs/cli/src/debug_sandbox.rs
T
Michael Bolin 889ee018e7 config: add strict config parsing (#20559)
## Why

Codex intentionally ignores unknown `config.toml` fields by default so
older and newer config files keep working across versions. That leniency
also makes typo detection hard because misspelled or misplaced keys
disappear silently.

This change adds an opt-in strict config mode so users and tooling can
fail fast on unrecognized config fields without changing the default
permissive behavior.

This feature is possible because `serde_ignored` exposes the exact
signal Codex needs: it lets Codex run ordinary Serde deserialization
while recording fields Serde would otherwise ignore. That avoids
requiring `#[serde(deny_unknown_fields)]` across every config type and
keeps strict validation opt-in around the existing config model.

## What Changed

### Added strict config validation

- Added `serde_ignored`-based validation for `ConfigToml` in
`codex-rs/config/src/strict_config.rs`.
- Combined `serde_ignored` with `serde_path_to_error` so strict mode
preserves typed config error paths while also collecting fields Serde
would otherwise ignore.
- Added strict-mode validation for unknown `[features]` keys, including
keys that would otherwise be accepted by `FeaturesToml`'s flattened
boolean map.
- Kept typed config errors ahead of ignored-field reporting, so
malformed known fields are reported before unknown-field diagnostics.
- Added source-range diagnostics for top-level and nested unknown config
fields, including non-file managed preference source names.

### Kept parsing single-pass per source

- Reworked file and managed-config loading so strict validation reuses
the already parsed `TomlValue` for that source.
- For actual config files and managed config strings, the loader now
reads once, parses once, and validates that same parsed value instead of
deserializing multiple times.
- Validated `-c` / `--config` override layers with the same
base-directory context used for normal relative-path resolution, so
unknown override keys are still reported when another override contains
a relative path.

### Scoped `--strict-config` to config-heavy entry points

- Added support for `--strict-config` on the main config-loading entry
points where it is most useful:
  - `codex`
  - `codex resume`
  - `codex fork`
  - `codex exec`
  - `codex review`
  - `codex mcp-server`
  - `codex app-server` when running the server itself
  - the standalone `codex-app-server` binary
  - the standalone `codex-exec` binary
- Commands outside that set now reject `--strict-config` early with
targeted errors instead of accepting it everywhere through shared CLI
plumbing.
- `codex app-server` subcommands such as `proxy`, `daemon`, and
`generate-*` are intentionally excluded from the first rollout.
- When app-server strict mode sees invalid config, app-server exits with
the config error instead of logging a warning and continuing with
defaults.
- Introduced a dedicated `ReviewCommand` wrapper in `codex-rs/cli`
instead of extending shared `ReviewArgs`, so `--strict-config` stays on
the outer config-loading command surface and does not become part of the
reusable review payload used by `codex exec review`.

### Coverage

- Added tests for top-level and nested unknown config fields, unknown
`[features]` keys, typed-error precedence, source-location reporting,
and non-file managed preference source names.
- Added CLI coverage showing invalid `--enable`, invalid `--disable`,
and unknown `-c` overrides still error when `--strict-config` is
present, including compound-looking feature names such as
`multi_agent_v2.subagent_usage_hint_text`.
- Added integration coverage showing both `codex app-server
--strict-config` and standalone `codex-app-server --strict-config` exit
with an error for unknown config fields instead of starting with
fallback defaults.
- Added coverage showing unsupported command surfaces reject
`--strict-config` with explicit errors.

## Example Usage

Run Codex with strict config validation enabled:

```shell
codex --strict-config
```

Strict config mode is also available on the supported config-heavy
subcommands:

```shell
codex --strict-config exec "explain this repository"
codex review --strict-config --uncommitted
codex mcp-server --strict-config
codex app-server --strict-config --listen off
codex-app-server --strict-config --listen off
```

For example, if `~/.codex/config.toml` contains a typo in a key name:

```toml
model = "gpt-5"
approval_polic = "on-request"
```

then `codex --strict-config` reports the misspelled key instead of
silently ignoring it. The path is shortened to `~` here for readability:

```text
$ codex --strict-config
Error loading config.toml:
~/.codex/config.toml:2:1: unknown configuration field `approval_polic`
  |
2 | approval_polic = "on-request"
  | ^^^^^^^^^^^^^^
```

Without `--strict-config`, Codex keeps the existing permissive behavior
and ignores the unknown key.

Strict config mode also validates ad-hoc `-c` / `--config` overrides:

```text
$ codex --strict-config -c foo=bar
Error: unknown configuration field `foo` in -c/--config override

$ codex --strict-config -c features.foo=true
Error: unknown configuration field `features.foo` in -c/--config override
```

Invalid feature toggles are rejected too, including values that look
like nested config paths:

```text
$ codex --strict-config --enable does_not_exist
Error: Unknown feature flag: does_not_exist

$ codex --strict-config --disable does_not_exist
Error: Unknown feature flag: does_not_exist

$ codex --strict-config --enable multi_agent_v2.subagent_usage_hint_text
Error: Unknown feature flag: multi_agent_v2.subagent_usage_hint_text
```

Unsupported commands reject the flag explicitly:

```text
$ codex --strict-config cloud list
Error: `--strict-config` is not supported for `codex cloud`
```

## Verification

The `codex-cli` `strict_config` tests cover invalid `--enable`, invalid
`--disable`, the compound `multi_agent_v2.subagent_usage_hint_text`
case, unknown `-c` overrides, app-server strict startup failure through
`codex app-server`, and rejection for unsupported commands such as
`codex cloud`, `codex mcp`, `codex remote-control`, and `codex
app-server proxy`.

The config and config-loader tests cover unknown top-level fields,
unknown nested fields, unknown `[features]` keys, source-location
reporting, non-file managed config sources, and `-c` validation for keys
such as `features.foo`.

The app-server test suite covers standalone `codex-app-server
--strict-config` startup failure for an unknown config field.

## Documentation

The Codex CLI docs on developers.openai.com/codex should mention
`--strict-config` as an opt-in validation mode for supported
config-heavy entry points once this ships.
2026-05-13 16:08:05 +00:00

1072 lines
35 KiB
Rust

#[cfg(target_os = "macos")]
mod pid_tracker;
#[cfg(target_os = "macos")]
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;
use codex_core::config::NetworkProxyAuditMetadata;
use codex_core::exec_env::create_env;
#[cfg(target_os = "macos")]
use codex_core::spawn::CODEX_SANDBOX_ENV_VAR;
use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_sandboxing::landlock::allow_network_for_proxy;
use codex_sandboxing::landlock::create_linux_sandbox_command_args_for_permission_profile;
#[cfg(target_os = "macos")]
use codex_sandboxing::seatbelt::CreateSeatbeltCommandArgsParams;
#[cfg(target_os = "macos")]
use codex_sandboxing::seatbelt::create_seatbelt_command_args;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_cli::CliConfigOverrides;
use tokio::process::Child;
use tokio::process::Command as TokioCommand;
use toml::Value as TomlValue;
use crate::LandlockCommand;
use crate::SeatbeltCommand;
use crate::WindowsCommand;
use crate::exit_status::handle_exit_status;
#[cfg(target_os = "macos")]
use seatbelt::DenialLogger;
#[cfg(target_os = "macos")]
pub async fn run_command_under_seatbelt(
command: SeatbeltCommand,
codex_linux_sandbox_exe: Option<PathBuf>,
) -> 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(
DebugSandboxConfigOptions {
permissions_profile,
cwd,
managed_requirements_mode,
},
command,
config_overrides,
codex_linux_sandbox_exe,
SandboxType::Seatbelt,
log_denials,
&allow_unix_sockets,
)
.await
}
#[cfg(not(target_os = "macos"))]
pub async fn run_command_under_seatbelt(
_command: SeatbeltCommand,
_codex_linux_sandbox_exe: Option<PathBuf>,
) -> anyhow::Result<()> {
anyhow::bail!("Seatbelt sandbox is only available on macOS");
}
pub async fn run_command_under_landlock(
command: LandlockCommand,
codex_linux_sandbox_exe: Option<PathBuf>,
) -> 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(
DebugSandboxConfigOptions {
permissions_profile,
cwd,
managed_requirements_mode,
},
command,
config_overrides,
codex_linux_sandbox_exe,
SandboxType::Landlock,
/*log_denials*/ false,
&[],
)
.await
}
pub async fn run_command_under_windows(
command: WindowsCommand,
codex_linux_sandbox_exe: Option<PathBuf>,
) -> 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(
DebugSandboxConfigOptions {
permissions_profile,
cwd,
managed_requirements_mode,
},
command,
config_overrides,
codex_linux_sandbox_exe,
SandboxType::Windows,
/*log_denials*/ false,
&[],
)
.await
}
enum SandboxType {
#[cfg(target_os = "macos")]
Seatbelt,
Landlock,
Windows,
}
#[derive(Debug)]
struct DebugSandboxConfigOptions {
permissions_profile: Option<String>,
cwd: Option<PathBuf>,
managed_requirements_mode: ManagedRequirementsMode,
}
#[derive(Debug, Clone, Copy)]
enum ManagedRequirementsMode {
Include,
Ignore,
}
impl ManagedRequirementsMode {
fn for_profile_invocation(
permissions_profile: &Option<String>,
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<String>,
config_overrides: CliConfigOverrides,
codex_linux_sandbox_exe: Option<PathBuf>,
sandbox_type: SandboxType,
log_denials: bool,
#[cfg_attr(not(target_os = "macos"), allow(unused_variables))]
allow_unix_sockets: &[AbsolutePathBuf],
) -> anyhow::Result<()> {
let config = load_debug_sandbox_config(
config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?,
codex_linux_sandbox_exe,
config_options,
/*strict_config*/ false,
)
.await?;
// In practice, this should be `std::env::current_dir()` because this CLI
// does not support `--cwd`, but let's use the config value for consistency.
let cwd = config.cwd.clone();
// For now, we always use the same cwd for both the command and the
// sandbox policy. In the future, we could add a CLI option to set them
// separately.
let sandbox_policy_cwd = cwd.clone();
let env = create_env(
&config.permissions.shell_environment_policy,
/*thread_id*/ None,
);
// Special-case Windows sandbox: execute and exit the process to emulate inherited stdio.
if let SandboxType::Windows = sandbox_type {
#[cfg(target_os = "windows")]
{
run_command_under_windows_session(&config, command, cwd, sandbox_policy_cwd, env).await;
}
#[cfg(not(target_os = "windows"))]
{
anyhow::bail!("Windows sandbox is only available on Windows");
}
}
#[cfg(target_os = "macos")]
let mut denial_logger = log_denials.then(DenialLogger::new).flatten();
#[cfg(not(target_os = "macos"))]
let _ = log_denials;
let managed_network_requirements_enabled = config.managed_network_requirements_enabled();
// This proxy should only live for the lifetime of the child process.
let network_proxy = match config.permissions.network.as_ref() {
Some(spec) => Some(
spec.start_proxy(
config.permissions.permission_profile.get(),
/*policy_decider*/ None,
/*blocked_request_observer*/ None,
managed_network_requirements_enabled,
NetworkProxyAuditMetadata::default(),
)
.await
.map_err(|err| anyhow::anyhow!("failed to start managed network proxy: {err}"))?,
),
None => None,
};
let network = network_proxy
.as_ref()
.map(codex_core::config::StartedNetworkProxy::proxy);
let mut child = match sandbox_type {
#[cfg(target_os = "macos")]
SandboxType::Seatbelt => {
let file_system_sandbox_policy = config.permissions.file_system_sandbox_policy();
let network_sandbox_policy = config.permissions.network_sandbox_policy();
let args = create_seatbelt_command_args(CreateSeatbeltCommandArgsParams {
command,
file_system_sandbox_policy: &file_system_sandbox_policy,
network_sandbox_policy,
sandbox_policy_cwd: sandbox_policy_cwd.as_path(),
enforce_managed_network: false,
network: network.as_ref(),
extra_allow_unix_sockets: allow_unix_sockets,
});
spawn_debug_sandbox_child(
PathBuf::from("/usr/bin/sandbox-exec"),
args,
/*arg0*/ None,
cwd.to_path_buf(),
network_sandbox_policy,
env,
|env_map| {
env_map.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string());
if let Some(network) = network.as_ref() {
network.apply_to_env(env_map);
}
},
)
.await?
}
SandboxType::Landlock => {
#[expect(clippy::expect_used)]
let codex_linux_sandbox_exe = config
.codex_linux_sandbox_exe
.expect("codex-linux-sandbox executable not found");
let use_legacy_landlock = config.features.use_legacy_landlock();
let network_sandbox_policy = config.permissions.network_sandbox_policy();
let args = create_linux_sandbox_command_args_for_permission_profile(
command,
cwd.as_path(),
&config.permissions.permission_profile(),
sandbox_policy_cwd.as_path(),
use_legacy_landlock,
allow_network_for_proxy(managed_network_requirements_enabled),
);
spawn_debug_sandbox_child(
codex_linux_sandbox_exe,
args,
Some("codex-linux-sandbox"),
cwd.to_path_buf(),
network_sandbox_policy,
env,
|env_map| {
if let Some(network) = network.as_ref() {
network.apply_to_env(env_map);
}
},
)
.await?
}
SandboxType::Windows => {
unreachable!("Windows sandbox should have been handled above");
}
};
#[cfg(target_os = "macos")]
if let Some(denial_logger) = &mut denial_logger {
denial_logger.on_child_spawn(&child);
}
let status = child.wait().await?;
#[cfg(target_os = "macos")]
if let Some(denial_logger) = denial_logger {
let denials = denial_logger.finish().await;
eprintln!("\n=== Sandbox denials ===");
if denials.is_empty() {
eprintln!("None found.");
} else {
for seatbelt::SandboxDenial { name, capability } in denials {
eprintln!("({name}) {capability}");
}
}
}
handle_exit_status(status);
}
#[cfg(target_os = "windows")]
async fn run_command_under_windows_session(
config: &Config,
command: Vec<String>,
cwd: AbsolutePathBuf,
sandbox_policy_cwd: AbsolutePathBuf,
env: std::collections::HashMap<String, String>,
) -> ! {
use codex_core::windows_sandbox::WindowsSandboxLevelExt;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_windows_sandbox::spawn_windows_sandbox_session_elevated;
use codex_windows_sandbox::spawn_windows_sandbox_session_legacy;
let sandbox_policy = config
.permissions
.legacy_sandbox_policy(sandbox_policy_cwd.as_path());
let policy_str = match serde_json::to_string(&sandbox_policy) {
Ok(policy_str) => policy_str,
Err(err) => {
eprintln!("windows sandbox failed to serialize policy: {err}");
std::process::exit(1);
}
};
let use_elevated = matches!(
WindowsSandboxLevel::from_config(config),
WindowsSandboxLevel::Elevated
);
let spawned = if use_elevated {
spawn_windows_sandbox_session_elevated(
policy_str.as_str(),
sandbox_policy_cwd.as_path(),
config.codex_home.as_path(),
command,
cwd.as_path(),
env,
None,
/*read_roots_override*/ None,
/*read_roots_include_platform_defaults*/ false,
/*write_roots_override*/ None,
/*deny_read_paths_override*/ &[],
/*deny_write_paths_override*/ &[],
/*tty*/ false,
/*stdin_open*/ true,
config.permissions.windows_sandbox_private_desktop,
)
.await
} else {
spawn_windows_sandbox_session_legacy(
policy_str.as_str(),
sandbox_policy_cwd.as_path(),
config.codex_home.as_path(),
command,
cwd.as_path(),
env,
None,
/*additional_deny_read_paths*/ &[],
/*additional_deny_write_paths*/ &[],
/*tty*/ false,
/*stdin_open*/ true,
config.permissions.windows_sandbox_private_desktop,
)
.await
};
let spawned = match spawned {
Ok(spawned) => spawned,
Err(err) => {
eprintln!("windows sandbox failed: {err}");
std::process::exit(1);
}
};
let session = std::sync::Arc::new(spawned.session);
let tokio_runtime = tokio::runtime::Handle::current();
// Give large or slow tail output a better chance to finish draining
// without letting rare EOF issues hang the wrapper indefinitely.
let output_drain_timeout = std::time::Duration::from_secs(5);
// A helper thread watches our stdin. When the input source closes it,
// the thread tells the main async code so we can also close stdin for
// the sandboxed child process.
let (stdin_eof_tx, stdin_eof_rx) = tokio::sync::oneshot::channel();
// Start background threads that copy stdin/stdout/stderr. We
// intentionally do not keep their JoinHandles; dropping the handle does
// not stop the thread, it just means we are not going to wait on it
// later.
drop(windows_stdio_bridge::spawn_input_forwarder(
std::io::stdin(),
session.writer_sender(),
stdin_eof_tx,
));
let (stdout_forwarder, stdout_forwarder_done_rx) = windows_stdio_bridge::spawn_output_forwarder(
tokio_runtime.clone(),
spawned.stdout_rx,
std::io::stdout(),
);
drop(stdout_forwarder);
let (stderr_forwarder, stderr_forwarder_done_rx) = windows_stdio_bridge::spawn_output_forwarder(
tokio_runtime.clone(),
spawned.stderr_rx,
std::io::stderr(),
);
drop(stderr_forwarder);
let stdin_close_task = tokio::spawn({
let session = std::sync::Arc::clone(&session);
async move {
let _ = stdin_eof_rx.await;
session.close_stdin();
}
});
let mut exit_rx = spawned.exit_rx;
let exit_code = tokio::select! {
res = &mut exit_rx => res.unwrap_or(-1),
res = tokio::signal::ctrl_c() => {
if let Ok(()) = res {
session.request_terminate();
}
exit_rx.await.unwrap_or(-1)
}
};
stdin_close_task.abort();
let _ = tokio::time::timeout(output_drain_timeout, async {
let _ = stdout_forwarder_done_rx.await;
let _ = stderr_forwarder_done_rx.await;
})
.await;
std::process::exit(exit_code);
}
async fn spawn_debug_sandbox_child(
program: PathBuf,
args: Vec<String>,
arg0: Option<&str>,
cwd: PathBuf,
network_sandbox_policy: NetworkSandboxPolicy,
mut env: std::collections::HashMap<String, String>,
apply_env: impl FnOnce(&mut std::collections::HashMap<String, String>),
) -> std::io::Result<Child> {
let mut cmd = TokioCommand::new(&program);
#[cfg(unix)]
cmd.arg0(arg0.map_or_else(|| program.to_string_lossy().to_string(), String::from));
#[cfg(not(unix))]
let _ = arg0;
cmd.args(args);
cmd.current_dir(cwd);
apply_env(&mut env);
cmd.env_clear();
cmd.envs(env);
if !network_sandbox_policy.is_enabled() {
cmd.env(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR, "1");
}
cmd.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.kill_on_drop(true)
.spawn()
}
#[cfg(target_os = "windows")]
mod windows_stdio_bridge {
use std::io::Read;
use std::io::Write;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
const STDIN_FORWARD_CHUNK_SIZE: usize = 8 * 1024;
pub(super) fn spawn_input_forwarder<R>(
mut input: R,
writer_tx: mpsc::Sender<Vec<u8>>,
stdin_eof_tx: oneshot::Sender<()>,
) -> std::thread::JoinHandle<()>
where
R: Read + Send + 'static,
{
std::thread::spawn(move || {
let mut buffer = [0_u8; STDIN_FORWARD_CHUNK_SIZE];
loop {
match input.read(&mut buffer) {
Ok(0) => break,
Ok(n) => {
if writer_tx.blocking_send(buffer[..n].to_vec()).is_err() {
break;
}
}
Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
Err(err) => {
eprintln!("windows sandbox stdin forwarder failed: {err}");
break;
}
}
}
let _ = stdin_eof_tx.send(());
})
}
pub(super) fn spawn_output_forwarder<W>(
tokio_runtime: tokio::runtime::Handle,
output_rx: mpsc::Receiver<Vec<u8>>,
mut writer: W,
) -> (std::thread::JoinHandle<()>, oneshot::Receiver<()>)
where
W: Write + Send + 'static,
{
let (done_tx, done_rx) = oneshot::channel();
// The sandbox session emits output on Tokio channels, but writing to the
// caller's stdio is simplest from a dedicated blocking thread.
let handle = std::thread::spawn(move || {
let mut output_rx = output_rx;
while let Some(chunk) = tokio_runtime.block_on(output_rx.recv()) {
if let Err(err) = writer.write_all(&chunk) {
eprintln!("windows sandbox output forwarder failed to write: {err}");
break;
}
if let Err(err) = writer.flush() {
eprintln!("windows sandbox output forwarder failed to flush: {err}");
break;
}
}
let _ = done_tx.send(());
});
(handle, done_rx)
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use pretty_assertions::assert_eq;
use super::*;
#[tokio::test]
async fn input_forwarder_sends_chunks_and_reports_eof() -> anyhow::Result<()> {
let (writer_tx, mut writer_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(4);
let (stdin_closed_tx, stdin_closed_rx) = tokio::sync::oneshot::channel();
let input = std::io::Cursor::new(b"first\nsecond\n".to_vec());
let forwarder = spawn_input_forwarder(input, writer_tx, stdin_closed_tx);
let mut received = Vec::new();
while let Some(chunk) = writer_rx.recv().await {
received.extend_from_slice(&chunk);
}
stdin_closed_rx.await?;
forwarder.join().expect("stdin forwarder should finish");
assert_eq!(received, b"first\nsecond\n".to_vec());
Ok(())
}
#[tokio::test]
async fn output_forwarder_writes_all_chunks() -> anyhow::Result<()> {
#[derive(Clone, Default)]
struct SharedWriter(std::sync::Arc<Mutex<Vec<u8>>>);
impl std::io::Write for SharedWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let mut guard = self
.0
.lock()
.map_err(|_| std::io::Error::other("writer poisoned"))?;
guard.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let runtime = tokio::runtime::Handle::current();
let (output_tx, output_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(4);
let writer = SharedWriter::default();
let sink = std::sync::Arc::clone(&writer.0);
let (forwarder, done_rx) = spawn_output_forwarder(runtime, output_rx, writer);
output_tx.send(b"alpha".to_vec()).await?;
output_tx.send(b"beta".to_vec()).await?;
drop(output_tx);
forwarder.join().expect("output forwarder should finish");
done_rx.await?;
let output = sink
.lock()
.map_err(|_| anyhow::anyhow!("writer poisoned"))?
.clone();
assert_eq!(output, b"alphabeta".to_vec());
Ok(())
}
}
}
async fn load_debug_sandbox_config(
cli_overrides: Vec<(String, TomlValue)>,
codex_linux_sandbox_exe: Option<PathBuf>,
options: DebugSandboxConfigOptions,
strict_config: bool,
) -> anyhow::Result<Config> {
load_debug_sandbox_config_with_codex_home(
cli_overrides,
codex_linux_sandbox_exe,
options,
/*codex_home*/ None,
strict_config,
)
.await
}
async fn load_debug_sandbox_config_with_codex_home(
mut cli_overrides: Vec<(String, TomlValue)>,
codex_linux_sandbox_exe: Option<PathBuf>,
options: DebugSandboxConfigOptions,
codex_home: Option<PathBuf>,
strict_config: bool,
) -> anyhow::Result<Config> {
let DebugSandboxConfigOptions {
permissions_profile,
cwd,
managed_requirements_mode,
} = options;
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
// legacy `sandbox_mode` CLI override, which is now the documented writable
// replacement for the removed `--full-auto` flag.
let uses_legacy_sandbox_mode_override = cli_overrides_use_legacy_sandbox_mode(&cli_overrides);
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,
strict_config,
)
.await?;
if config_uses_permission_profiles(&config) || uses_legacy_sandbox_mode_override {
return Ok(config);
}
build_debug_sandbox_config(
cli_overrides,
ConfigOverrides {
sandbox_mode: Some(SandboxMode::ReadOnly),
cwd,
codex_linux_sandbox_exe,
..Default::default()
},
codex_home,
managed_requirements_mode,
strict_config,
)
.await
.map_err(Into::into)
}
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> {
let mut builder = ConfigBuilder::default()
.cli_overrides(cli_overrides)
.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()
});
}
if let Some(codex_home) = codex_home {
builder = builder
.codex_home(codex_home.clone())
.fallback_cwd(Some(codex_home));
}
builder.build().await
}
fn config_uses_permission_profiles(config: &Config) -> bool {
config
.config_layer_stack
.effective_config()
.get("default_permissions")
.is_some()
}
fn cli_overrides_use_legacy_sandbox_mode(cli_overrides: &[(String, TomlValue)]) -> bool {
cli_overrides.iter().any(|(key, _)| key == "sandbox_mode")
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
fn escape_toml_path(path: &std::path::Path) -> String {
path.display().to_string().replace('\\', "\\\\")
}
fn write_permissions_profile_config(
codex_home: &TempDir,
docs: &std::path::Path,
private: &std::path::Path,
) -> std::io::Result<()> {
std::fs::create_dir_all(private)?;
let config = format!(
"default_permissions = \"limited-read-test\"\n\
[permissions.limited-read-test.filesystem]\n\
\":minimal\" = \"read\"\n\
\"{}\" = \"read\"\n\
\"{}\" = \"none\"\n\
\n\
[permissions.limited-read-test.network]\n\
enabled = true\n",
escape_toml_path(docs),
escape_toml_path(private),
);
std::fs::write(codex_home.path().join("config.toml"), config)?;
Ok(())
}
#[tokio::test]
async fn debug_sandbox_honors_active_permission_profiles() -> 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 codex_home_path = codex_home.path().to_path_buf();
let profile_config = build_debug_sandbox_config(
Vec::new(),
ConfigOverrides::default(),
Some(codex_home_path.clone()),
ManagedRequirementsMode::Include,
/*strict_config*/ false,
)
.await?;
let legacy_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,
},
Some(codex_home_path),
/*strict_config*/ false,
)
.await?;
assert!(config_uses_permission_profiles(&config));
assert!(
profile_config.permissions.file_system_sandbox_policy()
!= legacy_config.permissions.file_system_sandbox_policy(),
"test fixture should distinguish profile syntax from legacy sandbox_mode"
);
assert_eq!(
config.permissions.file_system_sandbox_policy(),
profile_config.permissions.file_system_sandbox_policy(),
);
assert_ne!(
config.permissions.file_system_sandbox_policy(),
legacy_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()?;
let codex_home_path = codex_home.path().to_path_buf();
let cli_overrides = vec![(
"sandbox_mode".to_string(),
TomlValue::String("workspace-write".to_string()),
)];
let workspace_write_config = build_debug_sandbox_config(
cli_overrides.clone(),
ConfigOverrides::default(),
Some(codex_home_path.clone()),
ManagedRequirementsMode::Include,
/*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(
cli_overrides,
/*codex_linux_sandbox_exe*/ None,
DebugSandboxConfigOptions {
permissions_profile: None,
cwd: None,
managed_requirements_mode: ManagedRequirementsMode::Include,
},
Some(codex_home_path),
/*strict_config*/ false,
)
.await?;
if cfg!(target_os = "windows") {
assert_eq!(
workspace_write_config
.permissions
.file_system_sandbox_policy(),
read_only_config.permissions.file_system_sandbox_policy(),
"workspace-write downgrades to read-only when the Windows sandbox is disabled"
);
} else {
assert_ne!(
workspace_write_config
.permissions
.file_system_sandbox_policy(),
read_only_config.permissions.file_system_sandbox_policy(),
"test fixture should distinguish explicit workspace-write from read-only"
);
}
assert_eq!(
config.permissions.file_system_sandbox_policy(),
workspace_write_config
.permissions
.file_system_sandbox_policy(),
);
Ok(())
}
#[tokio::test]
async fn debug_sandbox_defaults_legacy_configs_to_read_only() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let codex_home_path = codex_home.path().to_path_buf();
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,
},
Some(codex_home_path),
/*strict_config*/ false,
)
.await?;
assert!(!config_uses_permission_profiles(&config));
assert_eq!(
config.permissions.file_system_sandbox_policy(),
read_only_config.permissions.file_system_sandbox_policy(),
);
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,
DebugSandboxConfigOptions {
permissions_profile: Some(":workspace".to_string()),
cwd: None,
managed_requirements_mode: ManagedRequirementsMode::Ignore,
},
Some(codex_home.path().to_path_buf()),
/*strict_config*/ false,
)
.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,
DebugSandboxConfigOptions {
permissions_profile: Some(":workspace".to_string()),
cwd: None,
managed_requirements_mode: ManagedRequirementsMode::Ignore,
},
Some(codex_home.path().to_path_buf()),
/*strict_config*/ false,
)
.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,
DebugSandboxConfigOptions {
permissions_profile: Some("limited-read-test".to_string()),
cwd: None,
managed_requirements_mode: ManagedRequirementsMode::Ignore,
},
Some(codex_home.path().to_path_buf()),
/*strict_config*/ false,
)
.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()),
ManagedRequirementsMode::Include,
/*strict_config*/ false,
)
.await?;
assert_eq!(
config.permissions.file_system_sandbox_policy(),
expected.permissions.file_system_sandbox_policy()
);
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()),
/*strict_config*/ false,
)
.await?;
assert_eq!(config.cwd.as_path(), cwd.path());
Ok(())
}
}