Support sandboxed file streaming in exec-server (#38356)

## Why

Streaming reads previously rejected requests that used a platform filesystem
sandbox.

## What changed

- Open streamed files in the sandbox helper and return the open file to the
  exec-server by passing a file descriptor on Unix or duplicating a file handle
  on Windows.
- Advertise support through the `sandboxedFileStreaming` environment capability.
- Preserve close-on-exec behavior for transferred descriptors, including the
  required inherited-descriptor cleanup on macOS.

## Testing

- Cover bounded sandboxed streams, continued reads after path replacement, and
  rejection of symlink escapes outside readable roots.

GitOrigin-RevId: 677b2444b74e834b78b87a8554bc119c1c6e08b2
This commit is contained in:
jif
2026-08-13 11:11:26 +00:00
committed by copyberry
parent fe614a6304
commit c30a3e49c9
17 changed files with 487 additions and 97 deletions
+1
View File
@@ -3028,6 +3028,7 @@ dependencies = [
"pretty_assertions",
"prost",
"rcgen",
"rustix 1.1.4",
"rustls",
"serde",
"serde_json",
+1
View File
@@ -398,6 +398,7 @@ regex-lite = "0.1.8"
reqwest = { version = "0.12", features = ["cookies"] }
rmcp = { version = "=3.0.0", default-features = false }
runfiles = { git = "https://github.com/dzbarsky/rules_rust", rev = "b56cbaa8465e74127f1ea216f813cd377295ad81" }
rustix = { version = "1.1.4", features = ["net"] }
rustls = { version = "0.23", default-features = false, features = [
"aws_lc_rs",
"std",
+8
View File
@@ -75,6 +75,11 @@ pub(crate) async fn spawn_child_async(request: SpawnChildRequest<'_>) -> std::io
if let Some(network) = network {
network.apply_to_env(&mut env);
}
// macOS fd cleanup must keep the shell escalation socket.
#[cfg(target_os = "macos")]
let inherited_fd = env
.get(codex_shell_escalation::ESCALATE_SOCKET_ENV_VAR)
.and_then(|fd| fd.parse().ok());
cmd.env_clear();
cmd.envs(env);
@@ -103,6 +108,9 @@ pub(crate) async fn spawn_child_async(request: SpawnChildRequest<'_>) -> std::io
// current parent dies."
codex_utils_pty::process_group::set_parent_death_signal(parent_pid)?;
}
// macOS cannot receive the fd with close-on-exec set atomically.
#[cfg(target_os = "macos")]
codex_utils_pty::pty::close_inherited_fds_except(inherited_fd.as_slice());
Ok(())
});
}
@@ -118,6 +118,9 @@ pub struct EnvironmentCapabilities {
/// Whether this executor supports the `environmentConfig/read` request.
#[serde(default)]
pub environment_config_read: bool,
/// Whether filesystem streams can use the requested platform sandbox.
#[serde(default)]
pub sandboxed_file_streaming: bool,
}
/// Status returned by an initialized exec-server connection.
@@ -179,6 +182,7 @@ impl EnvironmentInfo {
network_proxy_launch: true,
capability_discovery_sandbox: true,
environment_config_read: true,
sandboxed_file_streaming: true,
},
}
}
@@ -931,6 +935,7 @@ mod tests {
network_proxy_launch: true,
capability_discovery_sandbox: true,
environment_config_read: false,
sandboxed_file_streaming: false,
}
);
}
@@ -945,6 +950,7 @@ mod tests {
"networkProxyLaunch": false,
"capabilityDiscoverySandbox": false,
"environmentConfigRead": false,
"sandboxedFileStreaming": false,
},
});
let info: EnvironmentInfo = serde_json::from_value(expected.clone())
+2
View File
@@ -58,11 +58,13 @@ uuid = { workspace = true, features = ["v4"] }
[target.'cfg(unix)'.dependencies]
libc = { workspace = true }
rustix = { workspace = true }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.52", features = [
"Win32_Foundation",
"Win32_Storage_FileSystem",
"Win32_System_Threading",
] }
[dev-dependencies]
+21 -1
View File
@@ -14,6 +14,7 @@ use crate::protocol::FS_CANONICALIZE_METHOD;
use crate::protocol::FS_COPY_METHOD;
use crate::protocol::FS_CREATE_DIRECTORY_METHOD;
use crate::protocol::FS_GET_METADATA_METHOD;
use crate::protocol::FS_OPEN_METHOD;
use crate::protocol::FS_READ_DIRECTORY_METHOD;
use crate::protocol::FS_READ_FILE_METHOD;
use crate::protocol::FS_REMOVE_METHOD;
@@ -47,6 +48,8 @@ pub const CODEX_FS_HELPER_ARG1: &str = "--codex-run-as-fs-helper";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "operation", content = "params")]
pub(crate) enum FsHelperRequest {
#[serde(rename = "fs/open")]
Open(FsReadFileParams),
#[serde(rename = "fs/readFile")]
ReadFile(FsReadFileParams),
#[serde(rename = "fs/writeFile")]
@@ -74,9 +77,22 @@ pub(crate) enum FsHelperResponse {
Error(JSONRPCErrorError),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct FsHelperOpenResponse {
// Windows duplicates the handle from the helper process.
#[cfg(windows)]
pub(crate) process_id: u32,
// Unix passes the fd directly instead.
#[cfg(windows)]
pub(crate) file_handle: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "operation", content = "response")]
pub(crate) enum FsHelperPayload {
#[serde(rename = "fs/open")]
Open(FsHelperOpenResponse),
#[serde(rename = "fs/readFile")]
ReadFile(FsReadFileResponse),
#[serde(rename = "fs/writeFile")]
@@ -100,6 +116,7 @@ pub(crate) enum FsHelperPayload {
impl FsHelperPayload {
fn operation(&self) -> &'static str {
match self {
Self::Open(_) => FS_OPEN_METHOD,
Self::ReadFile(_) => FS_READ_FILE_METHOD,
Self::WriteFile(_) => FS_WRITE_FILE_METHOD,
Self::CreateDirectory(_) => FS_CREATE_DIRECTORY_METHOD,
@@ -203,6 +220,9 @@ pub(crate) async fn run_direct_request(
) -> Result<FsHelperPayload, JSONRPCErrorError> {
let file_system = DirectFileSystem;
match request {
FsHelperRequest::Open(_) => Err(invalid_request(
"opening a file requires descriptor handoff".to_string(),
)),
FsHelperRequest::ReadFile(params) => {
let data = file_system
.read_file(&params.path, /*sandbox*/ None)
@@ -316,7 +336,7 @@ pub(crate) async fn run_direct_request(
}
}
fn map_fs_error(err: io::Error) -> JSONRPCErrorError {
pub(crate) fn map_fs_error(err: io::Error) -> JSONRPCErrorError {
match err.kind() {
io::ErrorKind::NotFound => not_found(err.to_string()),
io::ErrorKind::InvalidInput | io::ErrorKind::PermissionDenied => {
+51 -5
View File
@@ -1,12 +1,17 @@
use std::error::Error;
use tokio::io;
use tokio::io::AsyncReadExt;
use tokio::io::AsyncBufReadExt;
use tokio::io::AsyncWriteExt;
use tokio::io::BufReader;
use crate::fs_helper::FsHelperOpenResponse;
use crate::fs_helper::FsHelperPayload;
use crate::fs_helper::FsHelperRequest;
use crate::fs_helper::FsHelperResponse;
use crate::fs_helper::map_fs_error;
use crate::fs_helper::run_direct_request;
use crate::regular_file;
pub fn main() -> ! {
let exit_code = match tokio::runtime::Builder::new_current_thread()
@@ -29,10 +34,40 @@ pub fn main() -> ! {
}
async fn run_main() -> Result<(), Box<dyn Error + Send + Sync>> {
let mut input = Vec::new();
io::stdin().read_to_end(&mut input).await?;
let request: FsHelperRequest = serde_json::from_slice(&input)?;
let response = match run_direct_request(request).await {
let mut stdin = BufReader::new(io::stdin());
let mut input = String::new();
stdin.read_line(&mut input).await?;
let request: FsHelperRequest = serde_json::from_str(&input)?;
let mut opened_file = None;
let result = match request {
FsHelperRequest::Open(params) => {
let result: io::Result<_> = async {
let path = params.path.to_abs_path()?;
let file = regular_file::open(path.as_path()).await?;
// Unix can hand the opened fd directly to the parent.
#[cfg(unix)]
crate::sandboxed_file_open::transfer_file(&file)?;
let response = FsHelperOpenResponse {
// Windows duplicates from the helper process instead.
#[cfg(windows)]
process_id: std::process::id(),
// The parent needs the raw handle to duplicate it.
#[cfg(windows)]
file_handle: {
use std::os::windows::io::AsRawHandle;
file.as_raw_handle() as usize as u64
},
};
opened_file = Some(file);
Ok(FsHelperPayload::Open(response))
}
.await;
result.map_err(map_fs_error)
}
request => run_direct_request(request).await,
};
let response = match result {
Ok(payload) => FsHelperResponse::Ok(payload),
Err(error) => FsHelperResponse::Error(error),
};
@@ -41,5 +76,16 @@ async fn run_main() -> Result<(), Box<dyn Error + Send + Sync>> {
.write_all(serde_json::to_string(&response)?.as_bytes())
.await?;
stdout.write_all(b"\n").await?;
stdout.flush().await?;
// Keep the Windows handle alive until the parent duplicates it.
#[cfg(windows)]
if opened_file.is_some() {
use tokio::io::AsyncReadExt;
let mut acknowledgement = Vec::new();
stdin.read_to_end(&mut acknowledgement).await?;
}
drop(opened_file);
Ok(())
}
+36 -13
View File
@@ -67,6 +67,15 @@ impl FileSystemSandboxRunner {
sandbox: &FileSystemSandboxContext,
request: FsHelperRequest,
) -> Result<FsHelperPayload, JSONRPCErrorError> {
let command = self.sandbox_command(sandbox)?;
let request_json = serde_json::to_vec(&request).map_err(json_error)?;
run_command(command, request_json).await
}
pub(crate) fn sandbox_command(
&self,
sandbox: &FileSystemSandboxContext,
) -> Result<SandboxExecRequest, JSONRPCErrorError> {
let cwd = sandbox_cwd(sandbox)?;
let native_workspace_roots = sandbox
.workspace_roots
@@ -98,10 +107,7 @@ impl FileSystemSandboxRunner {
&file_system_policy,
network_policy,
);
let command =
self.sandbox_exec_request(&permission_profile, &cwd, workspace_roots, sandbox)?;
let request_json = serde_json::to_vec(&request).map_err(json_error)?;
run_command(command, request_json).await
self.sandbox_exec_request(&permission_profile, &cwd, workspace_roots, sandbox)
}
fn sandbox_exec_request(
@@ -305,7 +311,7 @@ async fn run_command(
command: SandboxExecRequest,
request_json: Vec<u8>,
) -> Result<FsHelperPayload, JSONRPCErrorError> {
let mut child = spawn_command(command)?;
let mut child = spawn_command(command, std::process::Stdio::piped())?;
let mut stdin = child
.stdin
.take()
@@ -314,6 +320,17 @@ async fn run_command(
stdin.shutdown().await.map_err(io_error)?;
drop(stdin);
let output = wait_for_helper_output(child).await?;
let response: FsHelperResponse = serde_json::from_slice(&output.stdout).map_err(json_error)?;
match response {
FsHelperResponse::Ok(payload) => Ok(payload),
FsHelperResponse::Error(error) => Err(error),
}
}
pub(crate) async fn wait_for_helper_output(
child: tokio::process::Child,
) -> Result<std::process::Output, JSONRPCErrorError> {
let output = child.wait_with_output().await.map_err(io_error)?;
if !output.status.success() {
return Err(internal_error(format!(
@@ -322,14 +339,10 @@ async fn run_command(
stderr = String::from_utf8_lossy(&output.stderr).trim()
)));
}
let response: FsHelperResponse = serde_json::from_slice(&output.stdout).map_err(json_error)?;
match response {
FsHelperResponse::Ok(payload) => Ok(payload),
FsHelperResponse::Error(error) => Err(error),
}
Ok(output)
}
fn spawn_command(
pub(crate) fn spawn_command(
SandboxExecRequest {
command: argv,
cwd,
@@ -337,6 +350,7 @@ fn spawn_command(
arg0,
..
}: SandboxExecRequest,
stdin: std::process::Stdio,
) -> Result<tokio::process::Child, JSONRPCErrorError> {
let Some((program, args)) = argv.split_first() else {
return Err(invalid_request("fs sandbox command was empty".to_string()));
@@ -355,14 +369,23 @@ fn spawn_command(
env.retain(|name, _| !codex_protocol::shell_environment::is_non_inheritable_env_var(name));
command.env_clear();
command.envs(env);
command.stdin(std::process::Stdio::piped());
command.stdin(stdin);
command.stdout(std::process::Stdio::piped());
command.stderr(std::process::Stdio::piped());
command.kill_on_drop(true);
// macOS cannot receive passed fds with close-on-exec set atomically.
#[cfg(target_os = "macos")]
// SAFETY: Descriptor cleanup only uses fork-safe system calls.
unsafe {
command.pre_exec(|| {
codex_utils_pty::pty::close_inherited_fds_except(&[]);
Ok(())
});
}
command.spawn().map_err(io_error)
}
fn io_error(err: std::io::Error) -> JSONRPCErrorError {
pub(crate) fn io_error(err: std::io::Error) -> JSONRPCErrorError {
internal_error(err.to_string())
}
+1
View File
@@ -32,6 +32,7 @@ mod resolved_capability;
mod rpc;
mod rpc_server_requests;
mod runtime_paths;
mod sandboxed_file_open;
mod sandboxed_file_system;
mod server;
mod telemetry;
@@ -99,10 +99,7 @@ impl LocalFileSystem {
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<tokio::fs::File> {
if sandbox.is_some_and(FileSystemSandboxContext::should_run_in_sandbox) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"streaming file reads do not support platform sandboxing",
));
return self.sandboxed()?.open_file_for_read(path, sandbox).await;
}
self.unsandboxed.open_file_for_read(path, sandbox).await
}
@@ -100,12 +100,6 @@ impl RemoteFileSystem {
path: &PathUri,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<FileSystemReadStream> {
if sandbox.is_some_and(FileSystemSandboxContext::should_run_in_sandbox) {
return Err(io::Error::new(
io::ErrorKind::Unsupported,
"streaming file reads do not support platform sandboxing",
));
}
trace!("remote fs read_file_stream");
let client = self.client.get().await.map_err(map_remote_error)?;
file_stream::open(client, path.clone(), remote_sandbox_context(sandbox)).await
@@ -0,0 +1,207 @@
use codex_exec_server_protocol::JSONRPCErrorError;
use codex_sandboxing::SandboxExecRequest;
use codex_utils_path_uri::PathUri;
use tokio::io;
use crate::fs_helper::FsHelperOpenResponse;
use crate::fs_helper::FsHelperPayload;
use crate::fs_helper::FsHelperRequest;
use crate::fs_helper::FsHelperResponse;
use crate::fs_sandbox::io_error;
use crate::fs_sandbox::spawn_command;
use crate::fs_sandbox::wait_for_helper_output;
use crate::protocol::FsReadFileParams;
use crate::rpc::internal_error;
use crate::rpc::invalid_request;
pub(crate) async fn open(
command: SandboxExecRequest,
path: PathUri,
) -> Result<tokio::fs::File, JSONRPCErrorError> {
let request = serde_json::to_vec(&FsHelperRequest::Open(FsReadFileParams {
path,
sandbox: None,
}))
.map_err(|error| internal_error(format!("invalid fs sandbox helper request: {error}")))?;
open_platform(command, request).await
}
fn open_response(response: &[u8]) -> Result<FsHelperOpenResponse, JSONRPCErrorError> {
match serde_json::from_slice(response).map_err(|error| {
internal_error(format!("invalid fs sandbox helper open response: {error}"))
})? {
FsHelperResponse::Ok(FsHelperPayload::Open(response)) => Ok(response),
FsHelperResponse::Ok(_) => Err(invalid_request(
"invalid fs sandbox helper open response".to_string(),
)),
FsHelperResponse::Error(error) => Err(error),
}
}
// Unix passes the opened fd over the helper's stdin socket.
#[cfg(unix)]
async fn open_platform(
command: SandboxExecRequest,
request: Vec<u8>,
) -> Result<tokio::fs::File, JSONRPCErrorError> {
use std::io::Write;
use std::os::fd::OwnedFd;
use std::os::unix::net::UnixStream;
let (mut receiver, sender) = UnixStream::pair().map_err(io_error)?;
let sender: OwnedFd = sender.into();
let child = spawn_command(command, std::process::Stdio::from(sender))?;
receiver.write_all(&request).map_err(io_error)?;
receiver
.shutdown(std::net::Shutdown::Write)
.map_err(io_error)?;
let output = wait_for_helper_output(child).await?;
open_response(&output.stdout)?;
let descriptor = receive_file_descriptor(&receiver).map_err(io_error)?;
Ok(tokio::fs::File::from_std(std::fs::File::from(descriptor)))
}
// Windows duplicates the helper's handle before letting it exit.
#[cfg(windows)]
async fn open_platform(
command: SandboxExecRequest,
mut request: Vec<u8>,
) -> Result<tokio::fs::File, JSONRPCErrorError> {
use tokio::io::AsyncBufReadExt;
use tokio::io::AsyncWriteExt;
let mut child = spawn_command(command, std::process::Stdio::piped())?;
let mut stdin = child
.stdin
.take()
.ok_or_else(|| internal_error("missing fs sandbox helper stdin".to_string()))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| internal_error("missing fs sandbox helper stdout".to_string()))?;
request.push(b'\n');
stdin.write_all(&request).await.map_err(io_error)?;
let result = async {
let mut response = Vec::new();
tokio::io::BufReader::new(stdout)
.read_until(b'\n', &mut response)
.await
.map_err(io_error)?;
let response = open_response(&response)?;
duplicate_file_handle(response.process_id, response.file_handle).map_err(io_error)
}
.await;
drop(stdin);
wait_for_helper_output(child).await?;
result.map(tokio::fs::File::from_std)
}
// SCM_RIGHTS is Unix-only.
#[cfg(unix)]
pub(crate) fn transfer_file(file: &tokio::fs::File) -> io::Result<()> {
use rustix::net::SendAncillaryBuffer;
use rustix::net::SendAncillaryMessage;
use rustix::net::SendFlags;
use std::io::IoSlice;
use std::os::fd::AsFd;
let descriptors = [file.as_fd()];
let mut space = [std::mem::MaybeUninit::uninit(); rustix::cmsg_space!(ScmRights(1))];
let mut control = SendAncillaryBuffer::new(&mut space);
if !control.push(SendAncillaryMessage::ScmRights(&descriptors)) {
return Err(io::Error::other("missing file-descriptor control header"));
}
if rustix::net::sendmsg(
std::io::stdin(),
&[IoSlice::new(&[0])],
&mut control,
SendFlags::empty(),
)? != 1
{
return Err(io::Error::other(
"fs sandbox helper did not transfer its opened file descriptor",
));
}
Ok(())
}
// File-descriptor passing is only available on Unix.
#[cfg(unix)]
fn receive_file_descriptor(
socket: &std::os::unix::net::UnixStream,
) -> io::Result<std::os::fd::OwnedFd> {
use rustix::net::RecvAncillaryBuffer;
use rustix::net::RecvAncillaryMessage;
use rustix::net::RecvFlags;
use rustix::net::ReturnFlags;
use std::io::IoSliceMut;
let mut byte = [0_u8];
let mut buffers = [IoSliceMut::new(&mut byte)];
let mut space = [std::mem::MaybeUninit::uninit(); rustix::cmsg_space!(ScmRights(1))];
let mut control = RecvAncillaryBuffer::new(&mut space);
// Linux can set close-on-exec while receiving the fd.
#[cfg(target_os = "linux")]
let flags = RecvFlags::CMSG_CLOEXEC;
// Other Unix platforms need the non-atomic fcntl call below.
#[cfg(not(target_os = "linux"))]
let flags = RecvFlags::empty();
let message = rustix::net::recvmsg(socket, &mut buffers, &mut control, flags)?;
if message.bytes != 1 || message.flags.contains(ReturnFlags::CTRUNC) {
return Err(io::Error::other("invalid file-descriptor control message"));
}
let descriptor = control
.drain()
.find_map(|message| match message {
RecvAncillaryMessage::ScmRights(mut descriptors) => descriptors.next(),
_ => None,
})
.ok_or_else(|| io::Error::other("missing transferred file descriptor"))?;
// macOS cannot set this atomically, so the fd is briefly inheritable.
// Shell and filesystem helper launches close inherited fds to limit that race.
#[cfg(not(target_os = "linux"))]
rustix::io::fcntl_setfd(&descriptor, rustix::io::FdFlags::CLOEXEC)?;
Ok(descriptor)
}
// Windows file handles must be duplicated across processes.
#[cfg(windows)]
fn duplicate_file_handle(process_id: u32, file_handle: u64) -> io::Result<std::fs::File> {
use std::os::windows::io::AsRawHandle;
use std::os::windows::io::FromRawHandle;
use std::os::windows::io::OwnedHandle;
use windows_sys::Win32::Foundation::DUPLICATE_SAME_ACCESS;
use windows_sys::Win32::Foundation::DuplicateHandle;
use windows_sys::Win32::Foundation::HANDLE;
use windows_sys::Win32::System::Threading::GetCurrentProcess;
use windows_sys::Win32::System::Threading::OpenProcess;
use windows_sys::Win32::System::Threading::PROCESS_DUP_HANDLE;
// SAFETY: OpenProcess returns an owned handle or null on failure.
let process = unsafe { OpenProcess(PROCESS_DUP_HANDLE, 0, process_id) };
if process == 0 {
return Err(io::Error::last_os_error());
}
// SAFETY: The successful OpenProcess result is owned by this scope.
let process = unsafe { OwnedHandle::from_raw_handle(process as _) };
let mut duplicated: HANDLE = 0;
// SAFETY: Both process handles remain valid and duplicated receives an owned file handle.
if unsafe {
DuplicateHandle(
process.as_raw_handle() as HANDLE,
file_handle as HANDLE,
GetCurrentProcess(),
&raw mut duplicated,
0,
0,
DUPLICATE_SAME_ACCESS,
)
} == 0
{
return Err(io::Error::last_os_error());
}
// SAFETY: DuplicateHandle transferred ownership of the new file handle.
Ok(unsafe { std::fs::File::from_raw_handle(duplicated as _) })
}
@@ -3,12 +3,14 @@ use base64::engine::general_purpose::STANDARD;
use codex_exec_server_protocol::JSONRPCErrorError;
use codex_utils_path_uri::PathUri;
use tokio::io;
use tokio_util::io::ReaderStream;
use crate::CopyOptions;
use crate::CreateDirectoryOptions;
use crate::ExecServerRuntimePaths;
use crate::ExecutorFileSystem;
use crate::ExecutorFileSystemFuture;
use crate::FILE_READ_CHUNK_SIZE;
use crate::FileMetadata;
use crate::FileSystemReadStream;
use crate::FileSystemResult;
@@ -36,6 +38,22 @@ pub struct SandboxedFileSystem {
}
impl SandboxedFileSystem {
pub(crate) async fn open_file_for_read(
&self,
path: &PathUri,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<tokio::fs::File> {
let sandbox = require_platform_sandbox(sandbox)?;
validate_native_path(path)?;
let command = self
.sandbox_runner
.sandbox_command(sandbox)
.map_err(map_sandbox_error)?;
crate::sandboxed_file_open::open(command, path.clone())
.await
.map_err(map_sandbox_error)
}
pub fn new(runtime_paths: ExecServerRuntimePaths) -> Self {
Self {
sandbox_runner: FileSystemSandboxRunner::new(runtime_paths),
@@ -295,14 +313,15 @@ impl ExecutorFileSystem for SandboxedFileSystem {
fn read_file_stream<'a>(
&'a self,
_path: &'a PathUri,
_sandbox: Option<&'a FileSystemSandboxContext>,
path: &'a PathUri,
sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, FileSystemReadStream> {
Box::pin(async {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"streaming file reads do not support platform sandboxing",
))
Box::pin(async move {
let file = self.open_file_for_read(path, sandbox).await?;
Ok(FileSystemReadStream::new(ReaderStream::with_capacity(
file,
FILE_READ_CHUNK_SIZE,
)))
})
}
+14 -39
View File
@@ -5,7 +5,6 @@ use codex_exec_server::Environment;
use codex_exec_server::ExecServerClient;
use codex_exec_server::ExecServerError;
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::FileSystemSandboxContext;
use codex_exec_server::FsCloseParams;
use codex_exec_server::FsOpenParams;
use codex_exec_server::FsReadBlockParams;
@@ -13,13 +12,6 @@ use codex_exec_server::FsReadBlockResponse;
use codex_exec_server::RemoteExecServerConnectArgs;
use codex_http_client::HttpClientFactory;
use codex_http_client::OutboundProxyPolicy;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use futures::TryStreamExt;
use pretty_assertions::assert_eq;
@@ -83,32 +75,6 @@ async fn completed_streams_release_handle_capacity() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn stream_rejects_platform_sandbox() -> Result<()> {
let server = exec_server().await?;
let file_system = connect_file_system(server.websocket_url())?;
let tmp = TempDir::new()?;
let path = tmp.path().join("sandboxed.txt");
std::fs::write(&path, "sandboxed hello")?;
let result = file_system
.read_file_stream(
&PathUri::from_host_native_path(&path)?,
Some(&read_only_sandbox(tmp.path().to_path_buf())),
)
.await;
let Err(error) = result else {
panic!("sandboxed stream should be rejected");
};
assert_eq!(error.kind(), std::io::ErrorKind::Unsupported);
assert_eq!(
error.to_string(),
"streaming file reads do not support platform sandboxing"
);
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn file_reads_reject_fifo_without_waiting_for_a_writer() -> Result<()> {
@@ -206,11 +172,9 @@ async fn stream_keeps_reading_the_open_file_after_path_replacement() -> Result<(
let tmp = TempDir::new()?;
let path = tmp.path().join("replaceable.bin");
std::fs::write(&path, vec![b'a'; BLOCK_SIZE + 1])?;
let sandbox = read_only_sandbox(tmp.path().to_path_buf());
let mut stream = file_system
.read_file_stream(
&PathUri::from_host_native_path(&path)?,
/*sandbox*/ None,
)
.read_file_stream(&PathUri::from_host_native_path(&path)?, Some(&sandbox))
.await?;
assert_eq!(
@@ -394,7 +358,18 @@ fn connect_file_system(websocket_url: &str) -> Result<Arc<dyn ExecutorFileSystem
Ok(environment.get_filesystem())
}
fn read_only_sandbox(path: std::path::PathBuf) -> FileSystemSandboxContext {
// Only the Unix stream tests above need this sandbox builder.
#[cfg(unix)]
fn read_only_sandbox(path: std::path::PathBuf) -> codex_exec_server::FileSystemSandboxContext {
use codex_exec_server::FileSystemSandboxContext;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
let path = AbsolutePathBuf::from_absolute_path(&path)
.unwrap_or_else(|err| panic!("sandbox path should be absolute: {err}"));
FileSystemSandboxContext::from_permission_profile(PermissionProfile::from_runtime_permissions(
@@ -225,28 +225,29 @@ async fn file_system_read_file_stream_returns_bounded_chunks(
.collect::<Vec<_>>();
std::fs::write(&file_path, &contents)?;
let chunks = file_system
.read_file_stream(
&PathUri::from_host_native_path(file_path)?,
/*sandbox*/ None,
)
.await
.with_context(|| format!("mode={implementation}"))?
.try_collect::<Vec<_>>()
.await?;
let path = PathUri::from_host_native_path(file_path)?;
let sandbox = read_only_sandbox(tmp.path().to_path_buf());
for sandbox in [None, Some(&sandbox)] {
let chunks = file_system
.read_file_stream(&path, sandbox)
.await
.with_context(|| format!("mode={implementation}"))?
.try_collect::<Vec<_>>()
.await?;
assert!(
chunks
.iter()
.all(|chunk| !chunk.is_empty() && chunk.len() <= FILE_READ_CHUNK_SIZE)
);
assert_eq!(
chunks
.iter()
.flat_map(|chunk| chunk.iter().copied())
.collect::<Vec<_>>(),
contents
);
assert!(
chunks
.iter()
.all(|chunk| !chunk.is_empty() && chunk.len() <= FILE_READ_CHUNK_SIZE)
);
assert_eq!(
chunks
.iter()
.flat_map(|chunk| chunk.iter().copied())
.collect::<Vec<_>>(),
contents
);
}
Ok(())
}
@@ -691,6 +692,14 @@ async fn file_system_sandboxed_metadata_and_read_allow_readable_root(
.with_context(|| format!("mode={implementation}"))?;
assert_eq!(contents, b"sandboxed hello");
let chunks = file_system
.read_file_stream(&PathUri::from_host_native_path(&file_path)?, Some(&sandbox))
.await
.with_context(|| format!("stream mode={implementation}"))?
.try_collect::<Vec<_>>()
.await?;
assert_eq!(chunks.concat(), b"sandboxed hello");
Ok(())
}
@@ -630,6 +630,16 @@ async fn file_system_sandboxed_read_rejects_symlink_escape(
};
assert_sandbox_denied(&error);
let error = file_system
.read_file_stream(
&PathUri::from_host_native_path(&requested_path)?,
Some(&sandbox),
)
.await
.err()
.context("streaming read should be blocked")?;
assert_sandbox_denied(&error);
Ok(())
}
+72 -1
View File
@@ -467,7 +467,78 @@ fn set_cloexec(fd: RawFd) -> std::io::Result<()> {
Ok(())
}
#[cfg(unix)]
// macOS needs a fork-safe sweep because recvmsg cannot set close-on-exec.
#[cfg(target_os = "macos")]
pub fn close_inherited_fds_except(preserved_fds: &[RawFd]) {
let mut descriptors = [libc::proc_fdinfo {
proc_fd: 0,
proc_fdtype: 0,
}; 1024];
// SAFETY: proc_pidinfo writes descriptor records into the stack buffer.
let bytes = unsafe {
libc::proc_pidinfo(
libc::getpid(),
libc::PROC_PIDLISTFDS,
/*arg*/ 0,
descriptors.as_mut_ptr().cast(),
std::mem::size_of_val(&descriptors) as libc::c_int,
)
};
let close_inheritable = |fd| {
if fd <= libc::STDERR_FILENO || preserved_fds.contains(&fd) {
return;
}
// std::process keeps a CLOEXEC pipe open until exec to report spawn errors.
// SAFETY: fcntl and close only operate on a descriptor owned by this process.
unsafe {
let flags = libc::fcntl(fd, libc::F_GETFD);
if flags >= 0 && flags & libc::FD_CLOEXEC == 0 {
libc::close(fd);
}
}
};
if bytes > 0 && (bytes as usize) < std::mem::size_of_val(&descriptors) {
let count = bytes as usize / std::mem::size_of::<libc::proc_fdinfo>();
for descriptor in descriptors.iter().take(count) {
close_inheritable(descriptor.proc_fd);
}
return;
}
// SAFETY: proc_pidinfo accepts a null buffer when its size is zero.
let descriptor_table_bytes = unsafe {
libc::proc_pidinfo(
libc::getpid(),
libc::PROC_PIDLISTFDS,
/*arg*/ 0,
std::ptr::null_mut(),
/*buffersize*/ 0,
)
};
if descriptor_table_bytes > 0 {
let upper_bound =
descriptor_table_bytes as usize / std::mem::size_of::<libc::proc_fdinfo>();
for fd in libc::STDERR_FILENO + 1..upper_bound as RawFd {
close_inheritable(fd);
}
return;
}
let mut limit = libc::rlimit {
rlim_cur: 0,
rlim_max: 0,
};
// SAFETY: getrlimit writes into the stack-owned resource-limit structure.
if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &raw mut limit) } == 0 {
let upper_bound = limit.rlim_cur.min(RawFd::MAX as _) as RawFd;
for fd in libc::STDERR_FILENO + 1..upper_bound {
close_inheritable(fd);
}
}
}
// Other Unix platforms keep their existing fd cleanup.
#[cfg(all(unix, not(target_os = "macos")))]
pub(crate) fn close_inherited_fds_except(preserved_fds: &[RawFd]) {
if let Ok(dir) = std::fs::read_dir("/dev/fd") {
let mut fds = Vec::new();