Isolate app-server Unix sockets from filesystem-restricted commands (#45984)

## Why

Network access and Unix-socket allowlists must not let commands with filesystem restrictions reach the privileged app-server RPC transport.

## What changed

- Bind Unix control sockets in a fixed, user-owned directory with mode `0700`, independent of environment settings, and expose the advertised paths as symlinks. Preserve existing parent permissions, reject unsafe parents, and serialize socket setup and publication.
- Mask the socket directory in Linux bubblewrap sandboxes after each bind that exposes it. Reject host mount aliases and nested mounts that compromise isolation.
- Deny access to the directory and outbound connections to its sockets in macOS Seatbelt policies, including when network access or Unix-socket allowlists grant broader access.
- Require bubblewrap for filesystem-restricted Linux execution. Users with `features.use_legacy_landlock` enabled must disable it for these policies.

## Testing

Add regression coverage for direct and symlink socket access, hardlink attempts, Linux host-process links and bind-mount aliases, and continued use of unrelated and sandbox-local sockets. Add transport coverage for parent permissions, concurrent restart after a stale symlink, and cleanup that preserves a replacement at the advertised path.

GitOrigin-RevId: 53372c27eea278d964f2cf68aed24323ef3b7082
This commit is contained in:
Eric Traut
2026-09-16 16:56:41 +00:00
committed by copyberry
parent 0666c12e78
commit 49305d74b4
25 changed files with 828 additions and 191 deletions
+4
View File
@@ -2412,6 +2412,7 @@ dependencies = [
"hmac 0.12.1",
"httpdate",
"jsonwebtoken",
"libc",
"owo-colors",
"pretty_assertions",
"rand 0.9.3",
@@ -3786,6 +3787,7 @@ dependencies = [
"codex-process-hardening",
"codex-protocol",
"codex-sandboxing",
"codex-uds",
"codex-utils-absolute-path",
"globset",
"landlock",
@@ -4466,6 +4468,7 @@ dependencies = [
"codex-network-proxy",
"codex-otel",
"codex-protocol",
"codex-uds",
"codex-utils-absolute-path",
"codex-utils-home-dir",
"codex-utils-path-uri",
@@ -4866,6 +4869,7 @@ name = "codex-uds"
version = "0.0.0"
dependencies = [
"async-io",
"libc",
"pretty_assertions",
"tempfile",
"tokio",
+1
View File
@@ -57,6 +57,7 @@ url = { workspace = true }
uuid = { workspace = true, features = ["serde", "v7"] }
[target.'cfg(unix)'.dependencies]
libc = { workspace = true }
signal-hook = { workspace = true }
[dev-dependencies]
-1
View File
@@ -37,7 +37,6 @@ pub use transport::app_server_control_socket_path;
pub use transport::app_server_startup_lock_path;
pub use transport::auth;
pub use transport::daemon_recovery_file_path;
pub use transport::prepare_control_socket_path;
pub use transport::start_control_socket_acceptor;
pub use transport::start_remote_control;
pub use transport::start_stdio_connection;
@@ -46,7 +46,6 @@ pub use stdio::start_stdio_connection;
pub use unix_socket::AppServerStartupLock;
pub use unix_socket::DaemonShutdownAccess;
pub use unix_socket::acquire_app_server_startup_lock;
pub use unix_socket::prepare_control_socket_path;
pub use unix_socket::start_control_socket_acceptor;
pub use websocket::start_websocket_acceptor;
@@ -39,6 +39,45 @@ pub async fn start_control_socket_acceptor(
shutdown_token: CancellationToken,
daemon_shutdown_access: DaemonShutdownAccess,
) -> IoResult<JoinHandle<()>> {
#[cfg(unix)]
let (socket_path, rendezvous_path, _startup_lock) = {
use std::os::unix::fs::MetadataExt;
if let Some(parent) = socket_path.as_path().parent() {
// The advertised path is only an alias; preserve existing parents.
match tokio::fs::DirBuilder::new()
.mode(0o700)
.create(parent)
.await
{
Ok(()) => {}
Err(error) if error.kind() == ErrorKind::AlreadyExists => {}
Err(error) => return Err(error),
}
let metadata = std::fs::metadata(parent)?;
if !metadata.is_dir()
|| (metadata.uid() != 0 && metadata.uid() != unsafe { libc::geteuid() })
|| (metadata.mode() & 0o022 != 0 && metadata.mode() & 0o1000 == 0)
{
return Err(std::io::Error::new(
ErrorKind::PermissionDenied,
"socket parent must be owned by the user or root and prevent other users from replacing socket entries",
));
}
}
codex_uds::prepare_shared_daemon_socket_directory()?;
let physical_path = protected_socket_path(socket_path.as_path())?;
let lock = acquire_app_server_startup_lock(AbsolutePathBuf::from_absolute_path_checked(
physical_path.with_extension("lock"),
)?)
.await?;
prepare_control_socket_path(socket_path.as_path()).await?;
(
AbsolutePathBuf::from_absolute_path_checked(physical_path)?,
socket_path,
lock,
)
};
#[cfg(windows)]
let (socket_path, directory_guard) = {
if let Some(parent) = socket_path.as_path().parent() {
@@ -51,10 +90,17 @@ pub async fn start_control_socket_acceptor(
let listener = UnixListener::bind(socket_path.as_path()).await?;
let socket_guard = ControlSocketFileGuard {
socket_path,
#[cfg(unix)]
rendezvous_path,
#[cfg(windows)]
_directory_guard: directory_guard,
};
set_control_socket_permissions(socket_guard.socket_path.as_path()).await?;
#[cfg(unix)]
std::os::unix::fs::symlink(
socket_guard.socket_path.as_path(),
socket_guard.rendezvous_path.as_path(),
)?;
info!(
socket_path = %socket_guard.socket_path.display(),
"app-server control socket listening"
@@ -155,11 +201,8 @@ async fn run_daemon_shutdown(
.await;
}
pub async fn prepare_control_socket_path(socket_path: &Path) -> IoResult<()> {
if let Some(parent) = socket_path.parent() {
codex_uds::prepare_private_socket_directory(parent).await?;
}
// Unix callers hold the physical socket's startup lock through bind and publication.
async fn prepare_control_socket_path(socket_path: &Path) -> IoResult<()> {
#[cfg(windows)]
let (socket_path, _directory_guard) = codex_uds::validate_private_socket_path(socket_path)?;
#[cfg(windows)]
@@ -177,7 +220,7 @@ pub async fn prepare_control_socket_path(socket_path: &Path) -> IoResult<()> {
),
));
}
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(()),
Err(err) if err.kind() == ErrorKind::NotFound => {}
Err(err) if err.kind() == ErrorKind::ConnectionRefused => {}
Err(err) => {
if !socket_path.exists() {
@@ -187,6 +230,15 @@ pub async fn prepare_control_socket_path(socket_path: &Path) -> IoResult<()> {
}
}
// A crashed daemon can leave a dangling rendezvous symlink. Only recognize
// our own deterministic alias; never remove an arbitrary symlink target.
#[cfg(unix)]
if std::fs::read_link(socket_path).ok().as_deref()
== Some(protected_socket_path(socket_path)?.as_path())
{
return tokio::fs::remove_file(socket_path).await;
}
if !socket_path.try_exists()? {
return Ok(());
}
@@ -203,6 +255,23 @@ pub async fn prepare_control_socket_path(socket_path: &Path) -> IoResult<()> {
tokio::fs::remove_file(socket_path).await
}
#[cfg(unix)]
fn protected_socket_path(rendezvous_path: &Path) -> IoResult<std::path::PathBuf> {
use sha2::Digest;
use sha2::Sha256;
use std::os::unix::ffi::OsStrExt;
let parent = rendezvous_path.parent().ok_or_else(|| {
std::io::Error::new(ErrorKind::InvalidInput, "socket path must have a parent")
})?;
let name = rendezvous_path.file_name().ok_or_else(|| {
std::io::Error::new(ErrorKind::InvalidInput, "socket path must have a filename")
})?;
let path = std::fs::canonicalize(parent)?.join(name);
let hash = Sha256::digest(path.as_os_str().as_bytes());
Ok(codex_uds::shared_daemon_socket_directory()?.join(format!("{hash:x}")))
}
pub struct AppServerStartupLock {
_file: std::fs::File,
}
@@ -245,6 +314,8 @@ async fn set_control_socket_permissions(_socket_path: &Path) -> IoResult<()> {
struct ControlSocketFileGuard {
socket_path: AbsolutePathBuf,
#[cfg(unix)]
rendezvous_path: AbsolutePathBuf,
// Keep the directory pinned until after the socket file is removed in Drop.
#[cfg(windows)]
_directory_guard: std::os::windows::io::OwnedHandle,
@@ -252,6 +323,14 @@ struct ControlSocketFileGuard {
impl Drop for ControlSocketFileGuard {
fn drop(&mut self) {
#[cfg(unix)]
if std::fs::read_link(self.rendezvous_path.as_path())
.ok()
.as_deref()
== Some(self.socket_path.as_path())
{
let _ = std::fs::remove_file(self.rendezvous_path.as_path());
}
if let Err(err) = std::fs::remove_file(self.socket_path.as_path())
&& err.kind() != ErrorKind::NotFound
{
@@ -246,6 +246,35 @@ async fn app_server_startup_lock_serializes_waiters() {
.expect("second startup lock should succeed");
}
#[cfg(unix)]
#[tokio::test]
async fn control_socket_rejects_writable_parent_without_changing_permissions() {
use std::os::unix::fs::PermissionsExt;
let directory = tempfile::tempdir().unwrap();
std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o777)).unwrap();
let path = AbsolutePathBuf::from_absolute_path(directory.path().join("rpc.sock")).unwrap();
let (tx, _rx) = mpsc::channel(CHANNEL_CAPACITY);
let error = start_control_socket_acceptor(
path.clone(),
tx,
CancellationToken::new(),
DaemonShutdownAccess::Disabled,
)
.await
.unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
assert_eq!(
std::fs::metadata(directory.path())
.unwrap()
.permissions()
.mode()
& 0o777,
0o777
);
assert!(std::fs::symlink_metadata(path).is_err());
}
#[cfg(unix)]
#[tokio::test]
async fn control_socket_file_is_private_after_bind() {
@@ -253,6 +282,9 @@ async fn control_socket_file_is_private_after_bind() {
let temp_dir = tempfile::TempDir::new().expect("temp dir");
let socket_path = test_socket_path(temp_dir.path());
let parent = socket_path.as_path().parent().unwrap();
std::fs::create_dir_all(parent).unwrap();
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o755)).unwrap();
let (transport_event_tx, _transport_event_rx) =
mpsc::channel::<TransportEvent>(CHANNEL_CAPACITY);
let shutdown_token = CancellationToken::new();
@@ -269,9 +301,60 @@ async fn control_socket_file_is_private_after_bind() {
.await
.expect("socket metadata should exist");
assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
assert_eq!(
std::fs::metadata(parent).unwrap().permissions().mode() & 0o777,
0o755
);
let physical_path = std::fs::read_link(socket_path.as_path()).expect("rendezvous symlink");
assert_eq!(
physical_path.parent(),
Some(
codex_uds::shared_daemon_socket_directory()
.unwrap()
.as_path()
)
);
shutdown_token.cancel();
accept_handle.await.expect("acceptor should join");
assert!(!physical_path.exists());
assert!(std::fs::symlink_metadata(socket_path.as_path()).is_err());
// Simulate a dangling rendezvous left by an interrupted cleanup.
std::os::unix::fs::symlink(&physical_path, socket_path.as_path()).unwrap();
let (sender, _receiver) = mpsc::channel::<TransportEvent>(CHANNEL_CAPACITY);
let shutdown = CancellationToken::new();
let (first, second) = tokio::join!(
start_control_socket_acceptor(
socket_path.clone(),
sender.clone(),
shutdown.clone(),
DaemonShutdownAccess::Disabled,
),
start_control_socket_acceptor(
socket_path.clone(),
sender,
shutdown.clone(),
DaemonShutdownAccess::Disabled,
),
);
let (acceptor, error) = match (first, second) {
(Ok(acceptor), Err(error)) | (Err(error), Ok(acceptor)) => (acceptor, error),
_ => panic!("exactly one concurrent restart should replace the stale symlink"),
};
assert_eq!(error.kind(), std::io::ErrorKind::AddrInUse);
let _client = connect_to_socket(socket_path.as_path()).await.unwrap();
// Cleanup must not remove a replacement at the advertised path.
std::fs::remove_file(socket_path.as_path()).unwrap();
std::fs::write(socket_path.as_path(), b"replacement").unwrap();
shutdown.cancel();
acceptor.await.unwrap();
assert_eq!(
std::fs::read(socket_path.as_path()).unwrap(),
b"replacement"
);
assert!(!physical_path.exists());
}
#[cfg(windows)]
+1 -3
View File
@@ -43,7 +43,6 @@ use crate::transport::TransportEvent;
use crate::transport::acquire_app_server_startup_lock;
use crate::transport::app_server_startup_lock_path;
use crate::transport::auth::policy_from_settings;
use crate::transport::prepare_control_socket_path;
use crate::transport::route_outgoing_envelope;
use crate::transport::start_control_socket_acceptor;
use crate::transport::start_remote_control;
@@ -633,10 +632,9 @@ pub async fn run_main_with_transport_options(
codex_core::otel_init::record_process_start(otel.as_ref(), OTEL_SERVICE_NAME);
codex_core::otel_init::install_sqlite_telemetry(otel.as_ref(), OTEL_SERVICE_NAME);
let unix_socket_startup_lock = match &transport {
AppServerTransport::UnixSocket { socket_path } => {
AppServerTransport::UnixSocket { .. } => {
let startup_lock_path = app_server_startup_lock_path(&codex_home)?;
let startup_lock = acquire_app_server_startup_lock(startup_lock_path).await?;
prepare_control_socket_path(socket_path.as_path()).await?;
Some(startup_lock)
}
_ => None,
-1
View File
@@ -30,7 +30,6 @@ pub(crate) use codex_app_server_transport::acquire_app_server_startup_lock;
pub use codex_app_server_transport::app_server_control_socket_path;
pub(crate) use codex_app_server_transport::app_server_startup_lock_path;
pub use codex_app_server_transport::auth;
pub(crate) use codex_app_server_transport::prepare_control_socket_path;
pub(crate) use codex_app_server_transport::start_control_socket_acceptor;
pub(crate) use codex_app_server_transport::start_remote_control;
pub(crate) use codex_app_server_transport::start_stdio_connection;
+1
View File
@@ -23,6 +23,7 @@ codex-network-proxy = { workspace = true }
codex-process-hardening = { workspace = true }
codex-protocol = { workspace = true }
codex-sandboxing = { workspace = true }
codex-uds = { workspace = true }
codex-utils-absolute-path = { workspace = true }
globset = { workspace = true }
landlock = { workspace = true }
+3 -6
View File
@@ -37,12 +37,9 @@ commands that would enter the bubblewrap path.
- WSL2 uses the normal Linux bubblewrap path.
- WSL1 is not supported for bubblewrap sandboxing; Codex rejects sandboxed
shell commands that would require the bubblewrap path before invoking `bwrap`.
- Legacy Landlock + mount protections remain available as an explicit legacy
fallback path.
- Set `features.use_legacy_landlock = true` (or CLI `-c use_legacy_landlock=true`)
to force the legacy Landlock fallback.
- The legacy Landlock fallback is used only when the split filesystem policy is
sandbox-equivalent to the legacy model after `cwd` resolution.
- Filesystem-restricted execution requires bubblewrap. The legacy Landlock
option is rejected for these policies because it cannot isolate app-server
Unix sockets. Disable `features.use_legacy_landlock` when upgrading.
- Split-only filesystem policies that do not round-trip through the legacy
`SandboxPolicy` model stay on bubblewrap so nested read-only or denied
carveouts are preserved.
+99 -1
View File
@@ -368,6 +368,8 @@ fn create_bwrap_flags(
}
// Request a user namespace explicitly rather than relying on bubblewrap's
// auto-enable behavior, which is skipped when the caller runs as uid 0.
// This also blocks host procfs root/cwd/fd links through ptrace permission
// checks, including when a container requires retaining the host procfs.
args.push("--unshare-user".to_string());
args.push("--unshare-pid".to_string());
args.push("--unshare-ipc".to_string());
@@ -420,6 +422,13 @@ fn create_filesystem_args(
cwd: &Path,
options: BwrapOptions,
) -> Result<BwrapArgs> {
let daemon_directory = codex_uds::prepare_shared_daemon_socket_directory()?;
crate::daemon_mounts::reject_daemon_mount_aliases(
&daemon_directory,
options
.mask_wslg_distro
.then_some(Path::new(WSLG_DISTRO_ROOT)),
)?;
let unreadable_globs = file_system_sandbox_policy.get_unreadable_globs_with_cwd(cwd);
// Bubblewrap requires bind mount targets to exist. Skip missing writable
// roots so mixed-platform configs can keep harmless paths for other
@@ -494,7 +503,7 @@ fn create_filesystem_args(
unreadable_roots.sort();
unreadable_roots.dedup();
let args = if file_system_sandbox_policy.has_full_disk_read_access() {
let mut args = if file_system_sandbox_policy.has_full_disk_read_access() {
// Read-only root, then mount a minimal device tree.
// In bubblewrap (`bubblewrap.c`, `SETUP_MOUNT_DEV`), `--dev /dev`
// creates the standard minimal nodes: null, zero, full, random,
@@ -562,11 +571,15 @@ fn create_filesystem_args(
args.push("--ro-bind".to_string());
args.push(path_to_string(&mount_root));
args.push(path_to_string(&mount_root));
append_daemon_socket_mask(&mut args, &mount_root, &daemon_directory)?;
}
}
args
};
if args[0] == "--ro-bind" {
append_daemon_socket_mask(&mut args, Path::new("/"), &daemon_directory)?;
}
let mut bwrap_args = BwrapArgs {
args,
preserved_files: Vec::new(),
@@ -624,6 +637,7 @@ fn create_filesystem_args(
bwrap_args.args.push("--bind".to_string());
bwrap_args.args.push(path_to_string(mount_root));
bwrap_args.args.push(path_to_string(mount_root));
append_daemon_socket_mask(&mut bwrap_args.args, mount_root, &daemon_directory)?;
let mut read_only_subpaths: Vec<PathBuf> = writable_root
.read_only_subpaths
@@ -761,6 +775,33 @@ fn append_metadata_path_masks_for_writable_root(
}
}
// Mask immediately after every bind that exposes the directory, before policy
// deny masks can hide its parent. Use the mount destination for read-root aliases.
fn append_daemon_socket_mask(
args: &mut Vec<String>,
mount_root: &Path,
daemon_directory: &Path,
) -> Result<()> {
let source = fs::canonicalize(mount_root)?;
if source.starts_with(daemon_directory) {
return Err(CodexErr::Fatal(
"app-server socket directory cannot be a sandbox mount root".to_string(),
));
}
if let Ok(relative) = daemon_directory.strip_prefix(source) {
let destination = path_to_string(&mount_root.join(relative));
args.extend([
"--perms".to_string(),
"000".to_string(),
"--tmpfs".to_string(),
destination.clone(),
"--remount-ro".to_string(),
destination,
]);
}
Ok(())
}
fn expand_unreadable_globs_with_ripgrep(
patterns: &[String],
cwd: &Path,
@@ -1134,6 +1175,11 @@ fn append_read_only_subpath_args(
bwrap_args.args.push("--ro-bind".to_string());
bwrap_args.args.push(path_to_string(subpath));
bwrap_args.args.push(path_to_string(subpath));
append_daemon_socket_mask(
&mut bwrap_args.args,
subpath,
&codex_uds::shared_daemon_socket_directory()?,
)?;
}
Ok(())
}
@@ -2129,6 +2175,8 @@ mod tests {
PathBuf::from("/dev/.codex"),
]
);
let daemon_directory =
path_to_string(&codex_uds::shared_daemon_socket_directory().unwrap());
assert_eq!(
args.args,
vec![
@@ -2139,10 +2187,22 @@ mod tests {
// Recreate a writable /dev inside the sandbox.
"--dev".to_string(),
"/dev".to_string(),
"--perms".to_string(),
"000".to_string(),
"--tmpfs".to_string(),
daemon_directory.clone(),
"--remount-ro".to_string(),
daemon_directory.clone(),
// Make the writable root itself writable again.
"--bind".to_string(),
"/".to_string(),
"/".to_string(),
"--perms".to_string(),
"000".to_string(),
"--tmpfs".to_string(),
daemon_directory.clone(),
"--remount-ro".to_string(),
daemon_directory,
// Mask the default metadata path names under the writable root.
// Because the root is `/` in this test, these carveout paths
// appear directly below `/`.
@@ -2196,6 +2256,44 @@ mod tests {
);
}
#[test]
fn read_only_tmp_carveout_keeps_daemon_sockets_masked() {
let policy = FileSystemSandboxPolicy::restricted(
[
("/", FileSystemAccessMode::Write),
("/tmp", FileSystemAccessMode::Read),
]
.into_iter()
.map(|(path, access)| {
FileSystemSandboxEntry::new(
AbsolutePathBuf::from_absolute_path(path).unwrap().into(),
access,
)
})
.collect(),
);
let args = create_filesystem_args(&policy, Path::new("/"), BwrapOptions::default())
.expect("filesystem args");
let tmp_bind = args
.args
.windows(3)
.position(|window| window == ["--ro-bind", "/tmp", "/tmp"])
.expect("read-only tmp bind");
let daemon_directory =
path_to_string(&codex_uds::shared_daemon_socket_directory().unwrap());
assert_eq!(
&args.args[tmp_bind + 3..tmp_bind + 9],
[
"--perms",
"000",
"--tmpfs",
&daemon_directory,
"--remount-ro",
&daemon_directory
],
);
}
#[test]
fn restricted_read_only_uses_scoped_read_roots_instead_of_erroring() {
let temp_dir = TempDir::new().expect("temp dir");
+115
View File
@@ -0,0 +1,115 @@
//! Reject host mount aliases that would bypass the privileged socket directory mask.
//! Mount roots describe filesystem identity; canonical paths alone miss bind mounts.
use std::collections::BTreeSet;
use std::fs;
use std::io;
use std::os::unix::ffi::OsStringExt;
use std::os::unix::fs::MetadataExt;
use std::path::Path;
use std::path::PathBuf;
pub(crate) fn reject_daemon_mount_aliases(
directory: &Path,
masked_root: Option<&Path>,
) -> io::Result<()> {
let device = fs::metadata(directory)?.dev();
check_mounts(
directory,
&format!("{}:{}", libc::major(device), libc::minor(device)),
&fs::read("/proc/self/mountinfo")?,
masked_root,
)
}
fn check_mounts(
directory: &Path,
device: &str,
mountinfo: &[u8],
masked_root: Option<&Path>,
) -> io::Result<()> {
let invalid = || io::Error::other("cannot establish app-server socket mount isolation");
let mut mounts = Vec::new();
let mut locations = BTreeSet::new();
for line in mountinfo
.split(|byte| *byte == b'\n')
.filter(|line| !line.is_empty())
{
let fields: Vec<_> = line.split(|byte| *byte == b' ').take(5).collect();
let [_, _, mount_device, root, destination] = fields.as_slice() else {
return Err(invalid());
};
let root = mount_path(root)?;
let destination = mount_path(destination)?;
if *mount_device == device.as_bytes()
&& let Ok(relative) = directory.strip_prefix(&destination)
{
locations.insert(root.join(relative));
}
mounts.push((*mount_device, root, destination));
}
// Overmounts can leave hidden entries in mountinfo. Require every possible
// containing mount to agree instead of guessing which root is visible.
if locations.len() != 1 {
return Err(invalid());
}
let location = locations.into_iter().next().ok_or_else(invalid)?;
for (mount_device, root, destination) in &mounts {
// Nested mounts can introduce another filesystem (or an individual socket) under the mask.
let nested = destination != directory && destination.starts_with(directory);
let alias = if *mount_device == device.as_bytes() {
if let Ok(relative) = location.strip_prefix(root) {
Some(destination.join(relative))
} else if root.starts_with(&location) {
Some(destination.clone())
} else {
None
}
} else {
None
};
if nested
|| alias.is_some_and(|path| {
!path.starts_with(directory)
&& !masked_root.is_some_and(|root| path.starts_with(root))
})
{
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!(
"app-server socket directory has an unsupported host mount at {}; remove the bind-mount alias or nested mount before starting the sandbox",
destination.display()
),
));
}
}
Ok(())
}
fn mount_path(encoded: &[u8]) -> io::Result<PathBuf> {
let mut decoded = Vec::new();
let mut bytes = encoded.iter().copied();
while let Some(byte) = bytes.next() {
decoded.push(if byte == b'\\' {
let digits: Vec<_> = bytes.by_ref().take(3).collect();
match digits.as_slice() {
b"040" => b' ',
b"011" => b'\t',
b"012" => b'\n',
b"134" => b'\\',
_ => return Err(io::Error::other("invalid mountinfo path escape")),
}
} else {
byte
});
}
let path = PathBuf::from(std::ffi::OsString::from_vec(decoded));
if !path.is_absolute() {
return Err(io::Error::other("mountinfo path is not absolute"));
}
Ok(path)
}
#[cfg(test)]
#[path = "daemon_mounts_tests.rs"]
mod tests;
@@ -0,0 +1,63 @@
use super::*;
use pretty_assertions::assert_eq;
use test_case::test_case;
// Most cases have no independently masked subtree.
fn check_mounts(directory: &Path, device: &str, mountinfo: &[u8]) -> io::Result<()> {
super::check_mounts(directory, device, mountinfo, /*masked_root*/ None)
}
#[test_case("/tmp", "/host-tmp", false; "ancestor alias")]
#[test_case("/tmp/codex-daemon-1000", "/alias", false; "directory alias")]
#[test_case("/tmp/codex-daemon-1000/rpc.sock", "/alias.sock", false; "socket alias")]
#[test_case("/", "/host", false; "root alias")]
#[test_case("/workspace", "/project", true; "unrelated project bind")]
#[test_case("/tmp", "/tmp", true; "same location")]
#[test_case("/tmp", "/host\\040tmp", false; "escaped alias")]
#[test_case("/other", "/tmp/codex-daemon-1000/nested", false; "nested mount")]
fn rejects_only_mounts_that_compromise_the_directory(root: &str, destination: &str, allowed: bool) {
let mounts =
format!("1 0 0:1 / / rw - ext4 disk rw\n2 1 0:1 {root} {destination} rw - ext4 disk rw\n");
assert_eq!(
check_mounts(
Path::new("/tmp/codex-daemon-1000"),
"0:1",
mounts.as_bytes()
)
.is_ok(),
allowed
);
}
#[test]
fn rejects_alias_when_tmp_is_itself_a_bind_mount() {
let mounts = b"1 0 0:1 / / rw - ext4 disk rw\n2 1 0:1 /backing/tmp /tmp rw - ext4 disk rw\n";
assert!(check_mounts(Path::new("/tmp/codex-daemon-1000"), "0:1", mounts).is_err());
// A hidden deeper mount must not override the actual /tmp backing location.
let hidden = [
mounts.as_slice(),
b"3 1 0:1 /tmp/codex-daemon-1000 /tmp/codex-daemon-1000 rw - ext4 disk rw\n",
]
.concat();
assert!(check_mounts(Path::new("/tmp/codex-daemon-1000"), "0:1", &hidden).is_err());
}
#[test]
fn accepts_private_tmp_filesystem_but_rejects_ambiguous_stacked_mounts() {
let mounts = "1 0 0:1 / / rw - ext4 disk rw\n2 1 0:2 / /tmp rw - tmpfs tmpfs rw\n";
let directory = Path::new("/tmp/codex-daemon-1000");
assert!(check_mounts(directory, "0:2", mounts.as_bytes()).is_ok());
let stacked = format!("{mounts}3 2 0:2 /other /tmp rw - tmpfs tmpfs rw\n");
assert!(check_mounts(directory, "0:2", stacked.as_bytes()).is_err());
}
#[test]
fn masked_wslg_alias_does_not_allow_other_exposed_aliases() {
let mounts = "1 0 0:1 / / rw - ext4 disk rw\n2 1 0:1 / /mnt/wslg/distro rw - ext4 disk rw\n";
let directory = Path::new("/tmp/codex-daemon-1000");
let mask = Some(Path::new(crate::bwrap::WSLG_DISTRO_ROOT));
assert!(check_mounts(directory, "0:1", mounts.as_bytes()).is_err());
assert!(super::check_mounts(directory, "0:1", mounts.as_bytes(), mask).is_ok());
let exposed = format!("{mounts}3 1 0:1 /tmp /host-tmp rw - ext4 disk rw\n");
assert!(super::check_mounts(directory, "0:1", exposed.as_bytes(), mask).is_err());
}
+2
View File
@@ -10,6 +10,8 @@ mod bundled_bwrap;
#[cfg(target_os = "linux")]
mod bwrap;
#[cfg(target_os = "linux")]
mod daemon_mounts;
#[cfg(target_os = "linux")]
mod exec_util;
#[cfg(target_os = "linux")]
mod fd_mount;
+3 -31
View File
@@ -186,14 +186,7 @@ pub fn run_main() -> ! {
file_system_sandbox_policy,
network_sandbox_policy,
} = resolve_permission_profile(permission_profile).unwrap_or_else(|err| panic!("{err}"));
ensure_legacy_landlock_mode_supports_policy(
use_legacy_landlock,
&file_system_sandbox_policy,
network_sandbox_policy,
allow_network_for_proxy,
&sandbox_policy_cwd,
Path::new(WSL_INTEROP_DIR),
);
ensure_legacy_landlock_mode_supports_policy(use_legacy_landlock, &file_system_sandbox_policy);
// Inner stage: apply seccomp/no_new_privs after bubblewrap has already
// established the filesystem view.
@@ -412,30 +405,9 @@ fn ensure_inner_stage_mode_is_valid(apply_seccomp_then_exec: bool, use_legacy_la
fn ensure_legacy_landlock_mode_supports_policy(
use_legacy_landlock: bool,
file_system_sandbox_policy: &FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
allow_network_for_proxy: bool,
sandbox_policy_cwd: &Path,
wsl_interop_dir: &Path,
) {
if use_legacy_landlock
&& file_system_sandbox_policy
.needs_direct_runtime_enforcement(network_sandbox_policy, sandbox_policy_cwd)
{
panic!(
"permission profiles requiring direct runtime enforcement are incompatible with --use-legacy-landlock"
);
}
if use_legacy_landlock
&& network_sandbox_policy.is_enabled()
&& !allow_network_for_proxy
&& !file_system_sandbox_policy.has_full_disk_write_access()
// Interop can use another binfmt handler or a newly created socket.
// An active endpoint probe would race with sandboxed command startup.
&& wsl_interop_dir.is_dir()
{
panic!(
"legacy Landlock cannot isolate WSL Windows interop with network access enabled; use bubblewrap or restrict network access"
);
if use_legacy_landlock && !file_system_sandbox_policy.has_full_disk_write_access() {
panic!("filesystem-restricted execution requires bubblewrap to isolate app-server sockets");
}
}
@@ -72,6 +72,10 @@ fn inserts_bwrap_argv0_before_command_separator() {
/*supports_argv0*/ true,
"/tmp/codex-arg0-session/codex-linux-sandbox".to_string(),
);
let daemon_directory = codex_uds::shared_daemon_socket_directory()
.unwrap()
.display()
.to_string();
assert_eq!(
argv,
vec![
@@ -83,6 +87,12 @@ fn inserts_bwrap_argv0_before_command_separator() {
"/".to_string(),
"--dev".to_string(),
"/dev".to_string(),
"--perms".to_string(),
"000".to_string(),
"--tmpfs".to_string(),
daemon_directory.clone(),
"--remount-ro".to_string(),
daemon_directory,
"--unshare-user".to_string(),
"--unshare-pid".to_string(),
"--unshare-ipc".to_string(),
@@ -741,66 +751,11 @@ fn apply_seccomp_then_exec_with_legacy_landlock_panics() {
}
#[test]
fn legacy_landlock_rejects_split_only_filesystem_policies() {
let temp_dir = tempfile::TempDir::new().expect("tempdir");
let docs = temp_dir.path().join("docs");
std::fs::create_dir_all(&docs).expect("create docs");
let docs = AbsolutePathBuf::from_absolute_path(&docs).expect("absolute docs");
let policy = FileSystemSandboxPolicy::restricted(vec![
codex_protocol::permissions::FileSystemSandboxEntry {
path: codex_protocol::permissions::FileSystemPath::Special {
value: codex_protocol::permissions::FileSystemSpecialPath::Root,
},
access: codex_protocol::permissions::FileSystemAccessMode::Read,
missing_path_behavior: None,
},
codex_protocol::permissions::FileSystemSandboxEntry {
path: docs.into(),
access: codex_protocol::permissions::FileSystemAccessMode::Write,
missing_path_behavior: None,
},
]);
let result = std::panic::catch_unwind(|| {
ensure_legacy_landlock_mode_supports_policy(
/*use_legacy_landlock*/ true,
&policy,
NetworkSandboxPolicy::Restricted,
/*allow_network_for_proxy*/ false,
temp_dir.path(),
&temp_dir.path().join("WSL"),
);
});
assert!(result.is_err());
}
#[test]
fn legacy_landlock_rejects_full_network_when_wsl_interop_is_available() {
let temp_dir = tempfile::TempDir::new().expect("tempdir");
let wsl_interop_dir = temp_dir.path().join("WSL");
std::fs::create_dir(&wsl_interop_dir).expect("create interop directory");
let policy = read_only_file_system_policy();
let result = std::panic::catch_unwind(|| {
ensure_legacy_landlock_mode_supports_policy(
/*use_legacy_landlock*/ true,
&policy,
NetworkSandboxPolicy::Enabled,
/*allow_network_for_proxy*/ false,
temp_dir.path(),
&wsl_interop_dir,
);
});
assert!(result.is_err());
#[should_panic(expected = "filesystem-restricted execution requires bubblewrap")]
fn legacy_landlock_cannot_bypass_daemon_socket_isolation() {
ensure_legacy_landlock_mode_supports_policy(
/*use_legacy_landlock*/ true,
&policy,
NetworkSandboxPolicy::Enabled,
/*allow_network_for_proxy*/ true,
temp_dir.path(),
&wsl_interop_dir,
&read_only_file_system_policy(),
);
}
@@ -0,0 +1,167 @@
use super::*;
use pretty_assertions::assert_eq;
use std::os::fd::AsRawFd;
use std::os::unix::net::UnixListener;
use std::os::unix::net::UnixStream;
#[tokio::test]
async fn daemon_socket_bind_mount_alias_rejects_sandbox_startup() {
if should_skip_bwrap_tests().await {
return;
}
let root = codex_uds::prepare_shared_daemon_socket_directory().unwrap();
let private = tempfile::tempdir_in(&root).unwrap();
let alias = tempfile::tempdir().unwrap();
let _listener = UnixListener::bind(private.path().join("rpc.sock")).unwrap();
let profile = PermissionProfile::from_runtime_permissions(
&FileSystemSandboxPolicy::read_only(),
NetworkSandboxPolicy::Enabled,
);
let script = r#"
import pathlib, socket, subprocess, sys
root, alias, endpoint, helper, profile = sys.argv[1:]
mount = subprocess.run(['mount', '--bind', root, alias], capture_output=True)
if mount.returncode:
sys.exit(77)
try:
socket.socket(socket.AF_UNIX).connect(str(pathlib.Path(alias) / endpoint))
result = subprocess.run([helper, '--sandbox-policy-cwd', '/', '--permission-profile', profile,
'--', 'sh', '-c', 'echo command-started'], capture_output=True)
assert result.returncode != 0, result
assert b'unsupported host mount' in result.stderr, result
assert b'command-started' not in result.stdout, result
finally:
subprocess.run(['umount', alias], check=True)
"#;
let output = tokio::process::Command::new("unshare")
.args([
"--user",
"--map-current-user",
"--keep-caps",
"--mount",
"--propagation",
"private",
"--",
])
.args(["python3", "-c", script])
.arg(&root)
.arg(alias.path())
.arg(private.path().strip_prefix(&root).unwrap().join("rpc.sock"))
.arg(codex_linux_sandbox_exe())
.arg(serde_json::to_string(&profile).unwrap())
.kill_on_drop(true)
.output()
.await;
let Ok(output) = output else {
eprintln!("skipping bind-mount test: unshare is unavailable");
return;
};
if output.status.code() == Some(77)
|| String::from_utf8_lossy(&output.stderr).starts_with("unshare:")
{
eprintln!("skipping bind-mount test: user/mount namespaces are unavailable");
return;
}
assert_eq!(output.status.code(), Some(0), "{output:?}");
}
#[tokio::test]
async fn daemon_sockets_are_hidden_with_network_and_tmp_write_grants() {
if should_skip_bwrap_tests().await {
return;
}
let root = codex_uds::prepare_shared_daemon_socket_directory().unwrap();
let private = tempfile::tempdir_in(&root).unwrap();
let workspace = tempfile::tempdir().unwrap();
let socket = private.path().join("rpc.sock");
let alias = workspace.path().join("alias.sock");
let unrelated = workspace.path().join("other.sock");
let directory_fd = std::fs::File::open(private.path()).unwrap();
let _daemon = UnixListener::bind(&socket).unwrap();
let _other = UnixListener::bind(&unrelated).unwrap();
std::os::unix::fs::symlink(&socket, &alias).unwrap();
UnixStream::connect(&alias).expect("host can reach daemon");
let script = r#"
import os, socket, sys
for path in [sys.argv[1], sys.argv[2], '/proc/' + sys.argv[5] + '/root' + sys.argv[1]]:
try:
socket.socket(socket.AF_UNIX).connect(path)
except OSError:
pass
else:
raise AssertionError('daemon reachable: ' + path)
try:
os.link(sys.argv[1], sys.argv[4] + '/hardlink')
except OSError:
pass
else:
raise AssertionError('daemon hardlink created')
socket.socket(socket.AF_UNIX).connect(sys.argv[3])
s = socket.socket(socket.AF_UNIX)
s.bind(sys.argv[4] + '/local.sock')
s.listen(1)
c = socket.socket(socket.AF_UNIX)
c.connect(sys.argv[4] + '/local.sock')
p, _ = s.accept()
c.sendall(b'ok')
assert p.recv(2) == b'ok'
os.unlink(sys.argv[4] + '/local.sock')
# Main already hides procfs when isolating WSL interop.
if sys.argv[6] == 'host-proc' and not os.path.isdir('/run/WSL'):
assert os.path.isfile('/proc/self/status')
assert os.path.isdir('/proc/' + sys.argv[5])
for name in ['root', 'cwd', 'fd/' + sys.argv[7]]:
try:
os.readlink('/proc/' + sys.argv[5] + '/' + name)
except PermissionError:
pass
else:
raise AssertionError('host process link accessible: ' + name)
"#;
let profile = PermissionProfile::workspace_write_with(
&[AbsolutePathBuf::from_absolute_path("/tmp").unwrap()],
NetworkSandboxPolicy::Enabled,
/*exclude_tmpdir_env_var*/ true,
/*exclude_slash_tmp*/ false,
);
let mut env = create_env_from_core_vars();
env.insert(
"CODEX_HOME".to_string(),
workspace.path().display().to_string(),
);
env.insert("TMPDIR".to_string(), workspace.path().display().to_string());
// Inherited procfs remains useful in containers that prohibit a fresh mount.
// Its host-process magic links must still respect the user namespace boundary.
for proc_mode in ["fresh-proc", "host-proc"] {
let mut command = tokio::process::Command::new(codex_linux_sandbox_exe());
command
.arg("--sandbox-policy-cwd")
.arg(workspace.path())
.arg("--permission-profile")
.arg(serde_json::to_string(&profile).unwrap())
.envs(&env)
.current_dir(workspace.path());
if proc_mode == "host-proc" {
command.arg("--no-proc");
}
let output = command
.args([
"--",
"python3",
"-c",
script,
socket.to_str().unwrap(),
alias.to_str().unwrap(),
unrelated.to_str().unwrap(),
workspace.path().to_str().unwrap(),
&std::process::id().to_string(),
proc_mode,
&directory_fd.as_raw_fd().to_string(),
])
.kill_on_drop(true)
.output()
.await
.unwrap();
assert_eq!(output.status.code(), Some(0), "{output:?}");
}
}
+15 -69
View File
@@ -561,59 +561,6 @@ async fn wsl_interop_cannot_reenter_distro_as_root_with_network_access() {
assert_ne!(output.stdout.text.trim(), "0");
}
#[tokio::test]
async fn legacy_landlock_rejects_wsl_interop_with_network_access() {
let Some(powershell) = wsl_windows_executable(r"WindowsPowerShell\v1.0\powershell.exe").await
else {
return;
};
if wsl_baseline_output(
&powershell,
&["-NoProfile", "-NonInteractive", "-Command", "exit 0"],
)
.await
.is_none()
{
eprintln!("skipping legacy WSL interop test: Windows interop is unavailable on the host");
return;
}
let safe_profile = PermissionProfile::from_runtime_permissions(
&FileSystemSandboxPolicy::read_only(),
NetworkSandboxPolicy::Restricted,
);
let linux_output = run_cmd_result_with_permission_profile(
&["/bin/true"],
safe_profile,
NETWORK_TIMEOUT_MS,
/*use_legacy_landlock*/ true,
)
.await
.expect("legacy Landlock should run a Linux command");
assert_eq!(linux_output.exit_code, 0);
let unsafe_profile = PermissionProfile::from_runtime_permissions(
&FileSystemSandboxPolicy::read_only(),
NetworkSandboxPolicy::Enabled,
);
let output = expect_denied(
run_cmd_result_with_permission_profile(
&["/bin/true"],
unsafe_profile,
NETWORK_TIMEOUT_MS,
/*use_legacy_landlock*/ true,
)
.await,
"legacy Landlock must reject restricted filesystem access with full network access on WSL",
);
assert!(
output
.stderr
.text
.contains("legacy Landlock cannot isolate WSL Windows interop")
);
}
#[tokio::test]
async fn test_root_read() {
run_cmd(&["ls", "-l", "/bin"], &[], SHORT_TIMEOUT_MS).await;
@@ -767,20 +714,19 @@ async fn sandbox_ignores_missing_writable_roots_under_bwrap() {
#[tokio::test]
async fn test_no_new_privs_is_enabled() {
let output = run_cmd_output(
&["bash", "-lc", "grep '^NoNewPrivs:' /proc/self/status"],
&[
"python3",
"-c",
"import ctypes; print(ctypes.CDLL(None).prctl(39, 0, 0, 0, 0))",
],
&[],
// We have seen timeouts when running this test in CI on GitHub,
// so we are using a generous timeout until we can diagnose further.
LONG_TIMEOUT_MS,
)
.await;
let line = output
.stdout
.text
.lines()
.find(|line| line.starts_with("NoNewPrivs:"))
.unwrap_or("");
assert_eq!(line.trim(), "NoNewPrivs:\t1");
assert_eq!(output.exit_code, 0);
assert_eq!(output.stdout.text, "1\n");
}
#[tokio::test]
@@ -792,19 +738,16 @@ async fn sandboxed_command_has_no_effective_or_permitted_capabilities() {
let output = run_cmd_output(
&[
"bash",
"-lc",
"awk '$1 == \"CapPrm:\" || $1 == \"CapEff:\" { print $1, $2 }' /proc/self/status",
"python3",
"-c",
"import ctypes; h=(ctypes.c_uint*2)(0x20080522,0); d=(ctypes.c_uint*6)(); assert ctypes.CDLL(None).capget(h,d)==0; print(d[0],d[1],d[3],d[4])",
],
&[],
LONG_TIMEOUT_MS,
)
.await;
assert_eq!(
output.stdout.text,
"CapPrm: 0000000000000000\nCapEff: 0000000000000000\n"
);
assert_eq!(output.exit_code, 0);
assert_eq!(output.stdout.text, "0 0 0 0\n");
}
#[tokio::test]
@@ -1622,3 +1565,6 @@ async fn sandbox_blocks_dev_tcp_redirection() {
// all images ship bash, so we guard against 127 as well.
assert_network_blocked(&["bash", "-c", "echo hi > /dev/tcp/127.0.0.1/80"]).await;
}
#[path = "daemon_sockets_tests.rs"]
mod daemon_sockets_tests;
@@ -246,7 +246,7 @@ async fn assert_seccomp_filtered_namespace_reaper(
&[
"bash",
"-c",
"if [ \"$(readlink /proc/1/ns/pid 2>/dev/null)\" != \"$(readlink /proc/self/ns/pid 2>/dev/null)\" ]; then printf 'namespace proc unavailable\\n'; exit 0; fi; printf '%s\\n' \"$PPID\"; awk '$1 == \"Seccomp:\" && FNR == NR { print $2 } $1 == \"Seccomp_filters:\" { print $2 }' /proc/1/status /proc/self/status",
"if [ ! -r /proc/self/status ] || [ ! -r /proc/1/status ] || [ \"$(readlink /proc/1/ns/pid 2>/dev/null)\" != \"$(readlink /proc/self/ns/pid 2>/dev/null)\" ]; then printf 'namespace proc unavailable\\n'; exit 0; fi; printf '%s\\n' \"$PPID\"; awk '$1 == \"Seccomp:\" && FNR == NR { print $2 } $1 == \"Seccomp_filters:\" { print $2 }' /proc/1/status /proc/self/status",
],
permission_profile,
allow_network_for_proxy,
@@ -286,7 +286,7 @@ async fn namespace_reaper_collects_orphaned_descendants() {
&[
"bash",
"-c",
"if [ \"$(readlink /proc/1/ns/pid 2>/dev/null)\" != \"$(readlink /proc/self/ns/pid 2>/dev/null)\" ]; then printf 'namespace proc unavailable\\n'; exit 0; fi; orphan=$(bash -c 'sleep 0.05 </dev/null >/dev/null 2>&1 & printf \"%s\\n\" \"$!\"'); for _ in $(seq 1 100); do if [ ! -e \"/proc/$orphan\" ]; then printf 'orphan reaped\\n'; exit 0; fi; sleep 0.01; done; exit 1",
"if [ ! -r /proc/self/status ] || [ ! -r /proc/1/status ] || [ \"$(readlink /proc/1/ns/pid 2>/dev/null)\" != \"$(readlink /proc/self/ns/pid 2>/dev/null)\" ]; then printf 'namespace proc unavailable\\n'; exit 0; fi; orphan=$(bash -c 'sleep 0.05 </dev/null >/dev/null 2>&1 & printf \"%s\\n\" \"$!\"'); for _ in $(seq 1 100); do if [ ! -e \"/proc/$orphan\" ]; then printf 'orphan reaped\\n'; exit 0; fi; sleep 0.01; done; exit 1",
],
&PermissionProfile::read_only(),
/*allow_network_for_proxy*/ false,
@@ -717,18 +717,25 @@ fn handoff_client() {
return;
};
assert!(std::env::var_os(PROXY_ATTRIBUTION_TOKEN_ENV_KEY).is_none());
let inherited_sockets = std::fs::read_dir("/proc/self/fd")
.expect("enumerate inherited descriptors")
.filter_map(Result::ok)
.filter_map(|entry| {
let target = std::fs::read_link(entry.path()).ok()?;
target
.to_string_lossy()
.starts_with("socket:")
.then(|| (entry.file_name(), target))
})
.collect::<Vec<_>>();
assert_eq!(inherited_sockets, Vec::new(), "privileged socket leaked");
match std::fs::read_dir("/proc/self/fd") {
Ok(entries) => {
let inherited_sockets = entries
.filter_map(Result::ok)
.filter_map(|entry| {
let target = std::fs::read_link(entry.path()).ok()?;
target
.to_string_lossy()
.starts_with("socket:")
.then(|| (entry.file_name(), target))
})
.collect::<Vec<_>>();
assert_eq!(inherited_sockets, Vec::new(), "privileged socket leaked");
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
eprintln!("skipping descriptor enumeration: sandbox proc is unavailable");
}
Err(error) => panic!("enumerate inherited descriptors: {error}"),
}
let shared = std::env::var("CODEX_TEST_HANDOFF_SHARED").expect("shared directory");
let shared = Path::new(&shared);
+1
View File
@@ -17,6 +17,7 @@ anyhow = { workspace = true }
codex-mxc-sandbox = { workspace = true }
codex-network-proxy = { workspace = true }
codex-protocol = { workspace = true }
codex-uds = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-path-uri = { workspace = true }
codex-utils-pty = { workspace = true }
+16
View File
@@ -1040,6 +1040,18 @@ pub(crate) fn create_seatbelt_command_args_with_profile(
file_write_policy,
network_policy,
];
// Network grants and Unix-socket allowlists must never reopen the
// privileged app-server RPC transport to filesystem-restricted commands.
if !file_system_sandbox_policy.has_full_disk_write_access() {
let directory = codex_uds::shared_daemon_socket_directory()
.map_err(|error| SeatbeltPreparationError::FileSystem(error.to_string()))?;
let directory = serde_json::to_string(&directory.to_string_lossy())
.map_err(|error| SeatbeltPreparationError::FileSystem(error.to_string()))?;
policy_sections.push(format!(
"(deny file-read* file-write* (subpath {directory}))\n\
(deny network-outbound (remote unix-socket (subpath {directory})))"
));
}
if file_system_sandbox_policy.has_full_disk_read_access() {
policy_sections.push(MACOS_SEATBELT_PREFERENCES_POLICY.to_string());
}
@@ -1086,3 +1098,7 @@ pub(crate) fn create_seatbelt_command_args_with_profile(
#[cfg(test)]
#[path = "seatbelt_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "seatbelt_daemon_socket_tests.rs"]
mod daemon_socket_tests;
@@ -0,0 +1,86 @@
use super::CreateSeatbeltCommandArgsParams;
use super::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
use super::create_seatbelt_command_args;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::os::unix::net::UnixListener;
use std::os::unix::net::UnixStream;
use std::process::Command;
#[test]
fn daemon_sockets_are_denied_despite_network_and_tmp_write_grants() {
let root = codex_uds::prepare_shared_daemon_socket_directory().unwrap();
let private = tempfile::tempdir_in(&root).unwrap();
let workspace = tempfile::tempdir().unwrap();
let socket = private.path().join("rpc.sock");
let alias = workspace.path().join("alias.sock");
let unrelated = workspace.path().join("other.sock");
let _daemon = UnixListener::bind(&socket).unwrap();
let _other = UnixListener::bind(&unrelated).unwrap();
std::os::unix::fs::symlink(&socket, &alias).unwrap();
UnixStream::connect(&alias).expect("host can reach daemon");
let script = r#"
import os, socket, sys
for path in sys.argv[1:3]:
try:
socket.socket(socket.AF_UNIX).connect(path)
except OSError:
pass
else:
raise AssertionError('daemon reachable: ' + path)
try:
os.link(sys.argv[1], sys.argv[4] + '/hardlink')
except OSError:
pass
else:
raise AssertionError('daemon hardlink created')
socket.socket(socket.AF_UNIX).connect(sys.argv[3])
s = socket.socket(socket.AF_UNIX)
s.bind(sys.argv[4] + '/local.sock')
s.listen(1)
c = socket.socket(socket.AF_UNIX)
c.connect(sys.argv[4] + '/local.sock')
p, _ = s.accept()
c.sendall(b'ok')
assert p.recv(2) == b'ok'
"#;
let command = vec![
"/usr/bin/python3".to_string(),
"-c".to_string(),
script.to_string(),
socket.display().to_string(),
alias.display().to_string(),
unrelated.display().to_string(),
workspace.path().display().to_string(),
];
let policy = FileSystemSandboxPolicy::workspace_write(
&[AbsolutePathBuf::from_absolute_path("/tmp").unwrap()],
/*exclude_tmpdir_env_var*/ false,
/*exclude_slash_tmp*/ false,
);
let args = create_seatbelt_command_args(CreateSeatbeltCommandArgsParams {
command,
file_system_sandbox_policy: &policy,
network_sandbox_policy: NetworkSandboxPolicy::Enabled,
sandbox_policy_cwd: workspace.path(),
enforce_managed_network: false,
managed_network: None,
environment_id: None,
network: None,
extra_allow_unix_sockets: &[AbsolutePathBuf::from_absolute_path(root).unwrap()],
})
.unwrap();
let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
.args(args)
.env("CODEX_HOME", workspace.path())
.output()
.unwrap();
assert_eq!(
output.status.code(),
Some(0),
"{}",
String::from_utf8_lossy(&output.stderr)
);
}
+3
View File
@@ -15,6 +15,9 @@ workspace = true
[dependencies]
tokio = { workspace = true, features = ["fs", "net", "rt"] }
[target.'cfg(unix)'.dependencies]
libc = { workspace = true }
[target.'cfg(windows)'.dependencies]
async-io = { workspace = true }
tokio-util = { workspace = true, features = ["compat"] }
+39
View File
@@ -0,0 +1,39 @@
//! The host-local rendezvous root for privileged app-server RPC sockets.
//!
//! Every listener uses this root, which sandboxes hide even before a daemon
//! starts. It must not depend on HOME, TMPDIR, CODEX_HOME, or command settings.
use std::fs;
use std::io;
use std::os::unix::fs::DirBuilderExt;
use std::os::unix::fs::MetadataExt;
use std::path::PathBuf;
/// Returns the fixed executor-local directory that every sandbox must hide.
pub fn shared_daemon_socket_directory() -> io::Result<PathBuf> {
// Resolve the system alias /tmp -> /private/tmp on macOS.
let temporary_root = fs::canonicalize("/tmp")?;
let uid = unsafe { libc::geteuid() };
Ok(temporary_root.join(format!("codex-daemon-{uid}")))
}
/// Creates the reserved directory, rejecting symlinks and unsafe existing owners or modes.
pub fn prepare_shared_daemon_socket_directory() -> io::Result<PathBuf> {
let directory = shared_daemon_socket_directory()?;
match fs::DirBuilder::new().mode(0o700).create(&directory) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
Err(error) => return Err(error),
}
let metadata = fs::symlink_metadata(&directory)?;
if !metadata.is_dir()
|| metadata.uid() != unsafe { libc::geteuid() }
|| metadata.mode() & 0o777 != 0o700
{
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"app-server socket directory must be a user-owned directory with mode 0700",
));
}
Ok(directory)
}
+7
View File
@@ -1,5 +1,12 @@
//! Cross-platform async Unix domain socket helpers.
#[cfg(unix)]
mod daemon_directory;
#[cfg(unix)]
pub use daemon_directory::prepare_shared_daemon_socket_directory;
#[cfg(unix)]
pub use daemon_directory::shared_daemon_socket_directory;
use std::io::Result as IoResult;
use std::path::Path;
use std::pin::Pin;