mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
c25d905f61
## Why This is the configuration/model half of the alternative permissions migration we discussed as a comparison point for [#22401](https://github.com/openai/codex/pull/22401) and [#22402](https://github.com/openai/codex/pull/22402). The old `workspace-write` model mixes three concerns that we want to keep separate: - reusable profile rules that should stay immutable once selected - user/runtime workspace roots from `cwd`, `--add-dir`, and legacy workspace-write config - internal Codex writable roots such as memories, which should not be shown as user workspace roots This PR gives permission profiles first-class `workspace_roots` so users can opt multiple repositories into the same `:workspace_roots` rules without using broad absolute-path write grants. It also starts separating the raw selected profile from the effective runtime profile by making `Permissions` expose explicit accessors instead of public mutable fields. A representative `config.toml` looks like this: ```toml default_permissions = "dev" [permissions.dev.workspace_roots] "~/code/openai" = true "~/code/developers-website" = true [permissions.dev.filesystem.":workspace_roots"] "." = "write" ".codex" = "read" ".git" = "read" ".vscode" = "read" ``` If Codex starts in `~/code/codex` with that profile selected, the effective workspace-root set becomes: - `~/code/codex` from the runtime `cwd` - `~/code/openai` from the profile - `~/code/developers-website` from the profile The `:workspace_roots` rules are materialized across each root, so `.git`, `.codex`, and `.vscode` stay scoped the same way everywhere. Runtime additions such as `--add-dir` can still layer on later stack entries without mutating the selected profile. ## Stack Shape This PR intentionally stops before the profile-identity cleanup in [#22683](https://github.com/openai/codex/pull/22683) so the base review stays focused on config loading, workspace-root materialization, and compatibility with legacy `workspace-write`. The representation in this PR is therefore transitional: `Permissions` carries enough state to distinguish the raw constrained profile from the effective runtime profile, and there are still call sites that must keep the active profile identity and constrained profile value in sync. The follow-up PR replaces that with a single resolved profile state (`ResolvedPermissionProfile` / `PermissionProfileState`) that keeps the profile id, immutable `PermissionProfile`, and profile-declared workspace roots together. That follow-up removes APIs such as `set_constrained_permission_profile_with_active_profile()` where separate arguments could drift out of sync. Downstream PRs then build on this base to switch app-server turn updates to profile ids plus runtime workspace roots and to finish the user-visible summary behavior. Reviewers should judge this PR as the workspace-roots foundation, not as the final in-memory shape of selected permission profiles. ## Review Guide Suggested review order: 1. Start with `codex-rs/core/src/config/mod.rs`. This is the main shape change in the base slice. `Permissions` now stores a private raw `Constrained<PermissionProfile>` plus runtime `workspace_roots`. Callers use `permission_profile()` when they need the raw constrained value and `effective_permission_profile()` when they need a materialized runtime profile. As noted above, [#22683](https://github.com/openai/codex/pull/22683) replaces this transitional shape with a resolved profile state that keeps identity and profile data together. 2. Review `codex-rs/config/src/permissions_toml.rs` and `codex-rs/core/src/config/permissions.rs`. These add `[permissions.<id>.workspace_roots]`, resolve enabled entries relative to the policy cwd, and keep `:workspace_roots` deny-read glob patterns symbolic until the actual roots are known. 3. Review `codex-rs/protocol/src/permissions.rs` and `codex-rs/protocol/src/models.rs`. These add the policy/profile materialization helpers that expand exact `:workspace_roots` entries and scoped deny-read globs over every workspace root. This is also where `ActivePermissionProfileModification` is removed from the core model. 4. Review the legacy bridge in `Config::load_from_base_config_with_overrides` and `Config::set_legacy_sandbox_policy`. This is where legacy `workspace-write` roots become runtime workspace roots, while Codex internal writable roots stay internal and do not appear as user-facing workspace roots. 5. Then skim downstream call sites. The interesting pattern is raw-vs-effective access: state/proxy/bwrap paths keep the raw constrained profile, while execution, summaries, and user-visible status use the effective profile and workspace-root list. ## What Changed - added `[permissions.<id>.workspace_roots]` to the config model and schema - added runtime `workspace_roots` state to `Config`/`Permissions` and `ConfigOverrides` - made `Permissions` profile fields private and replaced direct mutation with accessors/setters - added `PermissionProfile` and `FileSystemSandboxPolicy` helpers for materializing `:workspace_roots` exact paths and deny-read globs across all roots - moved legacy additional writable roots into runtime workspace-root state instead of active profile modifications - removed `ActivePermissionProfileModification` and its app-server protocol/schema export - updated sandbox/status summary paths so internal writable roots are not reported as user workspace roots ## Verification Strategy The targeted tests cover the behavior at the layers where regressions are most likely: - `codex-rs/core/src/config/config_tests.rs` verifies config loading, legacy workspace-root seeding, effective profile materialization, and memory-root handling. - `codex-rs/core/src/config/permissions_tests.rs` verifies profile `workspace_roots` parsing and `:workspace_roots` scoped/glob compilation. - `codex-rs/protocol/src/permissions.rs` unit tests verify exact and glob materialization over multiple workspace roots. - `codex-rs/tui/src/status/tests.rs` and `codex-rs/utils/sandbox-summary/src/sandbox_summary.rs` verify the user-facing summaries show effective workspace roots and hide internal writes. I also ran `cargo check --tests` locally after the latest stack refresh to catch cross-crate API breakage from the private-field/accessor changes. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/22610). * #22612 * #22611 * #22683 * __->__ #22610
1090 lines
35 KiB
Rust
1090 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.effective_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?;
|
|
|
|
let actual = config
|
|
.permissions
|
|
.permission_profile()
|
|
.get()
|
|
.file_system_sandbox_policy();
|
|
let expected = codex_protocol::models::PermissionProfile::workspace_write()
|
|
.file_system_sandbox_policy();
|
|
assert!(
|
|
expected
|
|
.entries
|
|
.iter()
|
|
.all(|entry| actual.entries.contains(entry)),
|
|
"explicit workspace profile should preserve the built-in workspace rules"
|
|
);
|
|
|
|
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?;
|
|
|
|
let actual = config
|
|
.permissions
|
|
.permission_profile()
|
|
.get()
|
|
.file_system_sandbox_policy();
|
|
let expected = codex_protocol::models::PermissionProfile::workspace_write()
|
|
.file_system_sandbox_policy();
|
|
assert!(
|
|
expected
|
|
.entries
|
|
.iter()
|
|
.all(|entry| actual.entries.contains(entry)),
|
|
"explicit workspace profile should preserve the built-in workspace rules"
|
|
);
|
|
|
|
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(())
|
|
}
|
|
}
|