From 17bb4317da82d19ba5156f52ceefe240284a91a0 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Fri, 3 Apr 2026 18:22:30 -0700 Subject: [PATCH] exec-server: sandbox filesystem helper ops --- .../src/protocol/common.rs | 4 +- .../app-server-protocol/src/protocol/v2.rs | 30 + codex-rs/app-server/src/fs_api.rs | 17 +- codex-rs/app-server/tests/suite/v2/fs.rs | 140 +++++ codex-rs/exec-server/Cargo.toml | 4 + .../exec-server/src/bin/codex-exec-server.rs | 25 +- codex-rs/exec-server/src/file_system.rs | 68 ++- codex-rs/exec-server/src/lib.rs | 2 + codex-rs/exec-server/src/local_file_system.rs | 98 ++++ .../exec-server/src/remote_file_system.rs | 93 ++- .../exec-server/src/sandboxed_file_system.rs | 555 ++++++++++++++++++ .../src/server/file_system_handler.rs | 17 +- codex-rs/exec-server/tests/file_system.rs | 101 ++++ 13 files changed, 1130 insertions(+), 24 deletions(-) create mode 100644 codex-rs/exec-server/src/sandboxed_file_system.rs diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index ebd1ba11c5..553b3ae9ac 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1610,6 +1610,7 @@ mod tests { request_id: RequestId::Integer(9), params: v2::FsGetMetadataParams { path: absolute_path("tmp/example"), + sandbox_policy: None, }, }; assert_eq!( @@ -1617,7 +1618,8 @@ mod tests { "method": "fs/getMetadata", "id": 9, "params": { - "path": absolute_path_string("tmp/example") + "path": absolute_path_string("tmp/example"), + "sandboxPolicy": null } }), serde_json::to_value(&request)?, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 82b568515c..9cebbbe7ec 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -43,6 +43,7 @@ use codex_protocol::openai_models::ModelAvailabilityNux as CoreModelAvailability use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::default_input_modalities; use codex_protocol::parse_command::ParsedCommand as CoreParsedCommand; +use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::plan_tool::PlanItemArg as CorePlanItemArg; use codex_protocol::plan_tool::StepStatus as CorePlanStepStatus; use codex_protocol::protocol::AgentStatus as CoreAgentStatus; @@ -2168,6 +2169,9 @@ pub struct FeedbackUploadResponse { pub struct FsReadFileParams { /// Absolute path to read. pub path: AbsolutePathBuf, + /// Optional sandbox policy for re-invoking this filesystem operation under a sandbox. + #[ts(optional = nullable)] + pub sandbox_policy: Option, } /// Base64-encoded file contents returned by `fs/readFile`. @@ -2188,6 +2192,9 @@ pub struct FsWriteFileParams { pub path: AbsolutePathBuf, /// File contents encoded as base64. pub data_base64: String, + /// Optional sandbox policy for re-invoking this filesystem operation under a sandbox. + #[ts(optional = nullable)] + pub sandbox_policy: Option, } /// Successful response for `fs/writeFile`. @@ -2206,6 +2213,9 @@ pub struct FsCreateDirectoryParams { /// Whether parent directories should also be created. Defaults to `true`. #[ts(optional = nullable)] pub recursive: Option, + /// Optional sandbox policy for re-invoking this filesystem operation under a sandbox. + #[ts(optional = nullable)] + pub sandbox_policy: Option, } /// Successful response for `fs/createDirectory`. @@ -2221,6 +2231,9 @@ pub struct FsCreateDirectoryResponse {} pub struct FsGetMetadataParams { /// Absolute path to inspect. pub path: AbsolutePathBuf, + /// Optional sandbox policy for re-invoking this filesystem operation under a sandbox. + #[ts(optional = nullable)] + pub sandbox_policy: Option, } /// Metadata returned by `fs/getMetadata`. @@ -2247,6 +2260,9 @@ pub struct FsGetMetadataResponse { pub struct FsReadDirectoryParams { /// Absolute directory path to read. pub path: AbsolutePathBuf, + /// Optional sandbox policy for re-invoking this filesystem operation under a sandbox. + #[ts(optional = nullable)] + pub sandbox_policy: Option, } /// A directory entry returned by `fs/readDirectory`. @@ -2284,6 +2300,9 @@ pub struct FsRemoveParams { /// Whether missing paths should be ignored. Defaults to `true`. #[ts(optional = nullable)] pub force: Option, + /// Optional sandbox policy for re-invoking this filesystem operation under a sandbox. + #[ts(optional = nullable)] + pub sandbox_policy: Option, } /// Successful response for `fs/remove`. @@ -2304,6 +2323,9 @@ pub struct FsCopyParams { /// Required for directory copies; ignored for file copies. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub recursive: bool, + /// Optional sandbox policy for re-invoking this filesystem operation under a sandbox. + #[ts(optional = nullable)] + pub sandbox_policy: Option, } /// Successful response for `fs/copy`. @@ -6526,6 +6548,7 @@ mod tests { fn fs_read_file_params_round_trip() { let params = FsReadFileParams { path: absolute_path("tmp/example.txt"), + sandbox_policy: None, }; let value = serde_json::to_value(¶ms).expect("serialize fs/readFile params"); @@ -6533,6 +6556,7 @@ mod tests { value, json!({ "path": absolute_path_string("tmp/example.txt"), + "sandboxPolicy": null, }) ); @@ -6546,6 +6570,7 @@ mod tests { let params = FsCreateDirectoryParams { path: absolute_path("tmp/example"), recursive: None, + sandbox_policy: None, }; let value = serde_json::to_value(¶ms).expect("serialize fs/createDirectory params"); @@ -6554,6 +6579,7 @@ mod tests { json!({ "path": absolute_path_string("tmp/example"), "recursive": null, + "sandboxPolicy": null, }) ); @@ -6567,6 +6593,7 @@ mod tests { let params = FsWriteFileParams { path: absolute_path("tmp/example.bin"), data_base64: "AAE=".to_string(), + sandbox_policy: None, }; let value = serde_json::to_value(¶ms).expect("serialize fs/writeFile params"); @@ -6575,6 +6602,7 @@ mod tests { json!({ "path": absolute_path_string("tmp/example.bin"), "dataBase64": "AAE=", + "sandboxPolicy": null, }) ); @@ -6589,6 +6617,7 @@ mod tests { source_path: absolute_path("tmp/source"), destination_path: absolute_path("tmp/destination"), recursive: true, + sandbox_policy: None, }; let value = serde_json::to_value(¶ms).expect("serialize fs/copy params"); @@ -6598,6 +6627,7 @@ mod tests { "sourcePath": absolute_path_string("tmp/source"), "destinationPath": absolute_path_string("tmp/destination"), "recursive": true, + "sandboxPolicy": null, }) ); diff --git a/codex-rs/app-server/src/fs_api.rs b/codex-rs/app-server/src/fs_api.rs index 57b355f818..08ebb254bd 100644 --- a/codex-rs/app-server/src/fs_api.rs +++ b/codex-rs/app-server/src/fs_api.rs @@ -46,7 +46,7 @@ impl FsApi { ) -> Result { let bytes = self .file_system - .read_file(¶ms.path) + .read_file_with_sandbox_policy(¶ms.path, params.sandbox_policy.as_ref()) .await .map_err(map_fs_error)?; Ok(FsReadFileResponse { @@ -64,7 +64,7 @@ impl FsApi { )) })?; self.file_system - .write_file(¶ms.path, bytes) + .write_file_with_sandbox_policy(¶ms.path, bytes, params.sandbox_policy.as_ref()) .await .map_err(map_fs_error)?; Ok(FsWriteFileResponse {}) @@ -75,11 +75,12 @@ impl FsApi { params: FsCreateDirectoryParams, ) -> Result { self.file_system - .create_directory( + .create_directory_with_sandbox_policy( ¶ms.path, CreateDirectoryOptions { recursive: params.recursive.unwrap_or(true), }, + params.sandbox_policy.as_ref(), ) .await .map_err(map_fs_error)?; @@ -92,7 +93,7 @@ impl FsApi { ) -> Result { let metadata = self .file_system - .get_metadata(¶ms.path) + .get_metadata_with_sandbox_policy(¶ms.path, params.sandbox_policy.as_ref()) .await .map_err(map_fs_error)?; Ok(FsGetMetadataResponse { @@ -109,7 +110,7 @@ impl FsApi { ) -> Result { let entries = self .file_system - .read_directory(¶ms.path) + .read_directory_with_sandbox_policy(¶ms.path, params.sandbox_policy.as_ref()) .await .map_err(map_fs_error)?; Ok(FsReadDirectoryResponse { @@ -129,12 +130,13 @@ impl FsApi { params: FsRemoveParams, ) -> Result { self.file_system - .remove( + .remove_with_sandbox_policy( ¶ms.path, RemoveOptions { recursive: params.recursive.unwrap_or(true), force: params.force.unwrap_or(true), }, + params.sandbox_policy.as_ref(), ) .await .map_err(map_fs_error)?; @@ -146,12 +148,13 @@ impl FsApi { params: FsCopyParams, ) -> Result { self.file_system - .copy( + .copy_with_sandbox_policy( ¶ms.source_path, ¶ms.destination_path, CopyOptions { recursive: params.recursive, }, + params.sandbox_policy.as_ref(), ) .await .map_err(map_fs_error)?; diff --git a/codex-rs/app-server/tests/suite/v2/fs.rs b/codex-rs/app-server/tests/suite/v2/fs.rs index e113904809..d1a75d61d2 100644 --- a/codex-rs/app-server/tests/suite/v2/fs.rs +++ b/codex-rs/app-server/tests/suite/v2/fs.rs @@ -14,10 +14,13 @@ use codex_app_server_protocol::FsWatchResponse; use codex_app_server_protocol::FsWriteFileParams; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::RequestId; +use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_cargo_bin::cargo_bin; use pretty_assertions::assert_eq; use serde_json::json; use std::path::PathBuf; +use std::sync::Mutex; use tempfile::TempDir; use tokio::time::Duration; use tokio::time::timeout; @@ -30,6 +33,50 @@ use std::os::unix::fs::symlink; use std::process::Command; const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); +static EXEC_SERVER_SELF_EXE_ENV_LOCK: Mutex<()> = Mutex::new(()); + +struct SandboxHelperEnvGuard { + previous_exec_server_self_exe: Option, + previous_linux_sandbox_exe: Option, +} + +impl SandboxHelperEnvGuard { + fn install() -> Result { + let exec_server_binary = cargo_bin("codex-exec-server")?; + let linux_sandbox_binary = cargo_bin("codex-linux-sandbox")?; + let previous_exec_server_self_exe = std::env::var_os("CODEX_EXEC_SERVER_SELF_EXE"); + let previous_linux_sandbox_exe = std::env::var_os("CODEX_LINUX_SANDBOX_EXE"); + unsafe { + std::env::set_var("CODEX_EXEC_SERVER_SELF_EXE", &exec_server_binary); + std::env::set_var("CODEX_LINUX_SANDBOX_EXE", &linux_sandbox_binary); + } + Ok(Self { + previous_exec_server_self_exe, + previous_linux_sandbox_exe, + }) + } +} + +impl Drop for SandboxHelperEnvGuard { + fn drop(&mut self) { + match self.previous_exec_server_self_exe.as_ref() { + Some(previous) => unsafe { + std::env::set_var("CODEX_EXEC_SERVER_SELF_EXE", previous); + }, + None => unsafe { + std::env::remove_var("CODEX_EXEC_SERVER_SELF_EXE"); + }, + } + match self.previous_linux_sandbox_exe.as_ref() { + Some(previous) => unsafe { + std::env::set_var("CODEX_LINUX_SANDBOX_EXE", previous); + }, + None => unsafe { + std::env::remove_var("CODEX_LINUX_SANDBOX_EXE"); + }, + } + } +} async fn initialized_mcp(codex_home: &TempDir) -> Result { let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -61,6 +108,10 @@ fn absolute_path(path: PathBuf) -> AbsolutePathBuf { AbsolutePathBuf::try_from(path).expect("path should be absolute") } +fn unrestricted_sandbox_policy() -> FileSystemSandboxPolicy { + FileSystemSandboxPolicy::unrestricted() +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fs_get_metadata_returns_only_used_fields() -> Result<()> { let codex_home = TempDir::new()?; @@ -71,6 +122,7 @@ async fn fs_get_metadata_returns_only_used_fields() -> Result<()> { let request_id = mcp .send_fs_get_metadata_request(codex_app_server_protocol::FsGetMetadataParams { path: absolute_path(file_path.clone()), + sandbox_policy: None, }) .await?; let response = timeout( @@ -129,6 +181,7 @@ async fn fs_methods_cover_current_fs_utils_surface() -> Result<()> { .send_fs_create_directory_request(codex_app_server_protocol::FsCreateDirectoryParams { path: absolute_path(nested_dir.clone()), recursive: None, + sandbox_policy: None, }) .await?; timeout( @@ -141,6 +194,7 @@ async fn fs_methods_cover_current_fs_utils_surface() -> Result<()> { .send_fs_write_file_request(FsWriteFileParams { path: absolute_path(nested_file.clone()), data_base64: STANDARD.encode("hello from app-server"), + sandbox_policy: None, }) .await?; timeout( @@ -153,6 +207,7 @@ async fn fs_methods_cover_current_fs_utils_surface() -> Result<()> { .send_fs_write_file_request(FsWriteFileParams { path: absolute_path(source_file.clone()), data_base64: STANDARD.encode("hello from source root"), + sandbox_policy: None, }) .await?; timeout( @@ -164,6 +219,7 @@ async fn fs_methods_cover_current_fs_utils_surface() -> Result<()> { let read_request_id = mcp .send_fs_read_file_request(codex_app_server_protocol::FsReadFileParams { path: absolute_path(nested_file.clone()), + sandbox_policy: None, }) .await?; let read_response: FsReadFileResponse = to_response( @@ -185,6 +241,7 @@ async fn fs_methods_cover_current_fs_utils_surface() -> Result<()> { source_path: absolute_path(nested_file.clone()), destination_path: absolute_path(copy_file_path.clone()), recursive: false, + sandbox_policy: None, }) .await?; timeout( @@ -202,6 +259,7 @@ async fn fs_methods_cover_current_fs_utils_surface() -> Result<()> { source_path: absolute_path(source_dir.clone()), destination_path: absolute_path(copied_dir.clone()), recursive: true, + sandbox_policy: None, }) .await?; timeout( @@ -217,6 +275,7 @@ async fn fs_methods_cover_current_fs_utils_surface() -> Result<()> { let read_directory_request_id = mcp .send_fs_read_directory_request(codex_app_server_protocol::FsReadDirectoryParams { path: absolute_path(source_dir.clone()), + sandbox_policy: None, }) .await?; let readdir_response = timeout( @@ -249,6 +308,7 @@ async fn fs_methods_cover_current_fs_utils_surface() -> Result<()> { path: absolute_path(copied_dir.clone()), recursive: None, force: None, + sandbox_policy: None, }) .await?; timeout( @@ -275,6 +335,7 @@ async fn fs_write_file_accepts_base64_bytes() -> Result<()> { .send_fs_write_file_request(FsWriteFileParams { path: absolute_path(file_path.clone()), data_base64: STANDARD.encode(bytes), + sandbox_policy: None, }) .await?; timeout( @@ -287,6 +348,7 @@ async fn fs_write_file_accepts_base64_bytes() -> Result<()> { let read_request_id = mcp .send_fs_read_file_request(codex_app_server_protocol::FsReadFileParams { path: absolute_path(file_path), + sandbox_policy: None, }) .await?; let read_response: FsReadFileResponse = to_response( @@ -306,6 +368,78 @@ async fn fs_write_file_accepts_base64_bytes() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fs_methods_support_sandbox_policy() -> Result<()> { + let _lock = EXEC_SERVER_SELF_EXE_ENV_LOCK + .lock() + .expect("lock exec-server path env"); + let _env_guard = SandboxHelperEnvGuard::install()?; + + let codex_home = TempDir::new()?; + let file_path = codex_home.path().join("sandboxed.txt"); + let copied_path = codex_home.path().join("sandboxed-copy.txt"); + let policy = unrestricted_sandbox_policy(); + + let result = async { + let mut mcp = initialized_mcp(&codex_home).await?; + let write_request_id = mcp + .send_fs_write_file_request(FsWriteFileParams { + path: absolute_path(file_path.clone()), + data_base64: STANDARD.encode("hello from sandbox policy"), + sandbox_policy: Some(policy.clone()), + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(write_request_id)), + ) + .await??; + + let read_request_id = mcp + .send_fs_read_file_request(codex_app_server_protocol::FsReadFileParams { + path: absolute_path(file_path.clone()), + sandbox_policy: Some(policy.clone()), + }) + .await?; + let read_response: FsReadFileResponse = to_response( + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_request_id)), + ) + .await??, + )?; + assert_eq!( + read_response, + FsReadFileResponse { + data_base64: STANDARD.encode("hello from sandbox policy"), + } + ); + + let copy_request_id = mcp + .send_fs_copy_request(FsCopyParams { + source_path: absolute_path(file_path), + destination_path: absolute_path(copied_path.clone()), + recursive: false, + sandbox_policy: Some(policy), + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(copy_request_id)), + ) + .await??; + assert_eq!( + std::fs::read_to_string(copied_path)?, + "hello from sandbox policy" + ); + + Ok::<(), anyhow::Error>(()) + } + .await; + + result +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fs_write_file_rejects_invalid_base64() -> Result<()> { let codex_home = TempDir::new()?; @@ -316,6 +450,7 @@ async fn fs_write_file_rejects_invalid_base64() -> Result<()> { .send_fs_write_file_request(FsWriteFileParams { path: absolute_path(file_path), data_base64: "%%%".to_string(), + sandbox_policy: None, }) .await?; let error = timeout( @@ -471,6 +606,7 @@ async fn fs_copy_rejects_directory_without_recursive() -> Result<()> { source_path: absolute_path(source_dir), destination_path: absolute_path(codex_home.path().join("dest")), recursive: false, + sandbox_policy: None, }) .await?; let error = timeout( @@ -498,6 +634,7 @@ async fn fs_copy_rejects_copying_directory_into_descendant() -> Result<()> { source_path: absolute_path(source_dir.clone()), destination_path: absolute_path(source_dir.join("nested").join("copy")), recursive: true, + sandbox_policy: None, }) .await?; let error = timeout( @@ -529,6 +666,7 @@ async fn fs_copy_preserves_symlinks_in_recursive_copy() -> Result<()> { source_path: absolute_path(source_dir), destination_path: absolute_path(copied_dir.clone()), recursive: true, + sandbox_policy: None, }) .await?; timeout( @@ -569,6 +707,7 @@ async fn fs_copy_ignores_unknown_special_files_in_recursive_copy() -> Result<()> source_path: absolute_path(source_dir), destination_path: absolute_path(copied_dir.clone()), recursive: true, + sandbox_policy: None, }) .await?; timeout( @@ -606,6 +745,7 @@ async fn fs_copy_rejects_standalone_fifo_source() -> Result<()> { source_path: absolute_path(fifo_path), destination_path: absolute_path(codex_home.path().join("copied")), recursive: false, + sandbox_policy: None, }) .await?; expect_error_message( diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index f064a82dca..38378f9db6 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -15,11 +15,14 @@ path = "src/bin/codex-exec-server.rs" workspace = true [dependencies] +anyhow = { workspace = true } arc-swap = { workspace = true } async-trait = { workspace = true } base64 = { workspace = true } clap = { workspace = true, features = ["derive"] } codex-app-server-protocol = { workspace = true } +codex-protocol = { workspace = true } +codex-sandboxing = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-pty = { workspace = true } futures = { workspace = true } @@ -39,6 +42,7 @@ tokio = { workspace = true, features = [ ] } tokio-tungstenite = { workspace = true } tracing = { workspace = true } +which = { workspace = true } [dev-dependencies] anyhow = { workspace = true } diff --git a/codex-rs/exec-server/src/bin/codex-exec-server.rs b/codex-rs/exec-server/src/bin/codex-exec-server.rs index 82fa9ec00f..109fab72bb 100644 --- a/codex-rs/exec-server/src/bin/codex-exec-server.rs +++ b/codex-rs/exec-server/src/bin/codex-exec-server.rs @@ -9,10 +9,27 @@ struct ExecServerArgs { default_value = codex_exec_server::DEFAULT_LISTEN_URL )] listen: String, + // Hidden entrypoint used by LocalFileSystem to re-run the same fs logic + // in a one-shot helper process after sandbox transformation. + #[arg(long = "internal-fs-op", hide = true)] + internal_fs_op: bool, } -#[tokio::main] -async fn main() -> Result<(), Box> { - let args = ExecServerArgs::parse(); - codex_exec_server::run_main_with_listen_url(&args.listen).await +fn main() -> anyhow::Result<()> { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + runtime.block_on(async move { + let args = ExecServerArgs::parse(); + if args.internal_fs_op { + // The helper mode reads a single structured fs request from stdin, + // executes it, writes the response to stdout, and exits. + codex_exec_server::run_internal_fs_op().await?; + } else { + codex_exec_server::run_main_with_listen_url(&args.listen) + .await + .map_err(|err| anyhow::anyhow!(err.to_string()))?; + } + Ok(()) + }) } diff --git a/codex-rs/exec-server/src/file_system.rs b/codex-rs/exec-server/src/file_system.rs index 35c2243f8e..2fe39d6f78 100644 --- a/codex-rs/exec-server/src/file_system.rs +++ b/codex-rs/exec-server/src/file_system.rs @@ -1,5 +1,8 @@ use async_trait::async_trait; +use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; +use serde::Deserialize; +use serde::Serialize; use tokio::io; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -18,7 +21,7 @@ pub struct CopyOptions { pub recursive: bool, } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct FileMetadata { pub is_directory: bool, pub is_file: bool, @@ -26,7 +29,7 @@ pub struct FileMetadata { pub modified_at_ms: i64, } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct ReadDirectoryEntry { pub file_name: String, pub is_directory: bool, @@ -39,27 +42,88 @@ pub type FileSystemResult = io::Result; pub trait ExecutorFileSystem: Send + Sync { async fn read_file(&self, path: &AbsolutePathBuf) -> FileSystemResult>; + async fn read_file_with_sandbox_policy( + &self, + path: &AbsolutePathBuf, + _sandbox_policy: Option<&FileSystemSandboxPolicy>, + ) -> FileSystemResult> { + self.read_file(path).await + } + async fn write_file(&self, path: &AbsolutePathBuf, contents: Vec) -> FileSystemResult<()>; + async fn write_file_with_sandbox_policy( + &self, + path: &AbsolutePathBuf, + contents: Vec, + _sandbox_policy: Option<&FileSystemSandboxPolicy>, + ) -> FileSystemResult<()> { + self.write_file(path, contents).await + } + async fn create_directory( &self, path: &AbsolutePathBuf, options: CreateDirectoryOptions, ) -> FileSystemResult<()>; + async fn create_directory_with_sandbox_policy( + &self, + path: &AbsolutePathBuf, + options: CreateDirectoryOptions, + _sandbox_policy: Option<&FileSystemSandboxPolicy>, + ) -> FileSystemResult<()> { + self.create_directory(path, options).await + } + async fn get_metadata(&self, path: &AbsolutePathBuf) -> FileSystemResult; + async fn get_metadata_with_sandbox_policy( + &self, + path: &AbsolutePathBuf, + _sandbox_policy: Option<&FileSystemSandboxPolicy>, + ) -> FileSystemResult { + self.get_metadata(path).await + } + async fn read_directory( &self, path: &AbsolutePathBuf, ) -> FileSystemResult>; + async fn read_directory_with_sandbox_policy( + &self, + path: &AbsolutePathBuf, + _sandbox_policy: Option<&FileSystemSandboxPolicy>, + ) -> FileSystemResult> { + self.read_directory(path).await + } + async fn remove(&self, path: &AbsolutePathBuf, options: RemoveOptions) -> FileSystemResult<()>; + async fn remove_with_sandbox_policy( + &self, + path: &AbsolutePathBuf, + options: RemoveOptions, + _sandbox_policy: Option<&FileSystemSandboxPolicy>, + ) -> FileSystemResult<()> { + self.remove(path, options).await + } + async fn copy( &self, source_path: &AbsolutePathBuf, destination_path: &AbsolutePathBuf, options: CopyOptions, ) -> FileSystemResult<()>; + + async fn copy_with_sandbox_policy( + &self, + source_path: &AbsolutePathBuf, + destination_path: &AbsolutePathBuf, + options: CopyOptions, + _sandbox_policy: Option<&FileSystemSandboxPolicy>, + ) -> FileSystemResult<()> { + self.copy(source_path, destination_path, options).await + } } diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index 77834c6143..069604e441 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -11,6 +11,7 @@ mod protocol; mod remote_file_system; mod remote_process; mod rpc; +mod sandboxed_file_system; mod server; pub use client::ExecServerClient; @@ -61,6 +62,7 @@ pub use protocol::TerminateResponse; pub use protocol::WriteParams; pub use protocol::WriteResponse; pub use protocol::WriteStatus; +pub use sandboxed_file_system::run_internal_fs_op; pub use server::DEFAULT_LISTEN_URL; pub use server::ExecServerListenUrlParseError; pub use server::run_main; diff --git a/codex-rs/exec-server/src/local_file_system.rs b/codex-rs/exec-server/src/local_file_system.rs index fba7efa306..69e62bed62 100644 --- a/codex-rs/exec-server/src/local_file_system.rs +++ b/codex-rs/exec-server/src/local_file_system.rs @@ -1,4 +1,5 @@ use async_trait::async_trait; +use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use std::path::Component; use std::path::Path; @@ -14,6 +15,7 @@ use crate::FileMetadata; use crate::FileSystemResult; use crate::ReadDirectoryEntry; use crate::RemoveOptions; +use crate::sandboxed_file_system::SandboxedFileSystem; const MAX_READ_FILE_BYTES: u64 = 512 * 1024 * 1024; @@ -33,10 +35,37 @@ impl ExecutorFileSystem for LocalFileSystem { tokio::fs::read(path.as_path()).await } + async fn read_file_with_sandbox_policy( + &self, + path: &AbsolutePathBuf, + sandbox_policy: Option<&FileSystemSandboxPolicy>, + ) -> FileSystemResult> { + if let Some(sandbox_policy) = sandbox_policy { + return SandboxedFileSystem::new(sandbox_policy.clone()) + .read_file(path) + .await; + } + self.read_file(path).await + } + async fn write_file(&self, path: &AbsolutePathBuf, contents: Vec) -> FileSystemResult<()> { tokio::fs::write(path.as_path(), contents).await } + async fn write_file_with_sandbox_policy( + &self, + path: &AbsolutePathBuf, + contents: Vec, + sandbox_policy: Option<&FileSystemSandboxPolicy>, + ) -> FileSystemResult<()> { + if let Some(sandbox_policy) = sandbox_policy { + return SandboxedFileSystem::new(sandbox_policy.clone()) + .write_file(path, contents) + .await; + } + self.write_file(path, contents).await + } + async fn create_directory( &self, path: &AbsolutePathBuf, @@ -50,6 +79,20 @@ impl ExecutorFileSystem for LocalFileSystem { Ok(()) } + async fn create_directory_with_sandbox_policy( + &self, + path: &AbsolutePathBuf, + options: CreateDirectoryOptions, + sandbox_policy: Option<&FileSystemSandboxPolicy>, + ) -> FileSystemResult<()> { + if let Some(sandbox_policy) = sandbox_policy { + return SandboxedFileSystem::new(sandbox_policy.clone()) + .create_directory(path, options) + .await; + } + self.create_directory(path, options).await + } + async fn get_metadata(&self, path: &AbsolutePathBuf) -> FileSystemResult { let metadata = tokio::fs::metadata(path.as_path()).await?; Ok(FileMetadata { @@ -60,6 +103,19 @@ impl ExecutorFileSystem for LocalFileSystem { }) } + async fn get_metadata_with_sandbox_policy( + &self, + path: &AbsolutePathBuf, + sandbox_policy: Option<&FileSystemSandboxPolicy>, + ) -> FileSystemResult { + if let Some(sandbox_policy) = sandbox_policy { + return SandboxedFileSystem::new(sandbox_policy.clone()) + .get_metadata(path) + .await; + } + self.get_metadata(path).await + } + async fn read_directory( &self, path: &AbsolutePathBuf, @@ -77,6 +133,19 @@ impl ExecutorFileSystem for LocalFileSystem { Ok(entries) } + async fn read_directory_with_sandbox_policy( + &self, + path: &AbsolutePathBuf, + sandbox_policy: Option<&FileSystemSandboxPolicy>, + ) -> FileSystemResult> { + if let Some(sandbox_policy) = sandbox_policy { + return SandboxedFileSystem::new(sandbox_policy.clone()) + .read_directory(path) + .await; + } + self.read_directory(path).await + } + async fn remove(&self, path: &AbsolutePathBuf, options: RemoveOptions) -> FileSystemResult<()> { match tokio::fs::symlink_metadata(path.as_path()).await { Ok(metadata) => { @@ -97,6 +166,20 @@ impl ExecutorFileSystem for LocalFileSystem { } } + async fn remove_with_sandbox_policy( + &self, + path: &AbsolutePathBuf, + options: RemoveOptions, + sandbox_policy: Option<&FileSystemSandboxPolicy>, + ) -> FileSystemResult<()> { + if let Some(sandbox_policy) = sandbox_policy { + return SandboxedFileSystem::new(sandbox_policy.clone()) + .remove(path, options) + .await; + } + self.remove(path, options).await + } + async fn copy( &self, source_path: &AbsolutePathBuf, @@ -147,6 +230,21 @@ 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, + options: CopyOptions, + sandbox_policy: Option<&FileSystemSandboxPolicy>, + ) -> FileSystemResult<()> { + if let Some(sandbox_policy) = sandbox_policy { + return SandboxedFileSystem::new(sandbox_policy.clone()) + .copy(source_path, destination_path, options) + .await; + } + self.copy(source_path, destination_path, options).await + } } fn copy_dir_recursive(source: &Path, target: &Path) -> io::Result<()> { diff --git a/codex-rs/exec-server/src/remote_file_system.rs b/codex-rs/exec-server/src/remote_file_system.rs index 27e4fee911..fde4502422 100644 --- a/codex-rs/exec-server/src/remote_file_system.rs +++ b/codex-rs/exec-server/src/remote_file_system.rs @@ -8,6 +8,7 @@ use codex_app_server_protocol::FsReadDirectoryParams; use codex_app_server_protocol::FsReadFileParams; use codex_app_server_protocol::FsRemoveParams; use codex_app_server_protocol::FsWriteFileParams; +use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use tokio::io; use tracing::trace; @@ -39,10 +40,22 @@ impl RemoteFileSystem { #[async_trait] impl ExecutorFileSystem for RemoteFileSystem { async fn read_file(&self, path: &AbsolutePathBuf) -> FileSystemResult> { + self.read_file_with_sandbox_policy(path, /*sandbox_policy*/ None) + .await + } + + async fn read_file_with_sandbox_policy( + &self, + path: &AbsolutePathBuf, + sandbox_policy: Option<&FileSystemSandboxPolicy>, + ) -> FileSystemResult> { trace!("remote fs read_file"); let response = self .client - .fs_read_file(FsReadFileParams { path: path.clone() }) + .fs_read_file(FsReadFileParams { + path: path.clone(), + sandbox_policy: sandbox_policy.cloned(), + }) .await .map_err(map_remote_error)?; STANDARD.decode(response.data_base64).map_err(|err| { @@ -54,11 +67,22 @@ impl ExecutorFileSystem for RemoteFileSystem { } async fn write_file(&self, path: &AbsolutePathBuf, contents: Vec) -> FileSystemResult<()> { + self.write_file_with_sandbox_policy(path, contents, /*sandbox_policy*/ None) + .await + } + + async fn write_file_with_sandbox_policy( + &self, + path: &AbsolutePathBuf, + contents: Vec, + sandbox_policy: Option<&FileSystemSandboxPolicy>, + ) -> FileSystemResult<()> { trace!("remote fs write_file"); self.client .fs_write_file(FsWriteFileParams { path: path.clone(), data_base64: STANDARD.encode(contents), + sandbox_policy: sandbox_policy.cloned(), }) .await .map_err(map_remote_error)?; @@ -69,12 +93,23 @@ impl ExecutorFileSystem for RemoteFileSystem { &self, path: &AbsolutePathBuf, options: CreateDirectoryOptions, + ) -> FileSystemResult<()> { + self.create_directory_with_sandbox_policy(path, options, /*sandbox_policy*/ None) + .await + } + + async fn create_directory_with_sandbox_policy( + &self, + path: &AbsolutePathBuf, + options: CreateDirectoryOptions, + sandbox_policy: Option<&FileSystemSandboxPolicy>, ) -> FileSystemResult<()> { trace!("remote fs create_directory"); self.client .fs_create_directory(FsCreateDirectoryParams { path: path.clone(), recursive: Some(options.recursive), + sandbox_policy: sandbox_policy.cloned(), }) .await .map_err(map_remote_error)?; @@ -82,10 +117,22 @@ impl ExecutorFileSystem for RemoteFileSystem { } async fn get_metadata(&self, path: &AbsolutePathBuf) -> FileSystemResult { + self.get_metadata_with_sandbox_policy(path, /*sandbox_policy*/ None) + .await + } + + async fn get_metadata_with_sandbox_policy( + &self, + path: &AbsolutePathBuf, + sandbox_policy: Option<&FileSystemSandboxPolicy>, + ) -> FileSystemResult { trace!("remote fs get_metadata"); let response = self .client - .fs_get_metadata(FsGetMetadataParams { path: path.clone() }) + .fs_get_metadata(FsGetMetadataParams { + path: path.clone(), + sandbox_policy: sandbox_policy.cloned(), + }) .await .map_err(map_remote_error)?; Ok(FileMetadata { @@ -99,11 +146,23 @@ impl ExecutorFileSystem for RemoteFileSystem { async fn read_directory( &self, path: &AbsolutePathBuf, + ) -> FileSystemResult> { + self.read_directory_with_sandbox_policy(path, /*sandbox_policy*/ None) + .await + } + + async fn read_directory_with_sandbox_policy( + &self, + path: &AbsolutePathBuf, + sandbox_policy: Option<&FileSystemSandboxPolicy>, ) -> FileSystemResult> { trace!("remote fs read_directory"); let response = self .client - .fs_read_directory(FsReadDirectoryParams { path: path.clone() }) + .fs_read_directory(FsReadDirectoryParams { + path: path.clone(), + sandbox_policy: sandbox_policy.cloned(), + }) .await .map_err(map_remote_error)?; Ok(response @@ -118,12 +177,23 @@ impl ExecutorFileSystem for RemoteFileSystem { } async fn remove(&self, path: &AbsolutePathBuf, options: RemoveOptions) -> FileSystemResult<()> { + self.remove_with_sandbox_policy(path, options, /*sandbox_policy*/ None) + .await + } + + async fn remove_with_sandbox_policy( + &self, + path: &AbsolutePathBuf, + options: RemoveOptions, + sandbox_policy: Option<&FileSystemSandboxPolicy>, + ) -> FileSystemResult<()> { trace!("remote fs remove"); self.client .fs_remove(FsRemoveParams { path: path.clone(), recursive: Some(options.recursive), force: Some(options.force), + sandbox_policy: sandbox_policy.cloned(), }) .await .map_err(map_remote_error)?; @@ -135,6 +205,22 @@ impl ExecutorFileSystem for RemoteFileSystem { source_path: &AbsolutePathBuf, destination_path: &AbsolutePathBuf, options: CopyOptions, + ) -> FileSystemResult<()> { + self.copy_with_sandbox_policy( + source_path, + destination_path, + options, + /*sandbox_policy*/ None, + ) + .await + } + + async fn copy_with_sandbox_policy( + &self, + source_path: &AbsolutePathBuf, + destination_path: &AbsolutePathBuf, + options: CopyOptions, + sandbox_policy: Option<&FileSystemSandboxPolicy>, ) -> FileSystemResult<()> { trace!("remote fs copy"); self.client @@ -142,6 +228,7 @@ impl ExecutorFileSystem for RemoteFileSystem { source_path: source_path.clone(), destination_path: destination_path.clone(), recursive: options.recursive, + sandbox_policy: sandbox_policy.cloned(), }) .await .map_err(map_remote_error)?; diff --git a/codex-rs/exec-server/src/sandboxed_file_system.rs b/codex-rs/exec-server/src/sandboxed_file_system.rs new file mode 100644 index 0000000000..2385140d4d --- /dev/null +++ b/codex-rs/exec-server/src/sandboxed_file_system.rs @@ -0,0 +1,555 @@ +use std::collections::HashMap; +use std::io; +use std::path::Path; +use std::path::PathBuf; +use std::process::Stdio; + +use async_trait::async_trait; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::NetworkAccess; +use codex_protocol::protocol::SandboxPolicy; +use codex_sandboxing::SandboxCommand; +use codex_sandboxing::SandboxExecRequest; +use codex_sandboxing::SandboxManager; +use codex_sandboxing::SandboxTransformRequest; +use codex_utils_absolute_path::AbsolutePathBuf; +use serde::Deserialize; +use serde::Serialize; +use tokio::io::AsyncReadExt; +use tokio::io::AsyncWriteExt; +use tokio::process::Command; + +use crate::CopyOptions; +use crate::CreateDirectoryOptions; +use crate::ExecutorFileSystem; +use crate::FileMetadata; +use crate::FileSystemResult; +use crate::ReadDirectoryEntry; +use crate::RemoveOptions; +use crate::local_file_system::LocalFileSystem; + +const INTERNAL_FS_OP_FLAG: &str = "--internal-fs-op"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +enum SandboxedFileSystemRequest { + ReadFile { + path: AbsolutePathBuf, + }, + WriteFile { + path: AbsolutePathBuf, + data_base64: String, + }, + CreateDirectory { + path: AbsolutePathBuf, + recursive: bool, + }, + GetMetadata { + path: AbsolutePathBuf, + }, + ReadDirectory { + path: AbsolutePathBuf, + }, + Remove { + path: AbsolutePathBuf, + recursive: bool, + force: bool, + }, + Copy { + source_path: AbsolutePathBuf, + destination_path: AbsolutePathBuf, + recursive: bool, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +enum SandboxedFileSystemResponse { + Unit, + ReadFile { data_base64: String }, + GetMetadata { metadata: FileMetadata }, + ReadDirectory { entries: Vec }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +enum SandboxedFileSystemEnvelope { + Ok(SandboxedFileSystemResponse), + Error(SandboxedFileSystemError), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SandboxedFileSystemError { + kind: SandboxedFileSystemErrorKind, + message: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +enum SandboxedFileSystemErrorKind { + NotFound, + PermissionDenied, + AlreadyExists, + InvalidInput, + Unsupported, + Other, +} + +impl SandboxedFileSystemErrorKind { + fn from_io_kind(kind: io::ErrorKind) -> Self { + match kind { + io::ErrorKind::NotFound => Self::NotFound, + io::ErrorKind::PermissionDenied => Self::PermissionDenied, + io::ErrorKind::AlreadyExists => Self::AlreadyExists, + io::ErrorKind::InvalidInput => Self::InvalidInput, + io::ErrorKind::Unsupported => Self::Unsupported, + _ => Self::Other, + } + } + + fn to_io_kind(self) -> io::ErrorKind { + match self { + Self::NotFound => io::ErrorKind::NotFound, + Self::PermissionDenied => io::ErrorKind::PermissionDenied, + Self::AlreadyExists => io::ErrorKind::AlreadyExists, + Self::InvalidInput => io::ErrorKind::InvalidInput, + Self::Unsupported => io::ErrorKind::Unsupported, + Self::Other => io::ErrorKind::Other, + } + } +} + +#[derive(Clone)] +pub(crate) struct SandboxedFileSystem { + sandbox_policy: FileSystemSandboxPolicy, +} + +impl SandboxedFileSystem { + pub(crate) fn new(sandbox_policy: FileSystemSandboxPolicy) -> Self { + Self { sandbox_policy } + } + + async fn execute_request_via_sandbox( + &self, + request: SandboxedFileSystemRequest, + ) -> FileSystemResult { + let prepared = prepare_helper_request(&self.sandbox_policy)?; + let request_bytes = serde_json::to_vec(&request).map_err(io::Error::other)?; + let (program, args) = prepared.command.split_first().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "helper command is empty") + })?; + + let mut command = Command::new(program); + command.args(args); + command.current_dir(&prepared.cwd); + command.envs(&prepared.env); + command.stdin(Stdio::piped()); + command.stdout(Stdio::piped()); + command.stderr(Stdio::piped()); + #[cfg(unix)] + { + if let Some(arg0) = prepared.arg0.as_ref() { + command.arg0(arg0); + } + } + + let mut child = command.spawn()?; + let Some(mut stdin) = child.stdin.take() else { + return Err(io::Error::other("sandbox helper stdin was not piped")); + }; + stdin.write_all(&request_bytes).await?; + drop(stdin); + + let output = child.wait_with_output().await?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let message = if !stderr.trim().is_empty() { + stderr.trim().to_string() + } else if !stdout.trim().is_empty() { + stdout.trim().to_string() + } else { + "sandbox helper exited without error output".to_string() + }; + return Err(io::Error::other(message)); + } + + let envelope: SandboxedFileSystemEnvelope = + serde_json::from_slice(&output.stdout).map_err(io::Error::other)?; + match envelope { + SandboxedFileSystemEnvelope::Ok(response) => Ok(response), + SandboxedFileSystemEnvelope::Error(error) => { + Err(io::Error::new(error.kind.to_io_kind(), error.message)) + } + } + } +} + +#[async_trait] +impl ExecutorFileSystem for SandboxedFileSystem { + async fn read_file(&self, path: &AbsolutePathBuf) -> FileSystemResult> { + let response = self + .execute_request_via_sandbox(SandboxedFileSystemRequest::ReadFile { + path: path.clone(), + }) + .await?; + let SandboxedFileSystemResponse::ReadFile { data_base64 } = response else { + return Err(io::Error::other( + "sandbox helper returned unexpected response for read_file", + )); + }; + STANDARD.decode(data_base64).map_err(|err| { + io::Error::other(format!("sandbox helper returned invalid base64: {err}")) + }) + } + + async fn write_file(&self, path: &AbsolutePathBuf, contents: Vec) -> FileSystemResult<()> { + let response = self + .execute_request_via_sandbox(SandboxedFileSystemRequest::WriteFile { + path: path.clone(), + data_base64: STANDARD.encode(contents), + }) + .await?; + if !matches!(response, SandboxedFileSystemResponse::Unit) { + return Err(io::Error::other( + "sandbox helper returned unexpected response for write_file", + )); + } + Ok(()) + } + + async fn create_directory( + &self, + path: &AbsolutePathBuf, + options: CreateDirectoryOptions, + ) -> FileSystemResult<()> { + let response = self + .execute_request_via_sandbox(SandboxedFileSystemRequest::CreateDirectory { + path: path.clone(), + recursive: options.recursive, + }) + .await?; + if !matches!(response, SandboxedFileSystemResponse::Unit) { + return Err(io::Error::other( + "sandbox helper returned unexpected response for create_directory", + )); + } + Ok(()) + } + + async fn get_metadata(&self, path: &AbsolutePathBuf) -> FileSystemResult { + let response = self + .execute_request_via_sandbox(SandboxedFileSystemRequest::GetMetadata { + path: path.clone(), + }) + .await?; + let SandboxedFileSystemResponse::GetMetadata { metadata } = response else { + return Err(io::Error::other( + "sandbox helper returned unexpected response for get_metadata", + )); + }; + Ok(metadata) + } + + async fn read_directory( + &self, + path: &AbsolutePathBuf, + ) -> FileSystemResult> { + let response = self + .execute_request_via_sandbox(SandboxedFileSystemRequest::ReadDirectory { + path: path.clone(), + }) + .await?; + let SandboxedFileSystemResponse::ReadDirectory { entries } = response else { + return Err(io::Error::other( + "sandbox helper returned unexpected response for read_directory", + )); + }; + Ok(entries) + } + + async fn remove(&self, path: &AbsolutePathBuf, options: RemoveOptions) -> FileSystemResult<()> { + let response = self + .execute_request_via_sandbox(SandboxedFileSystemRequest::Remove { + path: path.clone(), + recursive: options.recursive, + force: options.force, + }) + .await?; + if !matches!(response, SandboxedFileSystemResponse::Unit) { + return Err(io::Error::other( + "sandbox helper returned unexpected response for remove", + )); + } + Ok(()) + } + + async fn copy( + &self, + source_path: &AbsolutePathBuf, + destination_path: &AbsolutePathBuf, + options: CopyOptions, + ) -> FileSystemResult<()> { + let response = self + .execute_request_via_sandbox(SandboxedFileSystemRequest::Copy { + source_path: source_path.clone(), + destination_path: destination_path.clone(), + recursive: options.recursive, + }) + .await?; + if !matches!(response, SandboxedFileSystemResponse::Unit) { + return Err(io::Error::other( + "sandbox helper returned unexpected response for copy", + )); + } + Ok(()) + } +} + +pub async fn run_internal_fs_op() -> io::Result<()> { + let mut input = Vec::new(); + tokio::io::stdin().read_to_end(&mut input).await?; + let request: SandboxedFileSystemRequest = + serde_json::from_slice(&input).map_err(io::Error::other)?; + let response = match execute_internal_request(request).await { + Ok(response) => SandboxedFileSystemEnvelope::Ok(response), + Err(err) => SandboxedFileSystemEnvelope::Error(SandboxedFileSystemError { + kind: SandboxedFileSystemErrorKind::from_io_kind(err.kind()), + message: err.to_string(), + }), + }; + let bytes = serde_json::to_vec(&response).map_err(io::Error::other)?; + tokio::io::stdout().write_all(&bytes).await?; + Ok(()) +} + +async fn execute_internal_request( + request: SandboxedFileSystemRequest, +) -> FileSystemResult { + let file_system = LocalFileSystem; + match request { + SandboxedFileSystemRequest::ReadFile { path } => { + let bytes = file_system.read_file(&path).await?; + Ok(SandboxedFileSystemResponse::ReadFile { + data_base64: STANDARD.encode(bytes), + }) + } + SandboxedFileSystemRequest::WriteFile { path, data_base64 } => { + let bytes = STANDARD.decode(data_base64).map_err(|err| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("invalid writeFile base64 payload: {err}"), + ) + })?; + file_system.write_file(&path, bytes).await?; + Ok(SandboxedFileSystemResponse::Unit) + } + SandboxedFileSystemRequest::CreateDirectory { path, recursive } => { + file_system + .create_directory(&path, CreateDirectoryOptions { recursive }) + .await?; + Ok(SandboxedFileSystemResponse::Unit) + } + SandboxedFileSystemRequest::GetMetadata { path } => { + let metadata = file_system.get_metadata(&path).await?; + Ok(SandboxedFileSystemResponse::GetMetadata { metadata }) + } + SandboxedFileSystemRequest::ReadDirectory { path } => { + let entries = file_system.read_directory(&path).await?; + Ok(SandboxedFileSystemResponse::ReadDirectory { entries }) + } + SandboxedFileSystemRequest::Remove { + path, + recursive, + force, + } => { + file_system + .remove(&path, RemoveOptions { recursive, force }) + .await?; + Ok(SandboxedFileSystemResponse::Unit) + } + SandboxedFileSystemRequest::Copy { + source_path, + destination_path, + recursive, + } => { + file_system + .copy(&source_path, &destination_path, CopyOptions { recursive }) + .await?; + Ok(SandboxedFileSystemResponse::Unit) + } + } +} + +fn prepare_helper_request( + sandbox_policy: &FileSystemSandboxPolicy, +) -> io::Result { + let helper_exe = helper_executable_path()?; + let codex_linux_sandbox_exe = linux_sandbox_executable_path(&helper_exe); + let helper_cwd = std::env::current_dir()?; + let network_policy = NetworkSandboxPolicy::Enabled; + let helper_readable_roots = + helper_readable_roots(&helper_exe, codex_linux_sandbox_exe.as_ref()); + let effective_file_system_policy = sandbox_policy + .clone() + .with_additional_readable_roots(helper_cwd.as_path(), &helper_readable_roots); + let legacy_policy = helper_legacy_policy( + &effective_file_system_policy, + network_policy, + helper_cwd.as_path(), + )?; + let manager = SandboxManager::new(); + let sandbox = manager.select_initial( + &effective_file_system_policy, + network_policy, + codex_sandboxing::SandboxablePreference::Auto, + WindowsSandboxLevel::Disabled, + /*has_managed_network_requirements*/ false, + ); + let command = SandboxCommand { + program: helper_exe.clone().into(), + args: vec![INTERNAL_FS_OP_FLAG.to_string()], + cwd: helper_cwd.clone(), + env: HashMap::new(), + additional_permissions: None, + }; + manager + .transform(SandboxTransformRequest { + command, + policy: &legacy_policy, + file_system_policy: &effective_file_system_policy, + network_policy, + sandbox, + enforce_managed_network: false, + network: None, + sandbox_policy_cwd: helper_cwd.as_path(), + codex_linux_sandbox_exe: codex_linux_sandbox_exe.as_ref(), + use_legacy_landlock: false, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + windows_sandbox_private_desktop: false, + }) + .map_err(io::Error::other) +} + +fn helper_readable_roots( + helper_exe: &Path, + codex_linux_sandbox_exe: Option<&PathBuf>, +) -> Vec { + let mut roots = Vec::new(); + + for path in + std::iter::once(helper_exe).chain(codex_linux_sandbox_exe.into_iter().map(PathBuf::as_path)) + { + if let Some(parent) = path.parent() + && let Ok(root) = AbsolutePathBuf::from_absolute_path(parent) + && !roots.iter().any(|existing| existing == &root) + { + roots.push(root); + } + } + + roots +} + +fn helper_legacy_policy( + sandbox_policy: &FileSystemSandboxPolicy, + network_policy: NetworkSandboxPolicy, + helper_cwd: &Path, +) -> io::Result { + match sandbox_policy.to_legacy_sandbox_policy(network_policy, helper_cwd) { + Ok(policy) => Ok(policy), + Err(_) if sandbox_policy.needs_direct_runtime_enforcement(network_policy, helper_cwd) => { + Ok(SandboxPolicy::ExternalSandbox { + network_access: if network_policy.is_enabled() { + NetworkAccess::Enabled + } else { + NetworkAccess::Restricted + }, + }) + } + Err(err) => Err(err), + } +} + +fn helper_executable_path() -> io::Result { + if let Some(path) = std::env::var_os("CODEX_EXEC_SERVER_SELF_EXE") { + return Ok(PathBuf::from(path)); + } + if let Some(path) = std::env::var_os("CARGO_BIN_EXE_codex-exec-server") { + return Ok(PathBuf::from(path)); + } + if let Ok(path) = which::which("codex-exec-server") { + return Ok(path); + } + + let current_exe = std::env::current_exe()?; + if current_exe.file_name().and_then(|value| value.to_str()) == Some("codex-exec-server") { + return Ok(current_exe); + } + + Err(io::Error::new( + io::ErrorKind::NotFound, + "could not find codex-exec-server executable for sandboxed filesystem helper", + )) +} + +fn linux_sandbox_executable_path(helper_exe: &Path) -> Option { + if !cfg!(target_os = "linux") { + return None; + } + + std::env::var_os("CODEX_LINUX_SANDBOX_EXE") + .map(PathBuf::from) + .or_else(|| std::env::var_os("CARGO_BIN_EXE_codex-linux-sandbox").map(PathBuf::from)) + .or_else(|| which::which("codex-linux-sandbox").ok()) + .or_else(|| { + helper_exe.parent().and_then(|parent| { + let sibling = parent.join("codex-linux-sandbox"); + sibling.exists().then_some(sibling) + }) + }) +} + +#[cfg(test)] +mod tests { + use super::helper_legacy_policy; + 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_protocol::protocol::NetworkAccess; + use codex_protocol::protocol::SandboxPolicy; + use codex_utils_absolute_path::AbsolutePathBuf; + + #[test] + fn helper_legacy_policy_falls_back_to_external_sandbox_for_direct_runtime_enforcement() { + let outside_workspace = + AbsolutePathBuf::try_from(std::path::PathBuf::from("/outside")).expect("absolute path"); + let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: outside_workspace, + }, + access: FileSystemAccessMode::Write, + }]); + + let resolved = helper_legacy_policy( + &policy, + NetworkSandboxPolicy::Enabled, + std::path::Path::new("/workspace"), + ) + .expect("direct-enforcement policy should resolve"); + + assert_eq!( + resolved, + SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Enabled, + } + ); + } +} diff --git a/codex-rs/exec-server/src/server/file_system_handler.rs b/codex-rs/exec-server/src/server/file_system_handler.rs index 2e4e1592d1..99bcc3dbf4 100644 --- a/codex-rs/exec-server/src/server/file_system_handler.rs +++ b/codex-rs/exec-server/src/server/file_system_handler.rs @@ -39,7 +39,7 @@ impl FileSystemHandler { ) -> Result { let bytes = self .file_system - .read_file(¶ms.path) + .read_file_with_sandbox_policy(¶ms.path, params.sandbox_policy.as_ref()) .await .map_err(map_fs_error)?; Ok(FsReadFileResponse { @@ -57,7 +57,7 @@ impl FileSystemHandler { )) })?; self.file_system - .write_file(¶ms.path, bytes) + .write_file_with_sandbox_policy(¶ms.path, bytes, params.sandbox_policy.as_ref()) .await .map_err(map_fs_error)?; Ok(FsWriteFileResponse {}) @@ -68,11 +68,12 @@ impl FileSystemHandler { params: FsCreateDirectoryParams, ) -> Result { self.file_system - .create_directory( + .create_directory_with_sandbox_policy( ¶ms.path, CreateDirectoryOptions { recursive: params.recursive.unwrap_or(true), }, + params.sandbox_policy.as_ref(), ) .await .map_err(map_fs_error)?; @@ -85,7 +86,7 @@ impl FileSystemHandler { ) -> Result { let metadata = self .file_system - .get_metadata(¶ms.path) + .get_metadata_with_sandbox_policy(¶ms.path, params.sandbox_policy.as_ref()) .await .map_err(map_fs_error)?; Ok(FsGetMetadataResponse { @@ -102,7 +103,7 @@ impl FileSystemHandler { ) -> Result { let entries = self .file_system - .read_directory(¶ms.path) + .read_directory_with_sandbox_policy(¶ms.path, params.sandbox_policy.as_ref()) .await .map_err(map_fs_error)?; Ok(FsReadDirectoryResponse { @@ -122,12 +123,13 @@ impl FileSystemHandler { params: FsRemoveParams, ) -> Result { self.file_system - .remove( + .remove_with_sandbox_policy( ¶ms.path, RemoveOptions { recursive: params.recursive.unwrap_or(true), force: params.force.unwrap_or(true), }, + params.sandbox_policy.as_ref(), ) .await .map_err(map_fs_error)?; @@ -139,12 +141,13 @@ impl FileSystemHandler { params: FsCopyParams, ) -> Result { self.file_system - .copy( + .copy_with_sandbox_policy( ¶ms.source_path, ¶ms.destination_path, CopyOptions { recursive: params.recursive, }, + params.sandbox_policy.as_ref(), ) .await .map_err(map_fs_error)?; diff --git a/codex-rs/exec-server/tests/file_system.rs b/codex-rs/exec-server/tests/file_system.rs index dea47e8fcc..0bb89e45d2 100644 --- a/codex-rs/exec-server/tests/file_system.rs +++ b/codex-rs/exec-server/tests/file_system.rs @@ -5,6 +5,7 @@ mod common; use std::os::unix::fs::symlink; use std::process::Command; use std::sync::Arc; +use std::sync::Mutex; use anyhow::Context; use anyhow::Result; @@ -14,7 +15,9 @@ use codex_exec_server::Environment; use codex_exec_server::ExecutorFileSystem; use codex_exec_server::ReadDirectoryEntry; use codex_exec_server::RemoveOptions; +use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_cargo_bin::cargo_bin; use pretty_assertions::assert_eq; use tempfile::TempDir; use test_case::test_case; @@ -22,6 +25,51 @@ use test_case::test_case; use common::exec_server::ExecServerHarness; use common::exec_server::exec_server; +static SANDBOX_HELPER_ENV_LOCK: Mutex<()> = Mutex::new(()); + +struct SandboxHelperEnvGuard { + previous_exec_server_self_exe: Option, + previous_linux_sandbox_exe: Option, +} + +impl SandboxHelperEnvGuard { + fn install() -> Result { + let exec_server_binary = cargo_bin("codex-exec-server")?; + let linux_sandbox_binary = cargo_bin("codex-linux-sandbox")?; + let previous_exec_server_self_exe = std::env::var_os("CODEX_EXEC_SERVER_SELF_EXE"); + let previous_linux_sandbox_exe = std::env::var_os("CODEX_LINUX_SANDBOX_EXE"); + unsafe { + std::env::set_var("CODEX_EXEC_SERVER_SELF_EXE", &exec_server_binary); + std::env::set_var("CODEX_LINUX_SANDBOX_EXE", &linux_sandbox_binary); + } + Ok(Self { + previous_exec_server_self_exe, + previous_linux_sandbox_exe, + }) + } +} + +impl Drop for SandboxHelperEnvGuard { + fn drop(&mut self) { + match self.previous_exec_server_self_exe.as_ref() { + Some(previous) => unsafe { + std::env::set_var("CODEX_EXEC_SERVER_SELF_EXE", previous); + }, + None => unsafe { + std::env::remove_var("CODEX_EXEC_SERVER_SELF_EXE"); + }, + } + match self.previous_linux_sandbox_exe.as_ref() { + Some(previous) => unsafe { + std::env::set_var("CODEX_LINUX_SANDBOX_EXE", previous); + }, + None => unsafe { + std::env::remove_var("CODEX_LINUX_SANDBOX_EXE"); + }, + } + } +} + struct FileSystemContext { file_system: Arc, _server: Option, @@ -44,6 +92,10 @@ async fn create_file_system_context(use_remote: bool) -> Result FileSystemSandboxPolicy { + FileSystemSandboxPolicy::unrestricted() +} + fn absolute_path(path: std::path::PathBuf) -> AbsolutePathBuf { assert!( path.is_absolute(), @@ -359,3 +411,52 @@ async fn file_system_copy_rejects_standalone_fifo_source(use_remote: bool) -> Re Ok(()) } + +#[test_case(false ; "local")] +#[test_case(true ; "remote")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn file_system_methods_support_sandbox_policy_helper(use_remote: bool) -> Result<()> { + let _lock = SANDBOX_HELPER_ENV_LOCK + .lock() + .expect("lock sandbox helper env"); + let _env_guard = SandboxHelperEnvGuard::install()?; + + let context = create_file_system_context(use_remote).await?; + let file_system = context.file_system; + + let tmp = TempDir::new()?; + let policy = unrestricted_sandbox_policy(); + let file_path = tmp.path().join("sandboxed.txt"); + let copied_path = tmp.path().join("copied.txt"); + + file_system + .write_file_with_sandbox_policy( + &absolute_path(file_path.clone()), + b"hello from sandbox helper".to_vec(), + Some(&policy), + ) + .await + .with_context(|| format!("mode={use_remote}"))?; + + let bytes = file_system + .read_file_with_sandbox_policy(&absolute_path(file_path), Some(&policy)) + .await + .with_context(|| format!("mode={use_remote}"))?; + assert_eq!(bytes, b"hello from sandbox helper"); + + file_system + .copy_with_sandbox_policy( + &absolute_path(tmp.path().join("sandboxed.txt")), + &absolute_path(copied_path.clone()), + CopyOptions { recursive: false }, + Some(&policy), + ) + .await + .with_context(|| format!("mode={use_remote}"))?; + assert_eq!( + std::fs::read_to_string(copied_path)?, + "hello from sandbox helper" + ); + + Ok(()) +}