Route sandbox CLI launches through the shared sandbox manager (#60517)

## What changed

- Use `SandboxManager` to prepare `codex sandbox` commands, including permission profiles, workspace roots, and managed network proxy settings.
- Return a `WrappedSandboxCommand` that can produce an execution request or a configured process command, preserving native executable paths and Linux helper `argv[0]`. Require the session launcher for native Windows token sandboxes.
- Move macOS inherited-terminal policy handling into the shared manager, retaining Unix socket grants and the terminal input injection denial.
- Prepare managed network context alongside proxy environment variables and pass it through sandbox transformation.

## Testing

- Extend sandbox network proxy tests to macOS, covering blocked direct loopback access and explicitly allowed requests.
- Add coverage for Linux helper executable paths and dispatch names, including non-UTF-8 paths.

GitOrigin-RevId: 8b7396d21b2f34122699388f16047329a3f5e5c6
This commit is contained in:
viyatb-oai
2026-11-10 04:01:23 +00:00
committed by copyberry
parent 551fc719d6
commit d2efdf1986
12 changed files with 353 additions and 308 deletions
+113 -239
View File
@@ -22,18 +22,18 @@ use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
use codex_core::windows_sandbox::local_binding_policy_for_sandbox;
use codex_core::windows_sandbox::managed_proxy_routing_for_windows_sandbox;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::SandboxEnforcement;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_sandboxing::SandboxCommand;
use codex_sandboxing::SandboxManager;
use codex_sandboxing::SandboxTransformRequest;
use codex_sandboxing::SandboxType as SandboxBackend;
use codex_sandboxing::SandboxType::WindowsMxc;
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_sandboxing::with_managed_mitm_ca_readable_root;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_cli::CliConfigOverrides;
use codex_utils_path_uri::PathUri;
use tokio::process::Child;
use tokio::process::Command as TokioCommand;
use toml::Value as TomlValue;
@@ -208,7 +208,10 @@ async fn run_command_under_sandbox(
codex_linux_sandbox_exe: Option<PathBuf>,
sandbox_type: SandboxType,
log_denials: bool,
#[cfg_attr(not(target_os = "macos"), allow(unused_variables))]
#[cfg_attr(
not(any(target_os = "linux", target_os = "macos")),
allow(unused_variables)
)]
allow_unix_sockets: &[AbsolutePathBuf],
) -> anyhow::Result<()> {
let sandbox_state = config_options
@@ -249,18 +252,12 @@ async fn run_command_under_sandbox(
)
.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();
// Non-Windows sandbox launchers still use `sandbox_policy_cwd` for any
// remaining cwd-dependent policy resolution. `:workspace_roots` entries in
// the effective profile have already been materialized from config roots.
let sandbox_policy_cwd = cwd.clone();
let cwd = &config.cwd;
let env = create_env(
&config.permissions.shell_environment_policy,
/*thread_id*/ None,
);
let mut permission_profile = match sandbox_state.as_ref() {
let base_profile = match sandbox_state.as_ref() {
Some(state) => match &state.permission_profile {
PermissionProfile::External { .. } => {
// `External` only says that the producer relies on an outer sandbox; it does not
@@ -271,29 +268,28 @@ async fn run_command_under_sandbox(
}
permission_profile => permission_profile.clone(),
},
None if matches!(sandbox_type, SandboxType::Windows) => {
config.permissions.permission_profile().clone()
}
None => config.permissions.effective_permission_profile(),
None => config.permissions.permission_profile().clone(),
};
if matches!(permission_profile, PermissionProfile::Disabled) && sandbox_state_disable_network {
if matches!(base_profile, PermissionProfile::Disabled) && sandbox_state_disable_network {
anyhow::bail!(
"--sandbox-state-disable-network cannot be applied to a disabled permission profile"
);
}
if !matches!(permission_profile, PermissionProfile::Disabled)
let permission_profile = if !matches!(base_profile, PermissionProfile::Disabled)
&& (!sandbox_state_readable_root.is_empty() || sandbox_state_disable_network)
{
let file_system = permission_profile
let file_system = base_profile
.file_system_sandbox_policy()
.with_additional_readable_roots(&cwd, &sandbox_state_readable_root);
.with_additional_readable_roots(cwd, &sandbox_state_readable_root);
let network = if sandbox_state_disable_network {
NetworkSandboxPolicy::Restricted
} else {
permission_profile.network_sandbox_policy()
base_profile.network_sandbox_policy()
};
permission_profile = PermissionProfile::from_runtime_permissions(&file_system, network);
}
PermissionProfile::from_runtime_permissions(&file_system, network)
} else {
base_profile
};
let use_legacy_landlock = sandbox_state.as_ref().map_or_else(
|| config.features.use_legacy_landlock(),
|state| state.use_legacy_landlock,
@@ -307,237 +303,130 @@ async fn run_command_under_sandbox(
config.effective_local_windows_sandbox_type()
};
match permission_profile.enforcement() {
SandboxEnforcement::Managed => {}
SandboxEnforcement::Disabled | SandboxEnforcement::External => {
let (program, args) = command
.split_first()
.context("sandbox command must not be empty")?;
let mut child = spawn_debug_sandbox_child(
PathBuf::from(program),
args.to_vec(),
/*arg0*/ None,
cwd.to_path_buf(),
permission_profile.network_sandbox_policy(),
env,
|_| {},
)
.await?;
handle_exit_status(child.wait().await?);
}
}
// Legacy Windows sandbox sessions emulate inherited stdio and exit the process.
if let SandboxType::Windows = sandbox_type {
#[cfg(target_os = "windows")]
{
if windows_sandbox_type != WindowsMxc {
if sandbox_state.is_none() {
permission_profile = config.permissions.effective_permission_profile();
}
let sandbox = match permission_profile.enforcement() {
SandboxEnforcement::Disabled | SandboxEnforcement::External => SandboxBackend::None,
SandboxEnforcement::Managed => match sandbox_type {
#[cfg(target_os = "macos")]
SandboxType::Seatbelt => SandboxBackend::MacosSeatbelt,
SandboxType::Landlock => SandboxBackend::LinuxSeccomp,
#[cfg(windows)]
SandboxType::Windows if windows_sandbox_type == WindowsMxc => WindowsMxc,
#[cfg(windows)]
SandboxType::Windows => {
let session_profile = if sandbox_state.is_none() {
config.permissions.effective_permission_profile()
} else {
permission_profile
};
let workspace_roots = config
.effective_workspace_roots()
.iter()
.map(codex_utils_path_uri::PathUri::to_abs_path)
.map(PathUri::to_abs_path)
.collect::<std::io::Result<Vec<_>>>()
.context("invalid Windows sandbox workspace roots")?;
run_command_under_windows_session(
&config,
&permission_profile,
&session_profile,
command,
cwd,
cwd.clone(),
workspace_roots,
env,
)
.await;
}
}
#[cfg(not(target_os = "windows"))]
{
anyhow::bail!("Windows sandbox is only available on Windows");
}
}
#[cfg(not(windows))]
SandboxType::Windows => anyhow::bail!("Windows sandbox is only available on Windows"),
},
};
#[cfg(target_os = "macos")]
let mut denial_logger = log_denials.then(DenialLogger::new).flatten();
let mut denial_logger = (log_denials && sandbox == SandboxBackend::MacosSeatbelt)
.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(
// Keep the proxy alive until the child exits.
let network_proxy = if sandbox != SandboxBackend::None
&& let Some(spec) = &config.permissions.network
{
Some(
spec.start_proxy(
&permission_profile,
managed_proxy_routing_for_windows_sandbox(windows_sandbox_type),
local_binding_policy_for_sandbox(windows_sandbox_type, Some(std::env::consts::OS)),
/*policy_decider*/ None,
/*blocked_request_observer*/ None,
managed_network_requirements_enabled,
config.managed_network_requirements_enabled(),
NetworkProxyAuditMetadata::default(),
)
.await
.map_err(|err| anyhow::anyhow!("failed to start managed network proxy: {err}"))?,
),
None => None,
)
} else {
None
};
let network = network_proxy
.as_ref()
.map(codex_core::config::StartedNetworkProxy::proxy);
// Proxy containment depends on whether a proxy is active, not whether its
// policy came from managed requirements.
#[cfg(any(target_os = "macos", target_os = "windows"))]
let enforce_managed_network = network.is_some();
// MXC adds the CA read grant in transform after binding the workspace roots.
let managed_mitm_ca_trust_bundle_path = match (network.as_ref(), &sandbox_type) {
(None, _) | (_, SandboxType::Windows) => None,
(Some(network), _) => network.managed_mitm_ca_trust_bundle_path(),
let sandbox_exe = match sandbox {
SandboxBackend::LinuxSeccomp => config.codex_linux_sandbox_exe.clone(),
WindowsMxc => Some(std::env::current_exe()?),
SandboxBackend::None
| SandboxBackend::MacosSeatbelt
| SandboxBackend::WindowsRestrictedToken => None,
};
let runtime_permission_profile = with_managed_mitm_ca_readable_root(
permission_profile,
managed_mitm_ca_trust_bundle_path.as_ref(),
sandbox_policy_cwd.as_path(),
);
let mut child = match sandbox_type {
#[cfg(target_os = "macos")]
SandboxType::Seatbelt => {
let (file_system_sandbox_policy, network_sandbox_policy) =
runtime_permission_profile.to_runtime_permissions();
let mut 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,
managed_network: None,
environment_id: None,
network: network.as_ref(),
extra_allow_unix_sockets: allow_unix_sockets,
})
.map_err(|err| anyhow::anyhow!(err))?;
// This CLI inherits the user's controlling terminal. Keep this deny
// after every shared policy allowance so the child cannot queue input
// for the unsandboxed shell that resumes when Codex exits.
match args.as_mut_slice() {
[flag, policy, ..] if flag.as_str() == "-p" => {
// Older macOS policy compilers do not define the TIOCSTI symbol.
policy.push_str(&format!(
"\n(deny file-ioctl (ioctl-command {}))",
libc::TIOCSTI
));
}
_ => anyhow::bail!("Seatbelt command is missing its generated policy"),
}
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 network_sandbox_policy = runtime_permission_profile.network_sandbox_policy();
let (env, managed_network) = if let Some(network) = network.as_ref() {
let prepared =
network.prepare_for_optional_environment(env, /*environment_id*/ None)?;
(prepared.env, Some(prepared.sandbox_context))
} else {
(env, None)
};
let args = create_linux_sandbox_command_args_for_permission_profile(
command,
cwd.as_path(),
&runtime_permission_profile,
sandbox_policy_cwd.as_path(),
use_legacy_landlock,
managed_network.as_ref(),
);
spawn_debug_sandbox_child(
codex_linux_sandbox_exe,
args,
Some("codex-linux-sandbox"),
cwd.to_path_buf(),
network_sandbox_policy,
env,
|_| {},
)
.await?
}
#[cfg(not(target_os = "windows"))]
SandboxType::Windows => {
unreachable!("Windows sandbox should have been handled above");
}
#[cfg(target_os = "windows")]
SandboxType::Windows => {
use codex_sandboxing::SandboxCommand;
use codex_sandboxing::SandboxManager;
use codex_sandboxing::SandboxTransformRequest;
use codex_utils_path_uri::PathUri;
let (program, args) = command
.split_first()
.context("sandbox command must not be empty")?;
let sandbox_policy_cwd = PathUri::from_abs_path(&sandbox_policy_cwd);
let codex_self_exe = std::env::current_exe()?;
let workspace_roots = sandbox_state.is_none().then(|| {
config
.permissions
.workspace_roots()
.iter()
.map(PathUri::from_abs_path)
.collect::<Vec<_>>()
});
let request = SandboxManager::new().transform(SandboxTransformRequest {
command: SandboxCommand {
program: program.into(),
args: args.to_vec(),
cwd: PathUri::from_abs_path(&cwd),
env,
managed_network: None,
additional_permissions: None,
},
permissions: &runtime_permission_profile,
workspace_roots: workspace_roots.as_deref(),
sandbox: WindowsMxc,
enforce_managed_network,
environment_id: None,
network: network.as_ref(),
sandbox_policy_cwd: &sandbox_policy_cwd,
sandbox_exe: Some(codex_self_exe.as_path()),
use_legacy_landlock: false,
windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled,
})?;
let (program, args) = request
.command
.split_first()
.context("missing MXC wrapper")?;
spawn_debug_sandbox_child(
PathBuf::from(program),
args.to_vec(),
/*arg0*/ None,
cwd.to_path_buf(),
runtime_permission_profile.network_sandbox_policy(),
request.env,
|_| {},
)
.await?
let mut command = command.into_iter();
let program = command
.next()
.context("sandbox command must not be empty")?;
let cwd = PathUri::from_abs_path(cwd);
#[cfg(any(target_os = "linux", target_os = "macos"))]
let manager = SandboxManager::for_inherited_terminal(allow_unix_sockets);
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
let manager = SandboxManager::new();
let workspace_roots = sandbox_state.is_none().then(|| {
config
.permissions
.workspace_roots()
.iter()
.map(PathUri::from_abs_path)
.collect::<Vec<_>>()
});
#[cfg(target_os = "macos")]
let env = {
let mut env_map = env;
if sandbox == SandboxBackend::MacosSeatbelt {
env_map.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string());
}
env_map
};
let wrapped = manager.transform(SandboxTransformRequest {
command: SandboxCommand {
program: program.into(),
args: command.collect(),
cwd: cwd.clone(),
env,
managed_network: None,
additional_permissions: None,
},
permissions: &permission_profile,
workspace_roots: workspace_roots.as_deref(),
sandbox,
enforce_managed_network: network.is_some(),
network: network.as_ref(),
environment_id: None,
sandbox_policy_cwd: &cwd,
sandbox_exe: sandbox_exe.as_deref(),
use_legacy_landlock,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
})?;
let mut child = spawn_debug_sandbox_child(
wrapped.into_direct_command()?,
permission_profile.network_sandbox_policy(),
)
.await?;
#[cfg(target_os = "macos")]
if let Some(denial_logger) = &mut denial_logger {
@@ -627,25 +516,10 @@ async fn run_command_under_windows_session(
}
async fn spawn_debug_sandbox_child(
program: PathBuf,
args: Vec<String>,
arg0: Option<&str>,
cwd: PathBuf,
command: std::process::Command,
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);
let mut cmd = TokioCommand::from(command);
if !network_sandbox_policy.is_enabled() {
cmd.env(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR, "1");
}
+38 -13
View File
@@ -1,4 +1,4 @@
#![cfg(target_os = "linux")]
#![cfg(any(target_os = "linux", target_os = "macos"))]
use std::net::TcpListener;
@@ -10,12 +10,36 @@ use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
const BWRAP_UNAVAILABLE_ERR: &str = "bubblewrap is unavailable";
#[cfg(target_os = "macos")]
const LOOPBACK: &str = "127.0.0.1";
#[cfg(target_os = "linux")]
const LOOPBACK: &str = "127.0.0.2";
fn sandbox_unavailable(stderr: &str) -> Result<bool> {
#[cfg(target_os = "linux")]
if stderr.contains("bubblewrap is unavailable") {
eprintln!("skipping network proxy sandbox test: bubblewrap is unavailable");
return Ok(true);
}
#[cfg(target_os = "macos")]
if stderr.contains("sandbox-exec: sandbox_apply: Operation not permitted") {
let control = std::process::Command::new("/usr/bin/sandbox-exec")
.args(["-p", "(version 1)(allow default)", "/usr/bin/true"])
.output()?;
if String::from_utf8_lossy(&control.stderr)
.contains("sandbox_apply: Operation not permitted")
{
eprintln!("skipping network proxy sandbox test: nested Seatbelt is unavailable");
return Ok(true);
}
}
Ok(false)
}
#[test]
fn sandbox_with_network_proxy_blocks_direct_loopback_access() -> Result<()> {
let codex_home = TempDir::new()?;
let listener = TcpListener::bind("127.0.0.2:0")?;
let listener = TcpListener::bind((LOOPBACK, 0))?;
let port = listener.local_addr()?.port();
std::fs::write(
codex_home.path().join("config.toml"),
@@ -35,7 +59,7 @@ mode = "full"
"#,
)?;
let url = format!("http://127.0.0.2:{port}/");
let url = format!("http://{LOOPBACK}:{port}/");
let output = std::process::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?)
.env("CODEX_HOME", codex_home.path())
.args([
@@ -57,8 +81,7 @@ mode = "full"
.output()?;
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains(BWRAP_UNAVAILABLE_ERR) {
eprintln!("skipping network proxy sandbox test: bubblewrap is unavailable");
if sandbox_unavailable(&stderr)? {
return Ok(());
}
@@ -77,7 +100,7 @@ mode = "full"
#[tokio::test]
async fn sandbox_with_network_proxy_allows_explicit_loopback_access() -> Result<()> {
let codex_home = TempDir::new()?;
let listener = TcpListener::bind("127.0.0.2:0")?;
let listener = TcpListener::bind((LOOPBACK, 0))?;
let port = listener.local_addr()?.port();
let server = MockServer::builder().listener(listener).start().await;
Mock::given(method("GET"))
@@ -87,7 +110,8 @@ async fn sandbox_with_network_proxy_allows_explicit_loopback_access() -> Result<
.await;
std::fs::write(
codex_home.path().join("config.toml"),
r#"
format!(
r#"
default_permissions = "network-test"
[features]
@@ -103,11 +127,12 @@ mode = "full"
allow_local_binding = false
[permissions.network-test.network.domains]
"127.0.0.2" = "allow"
"#,
"{LOOPBACK}" = "allow"
"#
),
)?;
let url = format!("http://127.0.0.2:{port}/");
let url = format!("http://{LOOPBACK}:{port}/");
let output = tokio::process::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?)
.env("CODEX_HOME", codex_home.path())
.args([
@@ -129,8 +154,7 @@ allow_local_binding = false
.await?;
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains(BWRAP_UNAVAILABLE_ERR) {
eprintln!("skipping network proxy sandbox test: bubblewrap is unavailable");
if sandbox_unavailable(&stderr)? {
return Ok(());
}
@@ -141,5 +165,6 @@ allow_local_binding = false
String::from_utf8_lossy(&output.stdout),
stderr,
);
assert_eq!(server.received_requests().await.unwrap().len(), 1);
Ok(())
}