Files
codex/codex-rs/linux-sandbox/src/daemon_mounts.rs
T
open-matt 1bd1bfa7ca Fix daemon socket isolation checks for private tmp mounts (#46125)
## Why

Private `/tmp` bind mounts can leave hidden entries in `/proc/self/mountinfo`, causing the Linux sandbox to reject layouts that safely isolate daemon sockets. The proc-mount preflight also needs the main sandbox's WSL masks when checking aliases.

## What changed

- Identify the opened socket directory's mount using `fdinfo`, with a `statx` fallback, and follow its mount ancestry to distinguish hidden paths from exposed aliases.
- Accept safe private and stacked mounts while rejecting covered mounts, exposed aliases, and nested mounts. Retain conservative checks when no mount ID is available.
- Preserve WSL interop and WSLg masks in the proc-mount preflight.
- Heap-allocate large lifecycle futures in the TUI approval-gated MCP tool test to reduce Windows test-thread stack usage.

## Testing

Add mount-layout regression cases and a namespace integration test that verifies daemon sockets remain inaccessible under private `/tmp`, unrelated sockets remain reachable, and an exposed alias prevents startup. Add a preflight test for preserving WSL masks.

GitOrigin-RevId: 289b4446ba7776a7dd57ef5ff09e87bda19b069c
2026-09-17 05:23:55 +00:00

189 lines
7.2 KiB
Rust

//! Reject host mount aliases that would bypass the privileged socket directory mask.
//! Mount roots describe filesystem identity; canonical paths alone miss bind mounts.
use rustix::fs::AtFlags;
use rustix::fs::StatxFlags;
use rustix::fs::statx;
use std::collections::BTreeSet;
use std::fs;
use std::io;
use std::os::fd::AsRawFd;
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 directory_file = fs::File::open(directory)?;
let device = directory_file.metadata()?.dev();
let mount_id = fs::read_to_string(format!("/proc/self/fdinfo/{}", directory_file.as_raw_fd()))
.ok()
.and_then(|fdinfo| {
fdinfo
.lines()
.find_map(|line| line.strip_prefix("mnt_id:"))
.and_then(|id| id.trim().parse::<u64>().ok())
})
.or_else(|| {
// Query the same open directory, using the ID shared with mountinfo.
// Older kernels may succeed without returning the requested field.
statx(&directory_file, "", AtFlags::EMPTY_PATH, StatxFlags::MNT_ID)
.ok()
.filter(|stat| stat.stx_mask & StatxFlags::MNT_ID.bits() != 0)
.map(|stat| stat.stx_mnt_id)
})
.map(|id| id.to_string());
check_mounts(
directory,
&format!("{}:{}", libc::major(device), libc::minor(device)),
mount_id.as_deref(),
&fs::read("/proc/self/mountinfo")?,
masked_root,
)
}
fn check_mounts(
directory: &Path,
device: &str,
mount_id: Option<&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();
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 [id, parent, mount_device, root, destination] = fields.as_slice() else {
return Err(invalid());
};
let root = mount_path(root)?;
let destination = mount_path(destination)?;
mounts.push((*id, *parent, *mount_device, root, destination));
}
let (location, containing_mount) = if let Some(mount_id) = mount_id {
// fdinfo/statx identifies the opened mount, which may have been covered
// by another mount before we read mountinfo.
let selected = mounts
.iter()
.find(|(id, ..)| *id == mount_id.as_bytes())
.ok_or_else(invalid)?;
let (_, _, mount_device, root, destination) = selected;
if *mount_device != device.as_bytes() {
return Err(invalid());
}
let relative = directory.strip_prefix(destination).map_err(|_| invalid())?;
let mut current = Some(selected);
let mut visible_child: Option<&Path> = None;
let mut visited = BTreeSet::new();
while let Some((id, parent, _, _, destination)) = current {
if !visited.insert(id)
|| mounts.iter().any(|(child_id, child_parent, _, _, child)| {
child_id != id
&& child_parent == id
&& directory.starts_with(child)
&& !visible_child.is_some_and(|visible| child.starts_with(visible))
})
{
return Err(invalid());
}
if id == parent {
break;
}
// Follow the selected branch towards the namespace root. Sibling
// mounts below this branch are hidden; mounts above it cover it.
visible_child = Some(destination);
current = mounts.iter().find(|(id, ..)| id == parent);
}
(root.join(relative), Some((mount_id, destination)))
} else {
// Without a mount ID, require every possible containing mount to agree
// on the backing location, and do not assume any aliases are hidden.
let locations: BTreeSet<_> = mounts
.iter()
.filter(|(_, _, mount_device, ..)| *mount_device == device.as_bytes())
.filter_map(|(_, _, _, root, destination)| {
directory
.strip_prefix(destination)
.ok()
.map(|relative| root.join(relative))
})
.collect();
if locations.len() != 1 {
return Err(invalid());
}
(locations.into_iter().next().ok_or_else(invalid)?, None)
};
for (id, _, 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| {
// An ancestor's path beneath this mount is hidden by it. Keep
// checking other mounts, including aliases mounted beneath it.
let hidden = containing_mount.is_some_and(|(mount_id, containing_mount)| {
*id != mount_id.as_bytes()
&& containing_mount.starts_with(destination)
&& path.starts_with(containing_mount)
});
!path.starts_with(directory)
&& !masked_root.is_some_and(|root| path.starts_with(root))
&& !hidden
})
{
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;