diff --git a/codex-rs/exec-server/README.md b/codex-rs/exec-server/README.md index 208e53bc202..e7c2a8923d0 100644 --- a/codex-rs/exec-server/README.md +++ b/codex-rs/exec-server/README.md @@ -352,10 +352,17 @@ absolute path strings and normalize them to `file:` URIs: - `fs/copy` Each filesystem request accepts an optional `sandbox` object. When `sandbox` -contains a `ReadOnly` or `WorkspaceWrite` policy, the operation runs in a -hidden helper process launched from the top-level `codex` executable and -prepared through the shared sandbox transform path. Helper requests and -responses are passed over stdin/stdout. +contains a managed permission profile, concrete filesystem permission paths +are canonical `file:` URIs. Native absolute path strings and legacy +`file_system.read` / `file_system.write` roots remain accepted for compatibility +and are normalized to the canonical tagged profile with `file:` URIs when +serialized. The server converts those paths to native absolute paths before +invoking the local filesystem; a URI that is not native to the server host is +rejected as an invalid request. + +Sandboxed operations run in a hidden helper process launched from the +top-level `codex` executable and prepared through the shared sandbox transform +path. Helper requests and responses are passed over stdin/stdout. ## Errors @@ -384,6 +391,10 @@ The crate exports: - `ExecServerClientConnectOptions` - `RemoteExecServerConnectArgs` - protocol request/response structs for process and filesystem RPCs +- exec-server-owned sandbox permission types, including + `ExecServerFileSystemSandboxContext`, `ExecServerPermissionProfile`, and the + filesystem path, entry, access, special-path, network, and Windows-level + types they contain - `DEFAULT_LISTEN_URL` and `ExecServerListenUrlParseError` - `ExecServerRuntimePaths` - `run_main()` for embedding the websocket server diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 688c421dd3a..73942d678ab 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -498,6 +498,7 @@ mod tests { use crate::ProcessId; use crate::environment_provider::EnvironmentDefault; use crate::environment_provider::EnvironmentProviderSnapshot; + use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; fn test_runtime_paths() -> ExecServerRuntimePaths { diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index d6a9aacb511..10e54778ecc 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -20,6 +20,7 @@ mod remote_file_system; mod remote_process; mod rpc; mod runtime_paths; +mod sandbox_permissions; mod sandboxed_file_system; mod server; @@ -105,6 +106,15 @@ pub use protocol::WriteStatus; pub use remote::RemoteEnvironmentConfig; pub use remote::run_remote_environment; pub use runtime_paths::ExecServerRuntimePaths; +pub use sandbox_permissions::ExecServerFileSystemAccessMode; +pub use sandbox_permissions::ExecServerFileSystemPath; +pub use sandbox_permissions::ExecServerFileSystemSandboxContext; +pub use sandbox_permissions::ExecServerFileSystemSandboxEntry; +pub use sandbox_permissions::ExecServerFileSystemSpecialPath; +pub use sandbox_permissions::ExecServerManagedFileSystemPermissions; +pub use sandbox_permissions::ExecServerNetworkSandboxPolicy; +pub use sandbox_permissions::ExecServerPermissionProfile; +pub use sandbox_permissions::ExecServerWindowsSandboxLevel; pub use server::DEFAULT_LISTEN_URL; pub use server::ExecServerListenUrlParseError; pub use server::run_main; diff --git a/codex-rs/exec-server/src/protocol.rs b/codex-rs/exec-server/src/protocol.rs index 175d23fe0a9..b29de0a747e 100644 --- a/codex-rs/exec-server/src/protocol.rs +++ b/codex-rs/exec-server/src/protocol.rs @@ -1,7 +1,7 @@ use std::collections::HashMap; use std::path::PathBuf; -use crate::FileSystemSandboxContext; +use crate::ExecServerFileSystemSandboxContext; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_protocol::config_types::ShellEnvironmentPolicyInherit; use codex_utils_path_uri::PathUri; @@ -198,7 +198,7 @@ pub struct TerminateResponse { #[serde(rename_all = "camelCase")] pub struct FsReadFileParams { pub path: PathUri, - pub sandbox: Option, + pub sandbox: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -212,7 +212,7 @@ pub struct FsReadFileResponse { pub struct FsWriteFileParams { pub path: PathUri, pub data_base64: String, - pub sandbox: Option, + pub sandbox: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -224,7 +224,7 @@ pub struct FsWriteFileResponse {} pub struct FsCreateDirectoryParams { pub path: PathUri, pub recursive: Option, - pub sandbox: Option, + pub sandbox: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -235,7 +235,7 @@ pub struct FsCreateDirectoryResponse {} #[serde(rename_all = "camelCase")] pub struct FsGetMetadataParams { pub path: PathUri, - pub sandbox: Option, + pub sandbox: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -253,7 +253,7 @@ pub struct FsGetMetadataResponse { #[serde(rename_all = "camelCase")] pub struct FsCanonicalizeParams { pub path: PathUri, - pub sandbox: Option, + pub sandbox: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -266,7 +266,7 @@ pub struct FsCanonicalizeResponse { #[serde(rename_all = "camelCase")] pub struct FsReadDirectoryParams { pub path: PathUri, - pub sandbox: Option, + pub sandbox: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -289,7 +289,7 @@ pub struct FsRemoveParams { pub path: PathUri, pub recursive: Option, pub force: Option, - pub sandbox: Option, + pub sandbox: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -302,7 +302,7 @@ pub struct FsCopyParams { pub source_path: PathUri, pub destination_path: PathUri, pub recursive: bool, - pub sandbox: Option, + pub sandbox: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -466,8 +466,10 @@ mod tests { PermissionProfile::default(), PathUri::from_path(&legacy_cwd).expect("cwd URI"), ); + let expected_exec_server_sandbox: crate::ExecServerFileSystemSandboxContext = + expected_sandbox.into(); let mut legacy_sandbox = - serde_json::to_value(&expected_sandbox).expect("sandbox should serialize"); + serde_json::to_value(&expected_exec_server_sandbox).expect("sandbox should serialize"); legacy_sandbox["cwd"] = serde_json::json!(legacy_cwd.to_string_lossy()); let params: FsReadFileParams = serde_json::from_value(serde_json::json!({ "path": legacy_path.to_string_lossy(), @@ -476,7 +478,7 @@ mod tests { .expect("legacy absolute path should deserialize"); let expected = FsReadFileParams { path: PathUri::from_path(legacy_path).expect("path URI"), - sandbox: Some(expected_sandbox.clone()), + sandbox: Some(expected_exec_server_sandbox.clone()), }; assert_eq!(params, expected); @@ -484,7 +486,7 @@ mod tests { serde_json::to_value(params).expect("params should serialize"), serde_json::json!({ "path": expected.path.to_string(), - "sandbox": serde_json::to_value(expected_sandbox) + "sandbox": serde_json::to_value(expected_exec_server_sandbox) .expect("sandbox should serialize"), }) ); diff --git a/codex-rs/exec-server/src/remote_file_system.rs b/codex-rs/exec-server/src/remote_file_system.rs index 7c04f267eea..5c8d54ec6b9 100644 --- a/codex-rs/exec-server/src/remote_file_system.rs +++ b/codex-rs/exec-server/src/remote_file_system.rs @@ -7,6 +7,7 @@ use tracing::trace; use crate::CopyOptions; use crate::CreateDirectoryOptions; use crate::ExecServerError; +use crate::ExecServerFileSystemSandboxContext; use crate::ExecutorFileSystem; use crate::ExecutorFileSystemFuture; use crate::FileMetadata; @@ -286,10 +287,11 @@ impl ExecutorFileSystem for RemoteFileSystem { fn remote_sandbox_context( sandbox: Option<&FileSystemSandboxContext>, -) -> Option { +) -> Option { sandbox .cloned() .map(FileSystemSandboxContext::drop_cwd_if_unused) + .map(Into::into) } fn map_remote_error(error: ExecServerError) -> io::Error { diff --git a/codex-rs/exec-server/src/remote_file_system_path_uri_tests.rs b/codex-rs/exec-server/src/remote_file_system_path_uri_tests.rs index 29284e6ad90..0b4cd5e393a 100644 --- a/codex-rs/exec-server/src/remote_file_system_path_uri_tests.rs +++ b/codex-rs/exec-server/src/remote_file_system_path_uri_tests.rs @@ -9,6 +9,7 @@ use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; use futures::SinkExt; use futures::StreamExt; @@ -32,7 +33,7 @@ use crate::protocol::INITIALIZED_METHOD; use crate::protocol::InitializeResponse; #[tokio::test] -async fn remote_file_system_sends_path_and_sandbox_cwd_uris_without_native_conversion() { +async fn remote_file_system_sends_path_and_sandbox_uris_without_native_conversion() { let (websocket_url, captured_params, server) = record_read_file_params(/*expected_requests*/ 2).await; let file_system = RemoteFileSystem::new(LazyRemoteExecServerClient::new( @@ -43,12 +44,24 @@ async fn remote_file_system_sends_path_and_sandbox_cwd_uris_without_native_conve PathUri::parse("file://server/share/src/main.rs").expect("valid UNC URI"), ]; let sandbox_cwd = non_native_cwd(); - let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + let permission_root = AbsolutePathBuf::from_absolute_path( + std::env::temp_dir().join("exec-server-permission-root"), + ) + .expect("absolute permission root"); + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: permission_root, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + }, + access: FileSystemAccessMode::Write, }, - access: FileSystemAccessMode::Write, - }]); + ]); let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted), sandbox_cwd, @@ -68,7 +81,7 @@ async fn remote_file_system_sends_path_and_sandbox_cwd_uris_without_native_conve .into_iter() .map(|path| FsReadFileParams { path, - sandbox: Some(sandbox.clone()), + sandbox: Some(sandbox.clone().into()), }) .collect::>(); assert_eq!( diff --git a/codex-rs/exec-server/src/sandbox_permissions.rs b/codex-rs/exec-server/src/sandbox_permissions.rs new file mode 100644 index 00000000000..d2dfdce8e00 --- /dev/null +++ b/codex-rs/exec-server/src/sandbox_permissions.rs @@ -0,0 +1,381 @@ +use std::io; +use std::num::NonZeroUsize; +use std::path::PathBuf; + +use codex_file_system::FileSystemSandboxContext; +use codex_protocol::config_types::WindowsSandboxLevel as CoreWindowsSandboxLevel; +use codex_protocol::models::ManagedFileSystemPermissions as CoreManagedFileSystemPermissions; +use codex_protocol::models::PermissionProfile as CorePermissionProfile; +use codex_protocol::permissions::FileSystemAccessMode as CoreFileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath as CoreFileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry as CoreFileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSpecialPath as CoreFileSystemSpecialPath; +use codex_protocol::permissions::NetworkSandboxPolicy as CoreNetworkSandboxPolicy; +use codex_utils_path_uri::PathUri; +use serde::Deserialize; +use serde::Serialize; + +mod legacy; + +/// Network sandbox policy carried by the exec-server protocol. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ExecServerNetworkSandboxPolicy { + #[default] + Restricted, + Enabled, +} + +/// Windows sandbox level carried by the exec-server protocol. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ExecServerWindowsSandboxLevel { + #[default] + Disabled, + RestrictedToken, + Elevated, +} + +/// Access mode for an exec-server filesystem permission entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ExecServerFileSystemAccessMode { + Read, + Write, + #[serde(alias = "none")] + Deny, +} + +/// Symbolic filesystem location carried by the exec-server protocol. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ExecServerFileSystemSpecialPath { + Root, + Minimal, + #[serde(alias = "current_working_directory")] + ProjectRoots { + #[serde(default, skip_serializing_if = "Option::is_none")] + subpath: Option, + }, + Tmpdir, + SlashTmp, + Unknown { + path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + subpath: Option, + }, +} + +/// Filesystem location or pattern carried by the exec-server protocol. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ExecServerFileSystemPath { + Path { + path: PathUri, + }, + GlobPattern { + pattern: String, + }, + Special { + value: ExecServerFileSystemSpecialPath, + }, +} + +/// Filesystem permission entry carried by the exec-server protocol. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct ExecServerFileSystemSandboxEntry { + pub path: ExecServerFileSystemPath, + pub access: ExecServerFileSystemAccessMode, +} + +/// Filesystem permissions for an exec-server managed sandbox. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ExecServerManagedFileSystemPermissions { + #[serde(rename_all = "snake_case")] + Restricted { + entries: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + glob_scan_max_depth: Option, + }, + Unrestricted, +} + +/// Active sandbox permissions carried by the exec-server protocol. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ExecServerPermissionProfile { + #[serde(rename_all = "snake_case")] + Managed { + file_system: ExecServerManagedFileSystemPermissions, + network: ExecServerNetworkSandboxPolicy, + }, + Disabled, + #[serde(rename_all = "snake_case")] + External { + network: ExecServerNetworkSandboxPolicy, + }, +} + +/// Sandbox context carried by exec-server filesystem requests. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExecServerFileSystemSandboxContext { + pub permissions: ExecServerPermissionProfile, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option, + pub windows_sandbox_level: ExecServerWindowsSandboxLevel, + #[serde(default)] + pub windows_sandbox_private_desktop: bool, + #[serde(default)] + pub use_legacy_landlock: bool, +} + +impl From for ExecServerNetworkSandboxPolicy { + fn from(value: CoreNetworkSandboxPolicy) -> Self { + match value { + CoreNetworkSandboxPolicy::Restricted => Self::Restricted, + CoreNetworkSandboxPolicy::Enabled => Self::Enabled, + } + } +} + +impl From for CoreNetworkSandboxPolicy { + fn from(value: ExecServerNetworkSandboxPolicy) -> Self { + match value { + ExecServerNetworkSandboxPolicy::Restricted => Self::Restricted, + ExecServerNetworkSandboxPolicy::Enabled => Self::Enabled, + } + } +} + +impl From for ExecServerWindowsSandboxLevel { + fn from(value: CoreWindowsSandboxLevel) -> Self { + match value { + CoreWindowsSandboxLevel::Disabled => Self::Disabled, + CoreWindowsSandboxLevel::RestrictedToken => Self::RestrictedToken, + CoreWindowsSandboxLevel::Elevated => Self::Elevated, + } + } +} + +impl From for CoreWindowsSandboxLevel { + fn from(value: ExecServerWindowsSandboxLevel) -> Self { + match value { + ExecServerWindowsSandboxLevel::Disabled => Self::Disabled, + ExecServerWindowsSandboxLevel::RestrictedToken => Self::RestrictedToken, + ExecServerWindowsSandboxLevel::Elevated => Self::Elevated, + } + } +} + +impl From for ExecServerFileSystemAccessMode { + fn from(value: CoreFileSystemAccessMode) -> Self { + match value { + CoreFileSystemAccessMode::Read => Self::Read, + CoreFileSystemAccessMode::Write => Self::Write, + CoreFileSystemAccessMode::Deny => Self::Deny, + } + } +} + +impl From for CoreFileSystemAccessMode { + fn from(value: ExecServerFileSystemAccessMode) -> Self { + match value { + ExecServerFileSystemAccessMode::Read => Self::Read, + ExecServerFileSystemAccessMode::Write => Self::Write, + ExecServerFileSystemAccessMode::Deny => Self::Deny, + } + } +} + +impl From for ExecServerFileSystemSpecialPath { + fn from(value: CoreFileSystemSpecialPath) -> Self { + match value { + CoreFileSystemSpecialPath::Root => Self::Root, + CoreFileSystemSpecialPath::Minimal => Self::Minimal, + CoreFileSystemSpecialPath::ProjectRoots { subpath } => Self::ProjectRoots { subpath }, + CoreFileSystemSpecialPath::Tmpdir => Self::Tmpdir, + CoreFileSystemSpecialPath::SlashTmp => Self::SlashTmp, + CoreFileSystemSpecialPath::Unknown { path, subpath } => Self::Unknown { path, subpath }, + } + } +} + +impl From for CoreFileSystemSpecialPath { + fn from(value: ExecServerFileSystemSpecialPath) -> Self { + match value { + ExecServerFileSystemSpecialPath::Root => Self::Root, + ExecServerFileSystemSpecialPath::Minimal => Self::Minimal, + ExecServerFileSystemSpecialPath::ProjectRoots { subpath } => { + Self::ProjectRoots { subpath } + } + ExecServerFileSystemSpecialPath::Tmpdir => Self::Tmpdir, + ExecServerFileSystemSpecialPath::SlashTmp => Self::SlashTmp, + ExecServerFileSystemSpecialPath::Unknown { path, subpath } => { + Self::Unknown { path, subpath } + } + } + } +} + +impl From for ExecServerFileSystemPath { + fn from(value: CoreFileSystemPath) -> Self { + match value { + CoreFileSystemPath::Path { path } => Self::Path { + path: PathUri::from_abs_path(&path), + }, + CoreFileSystemPath::GlobPattern { pattern } => Self::GlobPattern { pattern }, + CoreFileSystemPath::Special { value } => Self::Special { + value: value.into(), + }, + } + } +} + +impl TryFrom for CoreFileSystemPath { + type Error = io::Error; + + fn try_from(value: ExecServerFileSystemPath) -> Result { + match value { + ExecServerFileSystemPath::Path { path } => { + let native_path = path.to_abs_path().map_err(|err| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "sandbox permission path URI `{path}` is not valid on this exec-server host: {err}" + ), + ) + })?; + Ok(Self::Path { path: native_path }) + } + ExecServerFileSystemPath::GlobPattern { pattern } => Ok(Self::GlobPattern { pattern }), + ExecServerFileSystemPath::Special { value } => Ok(Self::Special { + value: value.into(), + }), + } + } +} + +impl From for ExecServerFileSystemSandboxEntry { + fn from(value: CoreFileSystemSandboxEntry) -> Self { + Self { + path: value.path.into(), + access: value.access.into(), + } + } +} + +impl TryFrom for CoreFileSystemSandboxEntry { + type Error = io::Error; + + fn try_from(value: ExecServerFileSystemSandboxEntry) -> Result { + Ok(Self { + path: value.path.try_into()?, + access: value.access.into(), + }) + } +} + +impl From for ExecServerManagedFileSystemPermissions { + fn from(value: CoreManagedFileSystemPermissions) -> Self { + match value { + CoreManagedFileSystemPermissions::Restricted { + entries, + glob_scan_max_depth, + } => Self::Restricted { + entries: entries.into_iter().map(Into::into).collect(), + glob_scan_max_depth, + }, + CoreManagedFileSystemPermissions::Unrestricted => Self::Unrestricted, + } + } +} + +impl TryFrom for CoreManagedFileSystemPermissions { + type Error = io::Error; + + fn try_from(value: ExecServerManagedFileSystemPermissions) -> Result { + match value { + ExecServerManagedFileSystemPermissions::Restricted { + entries, + glob_scan_max_depth, + } => Ok(Self::Restricted { + entries: entries + .into_iter() + .map(TryInto::try_into) + .collect::>()?, + glob_scan_max_depth, + }), + ExecServerManagedFileSystemPermissions::Unrestricted => Ok(Self::Unrestricted), + } + } +} + +impl From for ExecServerPermissionProfile { + fn from(value: CorePermissionProfile) -> Self { + match value { + CorePermissionProfile::Managed { + file_system, + network, + } => Self::Managed { + file_system: file_system.into(), + network: network.into(), + }, + CorePermissionProfile::Disabled => Self::Disabled, + CorePermissionProfile::External { network } => Self::External { + network: network.into(), + }, + } + } +} + +impl TryFrom for CorePermissionProfile { + type Error = io::Error; + + fn try_from(value: ExecServerPermissionProfile) -> Result { + match value { + ExecServerPermissionProfile::Managed { + file_system, + network, + } => Ok(Self::Managed { + file_system: file_system.try_into()?, + network: network.into(), + }), + ExecServerPermissionProfile::Disabled => Ok(Self::Disabled), + ExecServerPermissionProfile::External { network } => Ok(Self::External { + network: network.into(), + }), + } + } +} + +impl From for ExecServerFileSystemSandboxContext { + fn from(value: FileSystemSandboxContext) -> Self { + Self { + permissions: value.permissions.into(), + cwd: value.cwd, + windows_sandbox_level: value.windows_sandbox_level.into(), + windows_sandbox_private_desktop: value.windows_sandbox_private_desktop, + use_legacy_landlock: value.use_legacy_landlock, + } + } +} + +impl TryFrom for FileSystemSandboxContext { + type Error = io::Error; + + fn try_from(value: ExecServerFileSystemSandboxContext) -> Result { + Ok(Self { + permissions: value.permissions.try_into()?, + cwd: value.cwd, + windows_sandbox_level: value.windows_sandbox_level.into(), + windows_sandbox_private_desktop: value.windows_sandbox_private_desktop, + use_legacy_landlock: value.use_legacy_landlock, + }) + } +} + +#[cfg(test)] +#[path = "sandbox_permissions_tests.rs"] +mod tests; diff --git a/codex-rs/exec-server/src/sandbox_permissions/legacy.rs b/codex-rs/exec-server/src/sandbox_permissions/legacy.rs new file mode 100644 index 00000000000..b1c94e72cf0 --- /dev/null +++ b/codex-rs/exec-server/src/sandbox_permissions/legacy.rs @@ -0,0 +1,168 @@ +use std::num::NonZeroUsize; + +use codex_utils_path_uri::PathUri; +use serde::Deserialize; +use serde::Deserializer; + +use super::ExecServerFileSystemAccessMode; +use super::ExecServerFileSystemPath; +use super::ExecServerFileSystemSandboxEntry; +use super::ExecServerManagedFileSystemPermissions; +use super::ExecServerNetworkSandboxPolicy; +use super::ExecServerPermissionProfile; + +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum TaggedExecServerPermissionProfile { + #[serde(rename_all = "snake_case")] + Managed { + file_system: ExecServerManagedFileSystemPermissions, + network: ExecServerNetworkSandboxPolicy, + }, + Disabled, + #[serde(rename_all = "snake_case")] + External { + network: ExecServerNetworkSandboxPolicy, + }, +} + +impl From for ExecServerPermissionProfile { + fn from(value: TaggedExecServerPermissionProfile) -> Self { + match value { + TaggedExecServerPermissionProfile::Managed { + file_system, + network, + } => Self::Managed { + file_system, + network, + }, + TaggedExecServerPermissionProfile::Disabled => Self::Disabled, + TaggedExecServerPermissionProfile::External { network } => Self::External { network }, + } + } +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct LegacyExecServerPermissionProfile { + network: Option, + file_system: Option, +} + +impl From for ExecServerPermissionProfile { + fn from(value: LegacyExecServerPermissionProfile) -> Self { + let file_system = value.file_system.unwrap_or_default(); + let network = if value + .network + .and_then(|network| network.enabled) + .unwrap_or(false) + { + ExecServerNetworkSandboxPolicy::Enabled + } else { + ExecServerNetworkSandboxPolicy::Restricted + }; + Self::Managed { + file_system: ExecServerManagedFileSystemPermissions::Restricted { + entries: file_system.entries, + glob_scan_max_depth: file_system.glob_scan_max_depth, + }, + network, + } + } +} + +#[derive(Debug, Clone, Default, Deserialize)] +struct LegacyNetworkPermissions { + enabled: Option, +} + +#[derive(Debug, Clone, Default)] +struct LegacyFileSystemPermissions { + entries: Vec, + glob_scan_max_depth: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct CanonicalLegacyFileSystemPermissions { + #[serde(default)] + entries: Vec, + #[serde(default)] + glob_scan_max_depth: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct ReadWriteLegacyFileSystemPermissions { + #[serde(default)] + read: Option>, + #[serde(default)] + write: Option>, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +enum LegacyFileSystemPermissionsDe { + Canonical(CanonicalLegacyFileSystemPermissions), + ReadWrite(ReadWriteLegacyFileSystemPermissions), +} + +impl<'de> Deserialize<'de> for LegacyFileSystemPermissions { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Ok( + match LegacyFileSystemPermissionsDe::deserialize(deserializer)? { + LegacyFileSystemPermissionsDe::Canonical(value) => Self { + entries: value.entries, + glob_scan_max_depth: value.glob_scan_max_depth, + }, + LegacyFileSystemPermissionsDe::ReadWrite(value) => { + let mut entries = Vec::new(); + if let Some(paths) = value.read { + entries.extend(paths.into_iter().map(|path| { + ExecServerFileSystemSandboxEntry { + path: ExecServerFileSystemPath::Path { path }, + access: ExecServerFileSystemAccessMode::Read, + } + })); + } + if let Some(paths) = value.write { + entries.extend(paths.into_iter().map(|path| { + ExecServerFileSystemSandboxEntry { + path: ExecServerFileSystemPath::Path { path }, + access: ExecServerFileSystemAccessMode::Write, + } + })); + } + Self { + entries, + glob_scan_max_depth: None, + } + } + }, + ) + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +enum ExecServerPermissionProfileDe { + Tagged(TaggedExecServerPermissionProfile), + Legacy(LegacyExecServerPermissionProfile), +} + +impl<'de> Deserialize<'de> for ExecServerPermissionProfile { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Ok( + match ExecServerPermissionProfileDe::deserialize(deserializer)? { + ExecServerPermissionProfileDe::Tagged(value) => value.into(), + ExecServerPermissionProfileDe::Legacy(value) => value.into(), + }, + ) + } +} diff --git a/codex-rs/exec-server/src/sandbox_permissions_tests.rs b/codex-rs/exec-server/src/sandbox_permissions_tests.rs new file mode 100644 index 00000000000..f53eb85bdc0 --- /dev/null +++ b/codex-rs/exec-server/src/sandbox_permissions_tests.rs @@ -0,0 +1,345 @@ +use std::num::NonZeroUsize; + +use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::models::ManagedFileSystemPermissions; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use serde_json::json; + +use super::*; + +#[test] +fn sandbox_context_round_trips_between_core_and_exec_server_types() { + let contexts = vec![ + FileSystemSandboxContext { + permissions: PermissionProfile::Managed { + file_system: ManagedFileSystemPermissions::Restricted { + entries: all_path_variants(), + glob_scan_max_depth: NonZeroUsize::new(4), + }, + network: NetworkSandboxPolicy::Enabled, + }, + cwd: Some(path_uri("workspace")), + windows_sandbox_level: WindowsSandboxLevel::Elevated, + windows_sandbox_private_desktop: true, + use_legacy_landlock: true, + }, + FileSystemSandboxContext { + permissions: PermissionProfile::Managed { + file_system: ManagedFileSystemPermissions::Unrestricted, + network: NetworkSandboxPolicy::Restricted, + }, + cwd: None, + windows_sandbox_level: WindowsSandboxLevel::RestrictedToken, + windows_sandbox_private_desktop: false, + use_legacy_landlock: false, + }, + FileSystemSandboxContext { + permissions: PermissionProfile::Disabled, + cwd: None, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + windows_sandbox_private_desktop: false, + use_legacy_landlock: false, + }, + FileSystemSandboxContext { + permissions: PermissionProfile::External { + network: NetworkSandboxPolicy::Enabled, + }, + cwd: None, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + windows_sandbox_private_desktop: false, + use_legacy_landlock: false, + }, + ]; + + for context in contexts { + let exec_server_context = ExecServerFileSystemSandboxContext::from(context.clone()); + let round_trip = FileSystemSandboxContext::try_from(exec_server_context) + .expect("exec-server context should convert to native core paths"); + assert_eq!(round_trip, context); + } +} + +#[test] +fn sandbox_context_serializes_concrete_paths_as_uris() { + let native_path = absolute_path("readable"); + let path = PathUri::from_abs_path(&native_path); + let cwd = path_uri("workspace"); + let context = ExecServerFileSystemSandboxContext { + permissions: ExecServerPermissionProfile::Managed { + file_system: ExecServerManagedFileSystemPermissions::Restricted { + entries: vec![ + ExecServerFileSystemSandboxEntry { + path: ExecServerFileSystemPath::Path { path: path.clone() }, + access: ExecServerFileSystemAccessMode::Read, + }, + ExecServerFileSystemSandboxEntry { + path: ExecServerFileSystemPath::GlobPattern { + pattern: "**/*.env".to_string(), + }, + access: ExecServerFileSystemAccessMode::Deny, + }, + ExecServerFileSystemSandboxEntry { + path: ExecServerFileSystemPath::Special { + value: ExecServerFileSystemSpecialPath::ProjectRoots { + subpath: Some("docs".into()), + }, + }, + access: ExecServerFileSystemAccessMode::Write, + }, + ], + glob_scan_max_depth: NonZeroUsize::new(3), + }, + network: ExecServerNetworkSandboxPolicy::Restricted, + }, + cwd: Some(cwd.clone()), + windows_sandbox_level: ExecServerWindowsSandboxLevel::RestrictedToken, + windows_sandbox_private_desktop: true, + use_legacy_landlock: true, + }; + let expected = json!({ + "permissions": { + "type": "managed", + "file_system": { + "type": "restricted", + "entries": [ + { + "path": {"type": "path", "path": path.to_string()}, + "access": "read" + }, + { + "path": {"type": "glob_pattern", "pattern": "**/*.env"}, + "access": "deny" + }, + { + "path": { + "type": "special", + "value": {"kind": "project_roots", "subpath": "docs"} + }, + "access": "write" + } + ], + "glob_scan_max_depth": 3 + }, + "network": "restricted" + }, + "cwd": cwd.to_string(), + "windowsSandboxLevel": "restricted-token", + "windowsSandboxPrivateDesktop": true, + "useLegacyLandlock": true + }); + + let serialized = serde_json::to_value(&context).expect("sandbox context should serialize"); + assert_eq!(serialized, expected); + assert_eq!( + serde_json::from_value::(serialized) + .expect("sandbox context should deserialize"), + context + ); + + let mut legacy_native = expected; + legacy_native["permissions"]["file_system"]["entries"][0]["path"]["path"] = + json!(native_path.to_string_lossy()); + assert_eq!( + serde_json::from_value::(legacy_native) + .expect("legacy native permission path should deserialize"), + context + ); +} + +#[test] +fn sandbox_context_accepts_legacy_read_write_roots_and_serializes_canonically() { + let read = absolute_path("legacy-read"); + let write = absolute_path("legacy-write"); + let value = json!({ + "permissions": { + "network": {"enabled": true}, + "file_system": { + "read": [read.to_string_lossy()], + "write": [write.to_string_lossy()] + } + }, + "windowsSandboxLevel": "disabled" + }); + let context: ExecServerFileSystemSandboxContext = + serde_json::from_value(value).expect("legacy permission profile should deserialize"); + let expected = ExecServerFileSystemSandboxContext { + permissions: ExecServerPermissionProfile::Managed { + file_system: ExecServerManagedFileSystemPermissions::Restricted { + entries: vec![ + ExecServerFileSystemSandboxEntry { + path: ExecServerFileSystemPath::Path { + path: PathUri::from_abs_path(&read), + }, + access: ExecServerFileSystemAccessMode::Read, + }, + ExecServerFileSystemSandboxEntry { + path: ExecServerFileSystemPath::Path { + path: PathUri::from_abs_path(&write), + }, + access: ExecServerFileSystemAccessMode::Write, + }, + ], + glob_scan_max_depth: None, + }, + network: ExecServerNetworkSandboxPolicy::Enabled, + }, + cwd: None, + windows_sandbox_level: ExecServerWindowsSandboxLevel::Disabled, + windows_sandbox_private_desktop: false, + use_legacy_landlock: false, + }; + assert_eq!(context, expected); + + let serialized = serde_json::to_value(context).expect("sandbox context should serialize"); + assert_eq!(serialized["permissions"]["type"], "managed"); + assert_eq!( + serialized["permissions"]["file_system"]["entries"][0]["path"]["path"], + PathUri::from_abs_path(&read).to_string() + ); + assert!( + serialized["permissions"]["file_system"] + .get("read") + .is_none() + ); + assert!( + serialized["permissions"]["file_system"] + .get("write") + .is_none() + ); +} + +#[test] +fn sandbox_context_preserves_legacy_aliases_canonically() { + let value = json!({ + "permissions": { + "type": "managed", + "file_system": { + "type": "restricted", + "entries": [{ + "path": { + "type": "special", + "value": {"kind": "current_working_directory"} + }, + "access": "none" + }] + }, + "network": "restricted" + }, + "windowsSandboxLevel": "disabled" + }); + let context: ExecServerFileSystemSandboxContext = + serde_json::from_value(value).expect("legacy aliases should deserialize"); + let serialized = serde_json::to_value(context).expect("sandbox context should serialize"); + + assert_eq!( + serialized["permissions"]["file_system"]["entries"][0], + json!({ + "path": { + "type": "special", + "value": {"kind": "project_roots"} + }, + "access": "deny" + }) + ); +} + +#[test] +fn non_native_permission_path_is_rejected_when_converting_to_core() { + let path = non_native_path_uri(); + let context = ExecServerFileSystemSandboxContext { + permissions: ExecServerPermissionProfile::Managed { + file_system: ExecServerManagedFileSystemPermissions::Restricted { + entries: vec![ExecServerFileSystemSandboxEntry { + path: ExecServerFileSystemPath::Path { path: path.clone() }, + access: ExecServerFileSystemAccessMode::Read, + }], + glob_scan_max_depth: None, + }, + network: ExecServerNetworkSandboxPolicy::Restricted, + }, + cwd: None, + windows_sandbox_level: ExecServerWindowsSandboxLevel::Disabled, + windows_sandbox_private_desktop: false, + use_legacy_landlock: false, + }; + + let error = FileSystemSandboxContext::try_from(context) + .expect_err("foreign permission path should not convert on this host"); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + assert_eq!( + error.to_string(), + format!( + "sandbox permission path URI `{path}` is not valid on this exec-server host: {}", + path.to_abs_path() + .expect_err("test URI should be foreign to this host") + ) + ); +} + +fn all_path_variants() -> Vec { + vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: absolute_path("exact"), + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: "**/*.secret".to_string(), + }, + access: FileSystemAccessMode::Deny, + }, + special_entry(FileSystemSpecialPath::Root, FileSystemAccessMode::Write), + special_entry(FileSystemSpecialPath::Minimal, FileSystemAccessMode::Read), + special_entry( + FileSystemSpecialPath::ProjectRoots { + subpath: Some("docs".into()), + }, + FileSystemAccessMode::Write, + ), + special_entry(FileSystemSpecialPath::Tmpdir, FileSystemAccessMode::Read), + special_entry(FileSystemSpecialPath::SlashTmp, FileSystemAccessMode::Write), + special_entry( + FileSystemSpecialPath::Unknown { + path: ":future_root".to_string(), + subpath: Some("nested".into()), + }, + FileSystemAccessMode::Deny, + ), + ] +} + +fn special_entry( + value: FileSystemSpecialPath, + access: FileSystemAccessMode, +) -> FileSystemSandboxEntry { + FileSystemSandboxEntry { + path: FileSystemPath::Special { value }, + access, + } +} + +fn absolute_path(name: &str) -> AbsolutePathBuf { + AbsolutePathBuf::from_absolute_path(std::env::temp_dir().join(name)) + .expect("test path should be absolute") +} + +fn path_uri(name: &str) -> PathUri { + PathUri::from_abs_path(&absolute_path(name)) +} + +fn non_native_path_uri() -> PathUri { + #[cfg(unix)] + let value = "file://server/share/private"; + #[cfg(windows)] + let value = "file:///usr/local/private"; + PathUri::parse(value).expect("non-native path URI should parse") +} 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 080d4829d08..a992dd8b294 100644 --- a/codex-rs/exec-server/src/server/file_system_handler.rs +++ b/codex-rs/exec-server/src/server/file_system_handler.rs @@ -6,8 +6,10 @@ use codex_app_server_protocol::JSONRPCErrorError; use crate::CopyOptions; use crate::CreateDirectoryOptions; +use crate::ExecServerFileSystemSandboxContext; use crate::ExecServerRuntimePaths; use crate::ExecutorFileSystem; +use crate::FileSystemSandboxContext; use crate::RemoveOptions; use crate::local_file_system::LocalFileSystem; use crate::protocol::FS_WRITE_FILE_METHOD; @@ -48,9 +50,10 @@ impl FileSystemHandler { &self, params: FsReadFileParams, ) -> Result { + let sandbox = native_sandbox_context(params.sandbox)?; let bytes = self .file_system - .read_file(¶ms.path, params.sandbox.as_ref()) + .read_file(¶ms.path, sandbox.as_ref()) .await .map_err(map_fs_error)?; Ok(FsReadFileResponse { @@ -62,13 +65,14 @@ impl FileSystemHandler { &self, params: FsWriteFileParams, ) -> Result { + let sandbox = native_sandbox_context(params.sandbox)?; let bytes = STANDARD.decode(params.data_base64).map_err(|err| { invalid_request(format!( "{FS_WRITE_FILE_METHOD} requires valid base64 dataBase64: {err}" )) })?; self.file_system - .write_file(¶ms.path, bytes, params.sandbox.as_ref()) + .write_file(¶ms.path, bytes, sandbox.as_ref()) .await .map_err(map_fs_error)?; Ok(FsWriteFileResponse {}) @@ -78,12 +82,13 @@ impl FileSystemHandler { &self, params: FsCreateDirectoryParams, ) -> Result { + let sandbox = native_sandbox_context(params.sandbox)?; let recursive = params.recursive.unwrap_or(true); self.file_system .create_directory( ¶ms.path, CreateDirectoryOptions { recursive }, - params.sandbox.as_ref(), + sandbox.as_ref(), ) .await .map_err(map_fs_error)?; @@ -94,9 +99,10 @@ impl FileSystemHandler { &self, params: FsGetMetadataParams, ) -> Result { + let sandbox = native_sandbox_context(params.sandbox)?; let metadata = self .file_system - .get_metadata(¶ms.path, params.sandbox.as_ref()) + .get_metadata(¶ms.path, sandbox.as_ref()) .await .map_err(map_fs_error)?; Ok(FsGetMetadataResponse { @@ -113,9 +119,10 @@ impl FileSystemHandler { &self, params: FsCanonicalizeParams, ) -> Result { + let sandbox = native_sandbox_context(params.sandbox)?; let path = self .file_system - .canonicalize(¶ms.path, params.sandbox.as_ref()) + .canonicalize(¶ms.path, sandbox.as_ref()) .await .map_err(map_fs_error)?; Ok(FsCanonicalizeResponse { path }) @@ -125,9 +132,10 @@ impl FileSystemHandler { &self, params: FsReadDirectoryParams, ) -> Result { + let sandbox = native_sandbox_context(params.sandbox)?; let entries = self .file_system - .read_directory(¶ms.path, params.sandbox.as_ref()) + .read_directory(¶ms.path, sandbox.as_ref()) .await .map_err(map_fs_error)? .into_iter() @@ -144,13 +152,14 @@ impl FileSystemHandler { &self, params: FsRemoveParams, ) -> Result { + let sandbox = native_sandbox_context(params.sandbox)?; let recursive = params.recursive.unwrap_or(true); let force = params.force.unwrap_or(true); self.file_system .remove( ¶ms.path, RemoveOptions { recursive, force }, - params.sandbox.as_ref(), + sandbox.as_ref(), ) .await .map_err(map_fs_error)?; @@ -161,6 +170,7 @@ impl FileSystemHandler { &self, params: FsCopyParams, ) -> Result { + let sandbox = native_sandbox_context(params.sandbox)?; self.file_system .copy( ¶ms.source_path, @@ -168,7 +178,7 @@ impl FileSystemHandler { CopyOptions { recursive: params.recursive, }, - params.sandbox.as_ref(), + sandbox.as_ref(), ) .await .map_err(map_fs_error)?; @@ -176,6 +186,15 @@ impl FileSystemHandler { } } +fn native_sandbox_context( + sandbox: Option, +) -> Result, JSONRPCErrorError> { + sandbox + .map(TryInto::try_into) + .transpose() + .map_err(|err: io::Error| invalid_request(err.to_string())) +} + fn map_fs_error(err: io::Error) -> JSONRPCErrorError { match err.kind() { io::ErrorKind::NotFound => not_found(err.to_string()), @@ -231,7 +250,7 @@ mod tests { .write_file(FsWriteFileParams { path: path.clone(), data_base64: STANDARD.encode("ok"), - sandbox: Some(sandbox_context(sandbox_policy.clone())), + sandbox: Some(sandbox_context(sandbox_policy.clone()).into()), }) .await .expect("write file"); @@ -239,7 +258,7 @@ mod tests { let canonicalized = handler .canonicalize(FsCanonicalizeParams { path: path.clone(), - sandbox: Some(sandbox_context(sandbox_policy.clone())), + sandbox: Some(sandbox_context(sandbox_policy.clone()).into()), }) .await .expect("canonicalize file"); @@ -254,7 +273,7 @@ mod tests { let response = handler .read_file(FsReadFileParams { path, - sandbox: Some(sandbox_context(sandbox_policy)), + sandbox: Some(sandbox_context(sandbox_policy).into()), }) .await .expect("read file");