mirror of
https://github.com/openai/codex.git
synced 2026-11-10 21:00:47 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user