Run exec-server fs operations through sandbox helper (#17294)

## Summary
- run exec-server filesystem RPCs requiring sandboxing through a
`codex-fs` arg0 helper over stdin/stdout
- keep direct local filesystem execution for `DangerFullAccess` and
external sandbox policies
- remove the standalone exec-server binary path in favor of top-level
arg0 dispatch/runtime paths
- add sandbox escape regression coverage for local and remote filesystem
paths

## Validation
- `just fmt`
- `git diff --check`
- remote devbox: `cd codex-rs && bazel test --bes_backend=
--bes_results_url= //codex-rs/exec-server:all` (6/6 passed)

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
starr-openai
2026-04-12 18:36:03 -07:00
committed by GitHub
parent 7c1e41c8b6
commit d626dc3895
52 changed files with 2313 additions and 895 deletions

View File

@@ -1,7 +1,4 @@
use async_trait::async_trait;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::protocol::SandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::path::Path;
use std::path::PathBuf;
@@ -13,23 +10,240 @@ use tokio::io;
use crate::CopyOptions;
use crate::CreateDirectoryOptions;
use crate::ExecServerRuntimePaths;
use crate::ExecutorFileSystem;
use crate::FileMetadata;
use crate::FileSystemResult;
use crate::FileSystemSandboxContext;
use crate::ReadDirectoryEntry;
use crate::RemoveOptions;
use crate::sandboxed_file_system::SandboxedFileSystem;
const MAX_READ_FILE_BYTES: u64 = 512 * 1024 * 1024;
pub static LOCAL_FS: LazyLock<Arc<dyn ExecutorFileSystem>> =
LazyLock::new(|| -> Arc<dyn ExecutorFileSystem> { Arc::new(LocalFileSystem) });
LazyLock::new(|| -> Arc<dyn ExecutorFileSystem> { Arc::new(LocalFileSystem::unsandboxed()) });
#[derive(Clone, Default)]
pub(crate) struct LocalFileSystem;
pub(crate) struct DirectFileSystem;
#[derive(Clone, Default)]
pub(crate) struct UnsandboxedFileSystem {
file_system: DirectFileSystem,
}
#[derive(Clone, Default)]
pub struct LocalFileSystem {
unsandboxed: UnsandboxedFileSystem,
sandboxed: Option<SandboxedFileSystem>,
}
impl LocalFileSystem {
pub fn unsandboxed() -> Self {
Self {
unsandboxed: UnsandboxedFileSystem::default(),
sandboxed: None,
}
}
pub fn with_runtime_paths(runtime_paths: ExecServerRuntimePaths) -> Self {
Self {
unsandboxed: UnsandboxedFileSystem::default(),
sandboxed: Some(SandboxedFileSystem::new(runtime_paths)),
}
}
fn sandboxed(&self) -> io::Result<&SandboxedFileSystem> {
self.sandboxed.as_ref().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"sandboxed filesystem operations require configured runtime paths",
)
})
}
fn file_system_for<'a>(
&'a self,
sandbox: Option<&'a FileSystemSandboxContext>,
) -> io::Result<(
&'a dyn ExecutorFileSystem,
Option<&'a FileSystemSandboxContext>,
)> {
if sandbox.is_some_and(FileSystemSandboxContext::should_run_in_sandbox) {
Ok((self.sandboxed()?, sandbox))
} else {
Ok((&self.unsandboxed, sandbox))
}
}
}
#[async_trait]
impl ExecutorFileSystem for LocalFileSystem {
async fn read_file(&self, path: &AbsolutePathBuf) -> FileSystemResult<Vec<u8>> {
async fn read_file(
&self,
path: &AbsolutePathBuf,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<Vec<u8>> {
let (file_system, sandbox) = self.file_system_for(sandbox)?;
file_system.read_file(path, sandbox).await
}
async fn write_file(
&self,
path: &AbsolutePathBuf,
contents: Vec<u8>,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()> {
let (file_system, sandbox) = self.file_system_for(sandbox)?;
file_system.write_file(path, contents, sandbox).await
}
async fn create_directory(
&self,
path: &AbsolutePathBuf,
options: CreateDirectoryOptions,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()> {
let (file_system, sandbox) = self.file_system_for(sandbox)?;
file_system.create_directory(path, options, sandbox).await
}
async fn get_metadata(
&self,
path: &AbsolutePathBuf,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<FileMetadata> {
let (file_system, sandbox) = self.file_system_for(sandbox)?;
file_system.get_metadata(path, sandbox).await
}
async fn read_directory(
&self,
path: &AbsolutePathBuf,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<Vec<ReadDirectoryEntry>> {
let (file_system, sandbox) = self.file_system_for(sandbox)?;
file_system.read_directory(path, sandbox).await
}
async fn remove(
&self,
path: &AbsolutePathBuf,
options: RemoveOptions,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()> {
let (file_system, sandbox) = self.file_system_for(sandbox)?;
file_system.remove(path, options, sandbox).await
}
async fn copy(
&self,
source_path: &AbsolutePathBuf,
destination_path: &AbsolutePathBuf,
options: CopyOptions,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()> {
let (file_system, sandbox) = self.file_system_for(sandbox)?;
file_system
.copy(source_path, destination_path, options, sandbox)
.await
}
}
#[async_trait]
impl ExecutorFileSystem for UnsandboxedFileSystem {
async fn read_file(
&self,
path: &AbsolutePathBuf,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<Vec<u8>> {
reject_platform_sandbox_context(sandbox)?;
self.file_system.read_file(path, /*sandbox*/ None).await
}
async fn write_file(
&self,
path: &AbsolutePathBuf,
contents: Vec<u8>,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()> {
reject_platform_sandbox_context(sandbox)?;
self.file_system
.write_file(path, contents, /*sandbox*/ None)
.await
}
async fn create_directory(
&self,
path: &AbsolutePathBuf,
options: CreateDirectoryOptions,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()> {
reject_platform_sandbox_context(sandbox)?;
self.file_system
.create_directory(path, options, /*sandbox*/ None)
.await
}
async fn get_metadata(
&self,
path: &AbsolutePathBuf,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<FileMetadata> {
reject_platform_sandbox_context(sandbox)?;
self.file_system.get_metadata(path, /*sandbox*/ None).await
}
async fn read_directory(
&self,
path: &AbsolutePathBuf,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<Vec<ReadDirectoryEntry>> {
reject_platform_sandbox_context(sandbox)?;
self.file_system
.read_directory(path, /*sandbox*/ None)
.await
}
async fn remove(
&self,
path: &AbsolutePathBuf,
options: RemoveOptions,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()> {
reject_platform_sandbox_context(sandbox)?;
self.file_system
.remove(path, options, /*sandbox*/ None)
.await
}
async fn copy(
&self,
source_path: &AbsolutePathBuf,
destination_path: &AbsolutePathBuf,
options: CopyOptions,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()> {
reject_platform_sandbox_context(sandbox)?;
self.file_system
.copy(
source_path,
destination_path,
options,
/*sandbox*/ None,
)
.await
}
}
#[async_trait]
impl ExecutorFileSystem for DirectFileSystem {
async fn read_file(
&self,
path: &AbsolutePathBuf,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<Vec<u8>> {
reject_sandbox_context(sandbox)?;
let metadata = tokio::fs::metadata(path.as_path()).await?;
if metadata.len() > MAX_READ_FILE_BYTES {
return Err(io::Error::new(
@@ -40,34 +254,23 @@ impl ExecutorFileSystem for LocalFileSystem {
tokio::fs::read(path.as_path()).await
}
async fn read_file_with_sandbox_policy(
&self,
path: &AbsolutePathBuf,
sandbox_policy: Option<&SandboxPolicy>,
) -> FileSystemResult<Vec<u8>> {
enforce_read_access(path, sandbox_policy)?;
self.read_file(path).await
}
async fn write_file(&self, path: &AbsolutePathBuf, contents: Vec<u8>) -> FileSystemResult<()> {
tokio::fs::write(path.as_path(), contents).await
}
async fn write_file_with_sandbox_policy(
async fn write_file(
&self,
path: &AbsolutePathBuf,
contents: Vec<u8>,
sandbox_policy: Option<&SandboxPolicy>,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()> {
enforce_write_access(path, sandbox_policy)?;
self.write_file(path, contents).await
reject_sandbox_context(sandbox)?;
tokio::fs::write(path.as_path(), contents).await
}
async fn create_directory(
&self,
path: &AbsolutePathBuf,
options: CreateDirectoryOptions,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()> {
reject_sandbox_context(sandbox)?;
if options.recursive {
tokio::fs::create_dir_all(path.as_path()).await?;
} else {
@@ -76,17 +279,12 @@ impl ExecutorFileSystem for LocalFileSystem {
Ok(())
}
async fn create_directory_with_sandbox_policy(
async fn get_metadata(
&self,
path: &AbsolutePathBuf,
create_directory_options: CreateDirectoryOptions,
sandbox_policy: Option<&SandboxPolicy>,
) -> FileSystemResult<()> {
enforce_write_access(path, sandbox_policy)?;
self.create_directory(path, create_directory_options).await
}
async fn get_metadata(&self, path: &AbsolutePathBuf) -> FileSystemResult<FileMetadata> {
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<FileMetadata> {
reject_sandbox_context(sandbox)?;
let metadata = tokio::fs::metadata(path.as_path()).await?;
Ok(FileMetadata {
is_directory: metadata.is_dir(),
@@ -96,19 +294,12 @@ impl ExecutorFileSystem for LocalFileSystem {
})
}
async fn get_metadata_with_sandbox_policy(
&self,
path: &AbsolutePathBuf,
sandbox_policy: Option<&SandboxPolicy>,
) -> FileSystemResult<FileMetadata> {
enforce_read_access(path, sandbox_policy)?;
self.get_metadata(path).await
}
async fn read_directory(
&self,
path: &AbsolutePathBuf,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<Vec<ReadDirectoryEntry>> {
reject_sandbox_context(sandbox)?;
let mut entries = Vec::new();
let mut read_dir = tokio::fs::read_dir(path.as_path()).await?;
while let Some(entry) = read_dir.next_entry().await? {
@@ -122,16 +313,13 @@ impl ExecutorFileSystem for LocalFileSystem {
Ok(entries)
}
async fn read_directory_with_sandbox_policy(
async fn remove(
&self,
path: &AbsolutePathBuf,
sandbox_policy: Option<&SandboxPolicy>,
) -> FileSystemResult<Vec<ReadDirectoryEntry>> {
enforce_read_access(path, sandbox_policy)?;
self.read_directory(path).await
}
async fn remove(&self, path: &AbsolutePathBuf, options: RemoveOptions) -> FileSystemResult<()> {
options: RemoveOptions,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()> {
reject_sandbox_context(sandbox)?;
match tokio::fs::symlink_metadata(path.as_path()).await {
Ok(metadata) => {
let file_type = metadata.file_type();
@@ -151,22 +339,14 @@ impl ExecutorFileSystem for LocalFileSystem {
}
}
async fn remove_with_sandbox_policy(
&self,
path: &AbsolutePathBuf,
remove_options: RemoveOptions,
sandbox_policy: Option<&SandboxPolicy>,
) -> FileSystemResult<()> {
enforce_write_access_preserving_leaf(path, sandbox_policy)?;
self.remove(path, remove_options).await
}
async fn copy(
&self,
source_path: &AbsolutePathBuf,
destination_path: &AbsolutePathBuf,
options: CopyOptions,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()> {
reject_sandbox_context(sandbox)?;
let source_path = source_path.to_path_buf();
let destination_path = destination_path.to_path_buf();
tokio::task::spawn_blocking(move || -> FileSystemResult<()> {
@@ -211,164 +391,26 @@ impl ExecutorFileSystem for LocalFileSystem {
.await
.map_err(|err| io::Error::other(format!("filesystem task failed: {err}")))?
}
async fn copy_with_sandbox_policy(
&self,
source_path: &AbsolutePathBuf,
destination_path: &AbsolutePathBuf,
copy_options: CopyOptions,
sandbox_policy: Option<&SandboxPolicy>,
) -> FileSystemResult<()> {
enforce_copy_source_read_access(source_path, sandbox_policy)?;
enforce_write_access(destination_path, sandbox_policy)?;
self.copy(source_path, destination_path, copy_options).await
}
}
fn enforce_read_access(
path: &AbsolutePathBuf,
sandbox_policy: Option<&SandboxPolicy>,
) -> FileSystemResult<()> {
enforce_access_for_current_dir(
path,
sandbox_policy,
FileSystemSandboxPolicy::can_read_path_with_cwd,
"read",
AccessPathMode::ResolveAll,
)
}
fn enforce_write_access(
path: &AbsolutePathBuf,
sandbox_policy: Option<&SandboxPolicy>,
) -> FileSystemResult<()> {
enforce_access_for_current_dir(
path,
sandbox_policy,
FileSystemSandboxPolicy::can_write_path_with_cwd,
"write",
AccessPathMode::ResolveAll,
)
}
fn enforce_write_access_preserving_leaf(
path: &AbsolutePathBuf,
sandbox_policy: Option<&SandboxPolicy>,
) -> FileSystemResult<()> {
enforce_access_for_current_dir(
path,
sandbox_policy,
FileSystemSandboxPolicy::can_write_path_with_cwd,
"write",
AccessPathMode::PreserveLeaf,
)
}
fn enforce_copy_source_read_access(
path: &AbsolutePathBuf,
sandbox_policy: Option<&SandboxPolicy>,
) -> FileSystemResult<()> {
let path_mode = match std::fs::symlink_metadata(path.as_path()) {
Ok(metadata) if metadata.file_type().is_symlink() => AccessPathMode::PreserveLeaf,
_ => AccessPathMode::ResolveAll,
};
enforce_access_for_current_dir(
path,
sandbox_policy,
FileSystemSandboxPolicy::can_read_path_with_cwd,
"read",
path_mode,
)
}
#[cfg(all(test, unix))]
fn enforce_read_access_for_cwd(
path: &AbsolutePathBuf,
sandbox_policy: Option<&SandboxPolicy>,
sandbox_cwd: &AbsolutePathBuf,
) -> FileSystemResult<()> {
enforce_access_for_cwd(
path,
sandbox_policy,
sandbox_cwd,
FileSystemSandboxPolicy::can_read_path_with_cwd,
"read",
AccessPathMode::ResolveAll,
)
}
fn enforce_access_for_current_dir(
path: &AbsolutePathBuf,
sandbox_policy: Option<&SandboxPolicy>,
is_allowed: fn(&FileSystemSandboxPolicy, &Path, &Path) -> bool,
access_kind: &str,
path_mode: AccessPathMode,
) -> FileSystemResult<()> {
let Some(sandbox_policy) = sandbox_policy else {
return Ok(());
};
let cwd = current_sandbox_cwd()?;
enforce_access(
path,
sandbox_policy,
cwd.as_path(),
is_allowed,
access_kind,
path_mode,
)
}
#[cfg(all(test, unix))]
fn enforce_access_for_cwd(
path: &AbsolutePathBuf,
sandbox_policy: Option<&SandboxPolicy>,
sandbox_cwd: &AbsolutePathBuf,
is_allowed: fn(&FileSystemSandboxPolicy, &Path, &Path) -> bool,
access_kind: &str,
path_mode: AccessPathMode,
) -> FileSystemResult<()> {
let Some(sandbox_policy) = sandbox_policy else {
return Ok(());
};
let cwd = resolve_existing_path(sandbox_cwd.as_path())?;
enforce_access(
path,
sandbox_policy,
cwd.as_path(),
is_allowed,
access_kind,
path_mode,
)
}
fn enforce_access(
path: &AbsolutePathBuf,
sandbox_policy: &SandboxPolicy,
sandbox_cwd: &Path,
is_allowed: fn(&FileSystemSandboxPolicy, &Path, &Path) -> bool,
access_kind: &str,
path_mode: AccessPathMode,
) -> FileSystemResult<()> {
let resolved_path = resolve_path_for_access_check(path.as_path(), path_mode)?;
let file_system_policy =
canonicalize_file_system_policy_paths(FileSystemSandboxPolicy::from(sandbox_policy))?;
if is_allowed(&file_system_policy, resolved_path.as_path(), sandbox_cwd) {
Ok(())
} else {
Err(io::Error::new(
fn reject_sandbox_context(sandbox: Option<&FileSystemSandboxContext>) -> io::Result<()> {
if sandbox.is_some() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"fs/{access_kind} is not permitted for path {}",
path.as_path().display()
),
))
"direct filesystem operations do not accept sandbox context",
));
}
Ok(())
}
#[derive(Clone, Copy)]
enum AccessPathMode {
ResolveAll,
PreserveLeaf,
fn reject_platform_sandbox_context(sandbox: Option<&FileSystemSandboxContext>) -> io::Result<()> {
if sandbox.is_some_and(FileSystemSandboxContext::should_run_in_sandbox) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"sandboxed filesystem operations require configured runtime paths",
));
}
Ok(())
}
fn copy_dir_recursive(source: &Path, target: &Path) -> io::Result<()> {
@@ -395,28 +437,11 @@ fn destination_is_same_or_descendant_of_source(
destination: &Path,
) -> io::Result<bool> {
let source = std::fs::canonicalize(source)?;
let destination = resolve_path_for_access_check(destination, AccessPathMode::ResolveAll)?;
let destination = resolve_existing_path(destination)?;
Ok(destination.starts_with(&source))
}
fn resolve_path_for_access_check(path: &Path, path_mode: AccessPathMode) -> io::Result<PathBuf> {
match path_mode {
AccessPathMode::ResolveAll => resolve_existing_path(path),
AccessPathMode::PreserveLeaf => preserve_leaf_path_for_access_check(path),
}
}
fn preserve_leaf_path_for_access_check(path: &Path) -> io::Result<PathBuf> {
let Some(file_name) = path.file_name() else {
return resolve_existing_path(path);
};
let parent = path.parent().unwrap_or_else(|| Path::new("/"));
let mut resolved_parent = resolve_existing_path(parent)?;
resolved_parent.push(file_name);
Ok(resolved_parent)
}
fn resolve_existing_path(path: &Path) -> io::Result<PathBuf> {
pub(crate) fn resolve_existing_path(path: &Path) -> io::Result<PathBuf> {
let mut unresolved_suffix = Vec::new();
let mut existing_path = path;
while !existing_path.exists() {
@@ -437,33 +462,12 @@ fn resolve_existing_path(path: &Path) -> io::Result<PathBuf> {
Ok(resolved)
}
fn current_sandbox_cwd() -> io::Result<PathBuf> {
pub(crate) fn current_sandbox_cwd() -> io::Result<PathBuf> {
let cwd = std::env::current_dir()
.map_err(|err| io::Error::other(format!("failed to read current dir: {err}")))?;
resolve_existing_path(cwd.as_path())
}
fn canonicalize_file_system_policy_paths(
mut file_system_policy: FileSystemSandboxPolicy,
) -> io::Result<FileSystemSandboxPolicy> {
for entry in &mut file_system_policy.entries {
if let FileSystemPath::Path { path } = &mut entry.path {
*path = canonicalize_absolute_path(path)?;
}
}
Ok(file_system_policy)
}
fn canonicalize_absolute_path(path: &AbsolutePathBuf) -> io::Result<AbsolutePathBuf> {
let resolved = resolve_existing_path(path.as_path())?;
AbsolutePathBuf::from_absolute_path(resolved.as_path()).map_err(|err| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("path must stay absolute after canonicalization: {err}"),
)
})
}
fn copy_symlink(source: &Path, target: &Path) -> io::Result<()> {
let link_target = std::fs::read_link(source)?;
#[cfg(unix)]
@@ -508,29 +512,11 @@ fn system_time_to_unix_ms(time: SystemTime) -> i64 {
#[cfg(all(test, unix))]
mod tests {
use super::*;
use codex_protocol::protocol::ReadOnlyAccess;
use pretty_assertions::assert_eq;
use std::os::unix::fs::symlink;
fn absolute_path(path: PathBuf) -> AbsolutePathBuf {
match AbsolutePathBuf::try_from(path) {
Ok(path) => path,
Err(err) => panic!("absolute path: {err}"),
}
}
fn read_only_sandbox_policy(readable_roots: Vec<PathBuf>) -> SandboxPolicy {
SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::Restricted {
include_platform_defaults: false,
readable_roots: readable_roots.into_iter().map(absolute_path).collect(),
},
network_access: false,
}
}
#[test]
fn resolve_path_for_access_check_rejects_symlink_parent_dotdot_escape() -> io::Result<()> {
fn resolve_existing_path_handles_symlink_parent_dotdot_escape() -> io::Result<()> {
let temp_dir = tempfile::TempDir::new()?;
let allowed_dir = temp_dir.path().join("allowed");
let outside_dir = temp_dir.path().join("outside");
@@ -538,13 +524,12 @@ mod tests {
std::fs::create_dir_all(&outside_dir)?;
symlink(&outside_dir, allowed_dir.join("link"))?;
let resolved = resolve_path_for_access_check(
let resolved = resolve_existing_path(
allowed_dir
.join("link")
.join("..")
.join("secret.txt")
.as_path(),
AccessPathMode::ResolveAll,
)?;
assert_eq!(
@@ -553,29 +538,6 @@ mod tests {
);
Ok(())
}
#[test]
fn enforce_read_access_uses_explicit_sandbox_cwd() -> io::Result<()> {
let temp_dir = tempfile::TempDir::new()?;
let workspace_dir = temp_dir.path().join("workspace");
let other_dir = temp_dir.path().join("other");
let note_path = workspace_dir.join("note.txt");
std::fs::create_dir_all(&workspace_dir)?;
std::fs::create_dir_all(&other_dir)?;
std::fs::write(&note_path, "hello")?;
let sandbox_policy = read_only_sandbox_policy(vec![]);
let sandbox_cwd = absolute_path(workspace_dir);
let other_cwd = absolute_path(other_dir);
let note_path = absolute_path(note_path);
enforce_read_access_for_cwd(&note_path, Some(&sandbox_policy), &sandbox_cwd)?;
let error = enforce_read_access_for_cwd(&note_path, Some(&sandbox_policy), &other_cwd)
.expect_err("read should be rejected outside provided cwd");
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
Ok(())
}
}
#[cfg(all(test, windows))]