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
+1 -1
View File
@@ -23,7 +23,7 @@ pub fn allow_network_for_proxy(enforce_managed_network: bool) -> bool {
/// after parsing these arguments. The profile JSON flag is emitted before
/// helper feature flags so the argv order matches the helper's CLI shape. See
/// `docs/linux_sandbox.md` for the Linux semantics.
pub fn create_linux_sandbox_command_args_for_permission_profile(
pub(crate) fn create_linux_sandbox_command_args_for_permission_profile(
command: Vec<String>,
command_cwd: &Path,
permission_profile: &PermissionProfile,
+51
View File
@@ -1,12 +1,63 @@
//! Bind filesystem and network inputs for sandbox preparation on the execution host.
use crate::SandboxExecRequest;
use crate::SandboxType;
use std::ffi::OsStr;
use std::ffi::OsString;
use std::io;
use std::process::Command;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemPath;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
/// A command prepared for its selected sandbox. Native Windows token sessions
/// must use the Windows session launcher.
#[must_use]
pub struct WrappedSandboxCommand {
pub(super) request: SandboxExecRequest,
// UTF-8 argv cannot represent every native executable path.
pub(super) native_program: Option<OsString>,
}
impl WrappedSandboxCommand {
/// Return the request with UTF-8 argv for callers that spawn the command themselves.
pub fn into_command_request(self) -> SandboxExecRequest {
self.request
}
/// Return a configured process command when argv carries all required sandbox enforcement.
pub fn into_direct_command(self) -> io::Result<Command> {
let request = self.request;
if request.sandbox == SandboxType::WindowsRestrictedToken {
return Err(io::Error::new(
io::ErrorKind::Unsupported,
"a native Windows sandbox requires its session launcher",
));
}
let (program, args) = request.command.split_first().ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "sandbox command is empty")
})?;
let mut command = Command::new(
self.native_program
.as_deref()
.unwrap_or_else(|| OsStr::new(program)),
);
#[cfg(unix)]
if let Some(arg0) = request.arg0 {
use std::os::unix::process::CommandExt;
command.arg0(arg0);
}
command
.args(args)
.current_dir(request.cwd.to_abs_path()?)
.env_clear()
.envs(request.env);
Ok(command)
}
}
/// The authorized profile, resolved on the host that will enforce it.
#[derive(Debug)]
pub(super) struct PreparedLaunchPolicy {
+1
View File
@@ -40,6 +40,7 @@ pub use manager::SandboxType;
pub use manager::SandboxablePreference;
pub use manager::compatibility_sandbox_policy_for_permission_profile;
pub use manager::get_platform_sandbox;
pub use manager::launch::WrappedSandboxCommand;
pub use manager::with_managed_mitm_ca_readable_root;
pub use sandbox_integrity::FileContentsChecker;
pub use sandbox_integrity::IntegrityFinding;
+54 -10
View File
@@ -1,4 +1,5 @@
use self::launch::PreparedLaunchPolicy;
use self::launch::WrappedSandboxCommand;
use crate::LinuxSandboxPidNamespace;
#[cfg(windows)]
use crate::WindowsSandboxFilesystemOverrides;
@@ -277,6 +278,8 @@ pub struct SandboxManager {
#[cfg(target_os = "macos")]
seatbelt_profile: MacosSeatbeltProfile,
#[cfg(target_os = "macos")]
extra_allow_unix_sockets: Vec<AbsolutePathBuf>,
#[cfg(target_os = "macos")]
allowed_symlinked_codex_home: Option<AbsolutePathBuf>,
}
@@ -288,11 +291,23 @@ impl SandboxManager {
/// Creates a manager that applies the narrower runtime profile required by filesystem helpers.
pub fn for_file_system_helpers() -> Self {
Self {
linux_sandbox_pid_namespace: LinuxSandboxPidNamespace::default(),
#[cfg(target_os = "macos")]
seatbelt_profile: MacosSeatbeltProfile::FileSystemHelper,
..Self::default()
}
}
/// Prepare commands that inherit the controlling terminal. On macOS this also
/// grants access to the named Unix sockets and denies terminal input injection.
pub fn for_inherited_terminal(allow_unix_sockets: &[AbsolutePathBuf]) -> Self {
#[cfg(not(target_os = "macos"))]
let _ = allow_unix_sockets;
Self {
#[cfg(target_os = "macos")]
allowed_symlinked_codex_home: None,
seatbelt_profile: MacosSeatbeltProfile::ProcessWithInheritedTerminal,
#[cfg(target_os = "macos")]
extra_allow_unix_sockets: allow_unix_sockets.to_vec(),
..Self::default()
}
}
@@ -359,7 +374,7 @@ impl SandboxManager {
pub fn transform(
&self,
request: SandboxTransformRequest<'_>,
) -> Result<SandboxExecRequest, SandboxTransformError> {
) -> Result<WrappedSandboxCommand, SandboxTransformError> {
#[cfg(windows)]
if request.sandbox == SandboxType::WindowsRestrictedToken
&& request.enforce_managed_network
@@ -371,6 +386,19 @@ impl SandboxManager {
}
let backend = request.sandbox;
let command = request.command;
let native_program = match backend {
SandboxType::None => Some(command.program.as_os_str()),
SandboxType::LinuxSeccomp | SandboxType::WindowsMxc => {
request.sandbox_exe.map(Path::as_os_str)
}
SandboxType::MacosSeatbelt | SandboxType::WindowsRestrictedToken => None,
}
.filter(|path| path.to_str().is_none())
.map(OsStr::to_owned);
let wrapped = |request| WrappedSandboxCommand {
request,
native_program,
};
let managed_network = command
.managed_network
.filter(|_| request.enforce_managed_network || backend == SandboxType::MacosSeatbelt);
@@ -404,7 +432,7 @@ impl SandboxManager {
arg0: None,
};
if backend == SandboxType::None {
return Ok(exec);
return Ok(wrapped(exec));
}
let pending = PendingSandboxedExecRequest::new(
&exec.cwd,
@@ -469,6 +497,18 @@ impl SandboxManager {
exec.permission_profile = pending.effective_permission_profile;
let (file_system_sandbox_policy, network_sandbox_policy) =
exec.permission_profile.to_runtime_permissions();
let managed_network = match (managed_network, request.network) {
(None, Some(proxy)) => {
let prepared = proxy
.prepare_for_optional_environment(exec.env, request.environment_id)
.map_err(|err| {
SandboxTransformError::EnvironmentNetworkProxy(err.to_string())
})?;
exec.env = prepared.env;
Some(prepared.sandbox_context)
}
(context, _) => context,
};
let args = create_seatbelt_command_args_with_profile(
CreateSeatbeltCommandArgsParams {
command: std::mem::take(&mut exec.command),
@@ -479,7 +519,7 @@ impl SandboxManager {
managed_network: managed_network.as_ref(),
environment_id: request.environment_id,
network: request.network,
extra_allow_unix_sockets: &[],
extra_allow_unix_sockets: &self.extra_allow_unix_sockets,
},
self.seatbelt_profile,
self.allowed_symlinked_codex_home.as_ref(),
@@ -536,7 +576,7 @@ impl SandboxManager {
exec.permission_profile = pending?.effective_permission_profile;
}
}
Ok(exec)
Ok(wrapped(exec))
}
fn wrap_linux(
@@ -567,9 +607,12 @@ impl SandboxManager {
if self.linux_sandbox_pid_namespace == LinuxSandboxPidNamespace::Inherit {
args.insert(0, "--inherit-pid-namespace".to_owned());
}
exec.command = std::iter::once(program.clone()).chain(args).collect();
let arg0 = if exe.file_name() == Some(OsStr::new(CODEX_LINUX_SANDBOX_ARG0)) {
&program
exec.command = std::iter::once(program).chain(args).collect();
// Without bwrap --argv0, the Linux helper uses its own argv[0] as the inner executable.
let arg0 = if let Some(path) = exe.to_str()
&& exe.file_name() == Some(OsStr::new(CODEX_LINUX_SANDBOX_ARG0))
{
path
} else {
CODEX_LINUX_SANDBOX_ARG0
};
@@ -588,6 +631,7 @@ impl SandboxManager {
#[cfg(not(windows))]
{
self.transform(request.transform)
.map(WrappedSandboxCommand::into_command_request)
}
}
@@ -601,7 +645,7 @@ impl SandboxManager {
let fallback_roots = request.workspace_roots;
let proxy_settings_mode = request.windows_sandbox_proxy_settings_mode;
let sandbox_exe = request.transform.sandbox_exe;
let mut request = self.transform(request.transform)?;
let mut request = self.transform(request.transform)?.into_command_request();
if request.sandbox != SandboxType::WindowsRestrictedToken {
return Ok(request);
}
+58 -15
View File
@@ -125,7 +125,8 @@ fn unsandboxed_transform_preserves_foreign_cwd_and_unrestricted_file_system_poli
use_legacy_landlock: false,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
})
.expect("transform");
.expect("transform")
.into_command_request();
assert_eq!(exec_request.cwd, cwd_uri);
assert_eq!(exec_request.sandbox_policy_cwd, cwd_uri);
@@ -186,7 +187,8 @@ fn symlinked_workspace_reports_seatbelt_preparation_error() {
use_legacy_landlock: false,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
})
.expect_err("symlinked workspace should be rejected");
.err()
.expect("symlinked workspace should be rejected");
assert!(matches!(
&error,
@@ -241,7 +243,8 @@ fn transform_additional_permissions_enable_network_for_external_sandbox() {
use_legacy_landlock: false,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
})
.expect("transform");
.expect("transform")
.into_command_request();
assert_eq!(
exec_request.permission_profile,
@@ -329,7 +332,10 @@ fn transform_additional_permissions_preserves_denied_entries() {
use_legacy_landlock: false,
windows_sandbox_level: WindowsSandboxLevel::RestrictedToken,
};
let exec_request = manager.transform(request).expect("transform");
let exec_request = manager
.transform(request)
.expect("transform")
.into_command_request();
assert_eq!(
exec_request.permission_profile,
PermissionProfile::from_runtime_permissions(
@@ -383,12 +389,11 @@ fn managed_mitm_ca_bundle_becomes_readable_for_restricted_sandbox() {
);
}
#[cfg(target_os = "linux")]
#[cfg(unix)]
fn transform_linux_seccomp_request(
codex_linux_sandbox_exe: &std::path::Path,
mode: crate::LinuxSandboxPidNamespace,
) -> super::SandboxExecRequest {
let manager = SandboxManager::new().with_linux_sandbox_pid_namespace(mode);
manager: SandboxManager,
) -> super::WrappedSandboxCommand {
let cwd = AbsolutePathBuf::current_dir().expect("current dir");
let cwd_uri = PathUri::from_abs_path(&cwd);
let permissions = PermissionProfile::Disabled;
@@ -491,10 +496,8 @@ fn wsl1_allows_non_bubblewrap_linux_paths() {
#[test]
fn transform_linux_seccomp_preserves_helper_path_in_arg0_when_available() {
let codex_linux_sandbox_exe = std::path::PathBuf::from("/tmp/codex-linux-sandbox");
let exec_request = transform_linux_seccomp_request(
&codex_linux_sandbox_exe,
crate::LinuxSandboxPidNamespace::default(),
);
let exec_request =
transform_linux_seccomp_request(&codex_linux_sandbox_exe, SandboxManager::new()).request;
assert_eq!(
exec_request.arg0,
@@ -516,12 +519,51 @@ fn transform_linux_seccomp_uses_helper_alias_when_launcher_is_not_helper_path()
"--inherit-pid-namespace",
),
] {
let exec_request = transform_linux_seccomp_request(&codex_linux_sandbox_exe, mode);
let exec_request = transform_linux_seccomp_request(
&codex_linux_sandbox_exe,
SandboxManager::new().with_linux_sandbox_pid_namespace(mode),
)
.request;
assert_eq!(exec_request.arg0, Some("codex-linux-sandbox".to_string()));
assert_eq!(exec_request.command[1], first_helper_arg);
}
}
#[cfg(unix)]
#[test]
fn inherited_terminal_preserves_native_linux_helper_and_uses_its_dispatch_name()
-> anyhow::Result<()> {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir()?;
let alias = OsStr::new(crate::landlock::CODEX_LINUX_SANDBOX_ARG0);
let alias_path = dir.path().join(alias);
for (helper, expected_arg0) in [
(alias_path.clone(), alias_path.as_os_str()),
(dir.path().join("codex"), alias),
#[cfg(target_os = "linux")]
(
dir.path().join(OsStr::from_bytes(b"cli-\xff")).join(alias),
alias,
),
] {
std::fs::create_dir_all(helper.parent().expect("helper directory"))?;
symlink("/bin/sh", &helper)?;
let mut wrapped =
transform_linux_seccomp_request(&helper, SandboxManager::for_inherited_terminal(&[]));
// Run a shell probe with the executable and argv[0] produced by the Linux backend.
wrapped.request.command.truncate(1);
let mut child = wrapped.into_direct_command()?;
assert_eq!(child.get_program(), helper.as_os_str());
let output = child.args(["-c", r#"printf '%s' "$0""#]).output()?;
assert!(output.status.success(), "{output:?}");
assert_eq!(output.stdout, expected_arg0.as_bytes());
}
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn linux_unix_socket_grant_uses_effective_managed_policy() -> anyhow::Result<()> {
@@ -617,7 +659,7 @@ async fn linux_unix_socket_grant_uses_effective_managed_policy() -> anyhow::Resu
),
("no managed network", Some(allow_all), true, false, None),
] {
let request = manager.transform(SandboxTransformRequest {
let prepared = manager.transform(SandboxTransformRequest {
command: SandboxCommand {
program: "true".into(),
args: Vec::new(),
@@ -637,7 +679,8 @@ async fn linux_unix_socket_grant_uses_effective_managed_policy() -> anyhow::Resu
use_legacy_landlock: false,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
})?;
let transported_context = request
let transported_context = prepared
.into_command_request()
.command
.windows(2)
.find(|args| args[0] == "--managed-network")
+15 -3
View File
@@ -34,6 +34,7 @@ mod scratch;
pub(crate) enum MacosSeatbeltProfile {
#[default]
Process,
ProcessWithInheritedTerminal,
FileSystemHelper,
}
@@ -790,7 +791,7 @@ fn seatbelt_regex_for_glob(pattern: &str, glob_match: GlobMatch) -> Option<Strin
}
#[derive(Debug)]
pub struct CreateSeatbeltCommandArgsParams<'a> {
pub(crate) struct CreateSeatbeltCommandArgsParams<'a> {
pub command: Vec<String>,
pub file_system_sandbox_policy: &'a FileSystemSandboxPolicy,
pub network_sandbox_policy: NetworkSandboxPolicy,
@@ -802,7 +803,8 @@ pub struct CreateSeatbeltCommandArgsParams<'a> {
pub extra_allow_unix_sockets: &'a [AbsolutePathBuf],
}
pub fn create_seatbelt_command_args(
#[cfg(test)]
fn create_seatbelt_command_args(
args: CreateSeatbeltCommandArgsParams<'_>,
) -> Result<Vec<String>, String> {
create_seatbelt_command_args_with_profile(
@@ -837,7 +839,10 @@ pub(crate) fn create_seatbelt_command_args_with_profile(
)
.map_err(|err| SeatbeltPreparationError::FileSystem(err.to_string()))?;
let include_process_defaults = resolved_filesystem_permissions.include_platform_defaults
&& profile == MacosSeatbeltProfile::Process;
&& matches!(
profile,
MacosSeatbeltProfile::Process | MacosSeatbeltProfile::ProcessWithInheritedTerminal
);
let scratch_reads = if include_process_defaults {
let (reads, writes) = scratch::scratch_access_roots(
file_system_sandbox_policy,
@@ -1023,6 +1028,13 @@ pub(crate) fn create_seatbelt_command_args_with_profile(
policy_sections.push("(deny system-fcntl (fcntl-command 80 110))".to_string());
}
// Deny injecting input into the shell that resumes after this child. Keep the deny last.
if profile == MacosSeatbeltProfile::ProcessWithInheritedTerminal {
policy_sections.push(format!(
"(deny file-ioctl (ioctl-command {}))",
libc::TIOCSTI
));
}
let full_policy = policy_sections.join("\n");
let dir_params = [