diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a6c7b4ee3..178149e63 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1244,6 +1244,8 @@ dependencies = [ "codex-lmstudio", "codex-ollama", "codex-protocol", + "codex-utils-absolute-path", + "pretty_assertions", "serde", "toml 0.9.5", ] @@ -1316,7 +1318,6 @@ dependencies = [ "sha2", "shlex", "similar", - "strum_macros 0.27.2", "tempfile", "test-case", "test-log", @@ -1913,6 +1914,7 @@ dependencies = [ "codex-utils-absolute-path", "dirs-next", "dunce", + "pretty_assertions", "rand 0.8.5", "serde", "serde_json", diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 37d3b71b3..dc2492995 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -18,6 +18,7 @@ use codex_protocol::plan_tool::StepStatus as CorePlanStepStatus; use codex_protocol::protocol::AskForApproval as CoreAskForApproval; use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot; +use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow; use codex_protocol::protocol::SessionSource as CoreSessionSource; @@ -470,6 +471,15 @@ pub enum ApprovalDecision { Cancel, } +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum NetworkAccess { + #[default] + Restricted, + Enabled, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "camelCase")] #[ts(tag = "type")] @@ -479,6 +489,12 @@ pub enum SandboxPolicy { ReadOnly, #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] + ExternalSandbox { + #[serde(default)] + network_access: NetworkAccess, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] WorkspaceWrite { #[serde(default)] writable_roots: Vec, @@ -498,6 +514,14 @@ impl SandboxPolicy { codex_protocol::protocol::SandboxPolicy::DangerFullAccess } SandboxPolicy::ReadOnly => codex_protocol::protocol::SandboxPolicy::ReadOnly, + SandboxPolicy::ExternalSandbox { network_access } => { + codex_protocol::protocol::SandboxPolicy::ExternalSandbox { + network_access: match network_access { + NetworkAccess::Restricted => CoreNetworkAccess::Restricted, + NetworkAccess::Enabled => CoreNetworkAccess::Enabled, + }, + } + } SandboxPolicy::WorkspaceWrite { writable_roots, network_access, @@ -520,6 +544,14 @@ impl From for SandboxPolicy { SandboxPolicy::DangerFullAccess } codex_protocol::protocol::SandboxPolicy::ReadOnly => SandboxPolicy::ReadOnly, + codex_protocol::protocol::SandboxPolicy::ExternalSandbox { network_access } => { + SandboxPolicy::ExternalSandbox { + network_access: match network_access { + CoreNetworkAccess::Restricted => NetworkAccess::Restricted, + CoreNetworkAccess::Enabled => NetworkAccess::Enabled, + }, + } + } codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { writable_roots, network_access, @@ -1916,11 +1948,30 @@ mod tests { use codex_protocol::items::TurnItem; use codex_protocol::items::UserMessageItem; use codex_protocol::items::WebSearchItem; + use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; use codex_protocol::user_input::UserInput as CoreUserInput; use pretty_assertions::assert_eq; use serde_json::json; use std::path::PathBuf; + #[test] + fn sandbox_policy_round_trips_external_sandbox_network_access() { + let v2_policy = SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Enabled, + }; + + let core_policy = v2_policy.to_core(); + assert_eq!( + core_policy, + codex_protocol::protocol::SandboxPolicy::ExternalSandbox { + network_access: CoreNetworkAccess::Enabled, + } + ); + + let back_to_v2 = SandboxPolicy::from(core_policy); + assert_eq!(back_to_v2, v2_policy); + } + #[test] fn core_turn_item_into_thread_item_converts_supported_variants() { let user_item = TurnItem::UserMessage(UserMessageItem { diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 2f141c4e1..f22758182 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -172,7 +172,7 @@ You can optionally specify config overrides on the new turn. If specified, these "cwd": "/Users/me/project", "approvalPolicy": "unlessTrusted", "sandboxPolicy": { - "mode": "workspaceWrite", + "type": "workspaceWrite", "writableRoots": ["/Users/me/project"], "networkAccess": true }, @@ -285,10 +285,12 @@ Run a standalone command (argv vector) in the server’s sandbox without creatin { "id": 32, "result": { "exitCode": 0, "stdout": "...", "stderr": "" } } ``` +- For clients that are already sandboxed externally, set `sandboxPolicy` to `{"type":"externalSandbox","networkAccess":"enabled"}` (or omit `networkAccess` to keep it restricted). Codex will not enforce its own sandbox in this mode; it tells the model it has full file-system access and passes the `networkAccess` state through `environment_context`. + Notes: - Empty `command` arrays are rejected. -- `sandboxPolicy` accepts the same shape used by `turn/start` (e.g., `dangerFullAccess`, `readOnly`, `workspaceWrite` with flags). +- `sandboxPolicy` accepts the same shape used by `turn/start` (e.g., `dangerFullAccess`, `readOnly`, `workspaceWrite` with flags, `externalSandbox` with `networkAccess` `restricted|enabled`). - When omitted, `timeoutMs` falls back to the server default. ## Events diff --git a/codex-rs/common/Cargo.toml b/codex-rs/common/Cargo.toml index 25264eff0..cd7b8dfe3 100644 --- a/codex-rs/common/Cargo.toml +++ b/codex-rs/common/Cargo.toml @@ -21,3 +21,10 @@ toml = { workspace = true, optional = true } cli = ["clap", "serde", "toml"] elapsed = [] sandbox_summary = [] + +[dev-dependencies] +clap = { workspace = true, features = ["derive", "wrap_help"] } +codex-utils-absolute-path = { workspace = true } +pretty_assertions = { workspace = true } +serde = { workspace = true } +toml = { workspace = true } diff --git a/codex-rs/common/src/sandbox_mode_cli_arg.rs b/codex-rs/common/src/sandbox_mode_cli_arg.rs index fa5662ce6..18935840f 100644 --- a/codex-rs/common/src/sandbox_mode_cli_arg.rs +++ b/codex-rs/common/src/sandbox_mode_cli_arg.rs @@ -26,3 +26,22 @@ impl From for SandboxMode { } } } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn maps_cli_args_to_protocol_modes() { + assert_eq!(SandboxMode::ReadOnly, SandboxModeCliArg::ReadOnly.into()); + assert_eq!( + SandboxMode::WorkspaceWrite, + SandboxModeCliArg::WorkspaceWrite.into() + ); + assert_eq!( + SandboxMode::DangerFullAccess, + SandboxModeCliArg::DangerFullAccess.into() + ); + } +} diff --git a/codex-rs/common/src/sandbox_summary.rs b/codex-rs/common/src/sandbox_summary.rs index 66e00cd45..45520b11a 100644 --- a/codex-rs/common/src/sandbox_summary.rs +++ b/codex-rs/common/src/sandbox_summary.rs @@ -1,9 +1,17 @@ +use codex_core::protocol::NetworkAccess; use codex_core::protocol::SandboxPolicy; pub fn summarize_sandbox_policy(sandbox_policy: &SandboxPolicy) -> String { match sandbox_policy { SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(), SandboxPolicy::ReadOnly => "read-only".to_string(), + SandboxPolicy::ExternalSandbox { network_access } => { + let mut summary = "external-sandbox".to_string(); + if matches!(network_access, NetworkAccess::Enabled) { + summary.push_str(" (network access enabled)"); + } + summary + } SandboxPolicy::WorkspaceWrite { writable_roots, network_access, @@ -34,3 +42,45 @@ pub fn summarize_sandbox_policy(sandbox_policy: &SandboxPolicy) -> String { } } } + +#[cfg(test)] +mod tests { + use super::*; + use codex_utils_absolute_path::AbsolutePathBuf; + use pretty_assertions::assert_eq; + + #[test] + fn summarizes_external_sandbox_without_network_access_suffix() { + let summary = summarize_sandbox_policy(&SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Restricted, + }); + assert_eq!(summary, "external-sandbox"); + } + + #[test] + fn summarizes_external_sandbox_with_enabled_network() { + let summary = summarize_sandbox_policy(&SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Enabled, + }); + assert_eq!(summary, "external-sandbox (network access enabled)"); + } + + #[test] + fn workspace_write_summary_still_includes_network_access() { + let root = if cfg!(windows) { "C:\\repo" } else { "/repo" }; + let writable_root = AbsolutePathBuf::try_from(root).unwrap(); + let summary = summarize_sandbox_policy(&SandboxPolicy::WorkspaceWrite { + writable_roots: vec![writable_root.clone()], + network_access: true, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }); + assert_eq!( + summary, + format!( + "workspace-write [workdir, {}] (network access enabled)", + writable_root.to_string_lossy() + ) + ); + } +} diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index bb1db41dc..7cb0eb670 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -61,7 +61,6 @@ sha1 = { workspace = true } sha2 = { workspace = true } shlex = { workspace = true } similar = { workspace = true } -strum_macros = { workspace = true } tempfile = { workspace = true } test-case = "3.3.1" test-log = { workspace = true } diff --git a/codex-rs/core/src/command_safety/is_dangerous_command.rs b/codex-rs/core/src/command_safety/is_dangerous_command.rs index 96f73f3e8..014cd7c0f 100644 --- a/codex-rs/core/src/command_safety/is_dangerous_command.rs +++ b/codex-rs/core/src/command_safety/is_dangerous_command.rs @@ -21,8 +21,11 @@ pub fn requires_initial_appoval( match policy { AskForApproval::Never | AskForApproval::OnFailure => false, AskForApproval::OnRequest => { - // In DangerFullAccess, only prompt if the command looks dangerous. - if matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) { + // In DangerFullAccess or ExternalSandbox, only prompt if the command looks dangerous. + if matches!( + sandbox_policy, + SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } + ) { return command_might_be_dangerous(command); } @@ -83,6 +86,7 @@ fn is_dangerous_to_call_with_exec(command: &[String]) -> bool { #[cfg(test)] mod tests { use super::*; + use codex_protocol::protocol::NetworkAccess; fn vec_str(items: &[&str]) -> Vec { items.iter().map(std::string::ToString::to_string).collect() @@ -150,4 +154,23 @@ mod tests { fn rm_f_is_dangerous() { assert!(command_might_be_dangerous(&vec_str(&["rm", "-f", "/"]))); } + + #[test] + fn external_sandbox_only_prompts_for_dangerous_commands() { + let external_policy = SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Restricted, + }; + assert!(!requires_initial_appoval( + AskForApproval::OnRequest, + &external_policy, + &vec_str(&["ls"]), + SandboxPermissions::UseDefault, + )); + assert!(requires_initial_appoval( + AskForApproval::OnRequest, + &external_policy, + &vec_str(&["rm", "-rf", "/"]), + SandboxPermissions::UseDefault, + )); + } } diff --git a/codex-rs/core/src/environment_context.rs b/codex-rs/core/src/environment_context.rs index fc4ae174d..6a0e0f26c 100644 --- a/codex-rs/core/src/environment_context.rs +++ b/codex-rs/core/src/environment_context.rs @@ -1,10 +1,6 @@ -use codex_utils_absolute_path::AbsolutePathBuf; -use serde::Deserialize; -use serde::Serialize; -use strum_macros::Display as DeriveDisplay; - use crate::codex::TurnContext; use crate::protocol::AskForApproval; +use crate::protocol::NetworkAccess; use crate::protocol::SandboxPolicy; use crate::shell::Shell; use codex_protocol::config_types::SandboxMode; @@ -12,15 +8,11 @@ use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG; use codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; +use codex_utils_absolute_path::AbsolutePathBuf; +use serde::Deserialize; +use serde::Serialize; use std::path::PathBuf; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, DeriveDisplay)] -#[serde(rename_all = "kebab-case")] -#[strum(serialize_all = "kebab-case")] -pub enum NetworkAccess { - Restricted, - Enabled, -} #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename = "environment_context", rename_all = "snake_case")] pub(crate) struct EnvironmentContext { @@ -45,12 +37,14 @@ impl EnvironmentContext { sandbox_mode: match sandbox_policy { Some(SandboxPolicy::DangerFullAccess) => Some(SandboxMode::DangerFullAccess), Some(SandboxPolicy::ReadOnly) => Some(SandboxMode::ReadOnly), + Some(SandboxPolicy::ExternalSandbox { .. }) => Some(SandboxMode::DangerFullAccess), Some(SandboxPolicy::WorkspaceWrite { .. }) => Some(SandboxMode::WorkspaceWrite), None => None, }, network_access: match sandbox_policy { Some(SandboxPolicy::DangerFullAccess) => Some(NetworkAccess::Enabled), Some(SandboxPolicy::ReadOnly) => Some(NetworkAccess::Restricted), + Some(SandboxPolicy::ExternalSandbox { network_access }) => Some(network_access), Some(SandboxPolicy::WorkspaceWrite { network_access, .. }) => { if network_access { Some(NetworkAccess::Enabled) @@ -272,6 +266,48 @@ mod tests { assert_eq!(context.serialize_to_xml(), expected); } + #[test] + fn serialize_external_sandbox_environment_context() { + let context = EnvironmentContext::new( + None, + Some(AskForApproval::OnRequest), + Some(SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Enabled, + }), + fake_shell(), + ); + + let expected = r#" + on-request + danger-full-access + enabled + bash +"#; + + assert_eq!(context.serialize_to_xml(), expected); + } + + #[test] + fn serialize_external_sandbox_with_restricted_network_environment_context() { + let context = EnvironmentContext::new( + None, + Some(AskForApproval::OnRequest), + Some(SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Restricted, + }), + fake_shell(), + ); + + let expected = r#" + on-request + danger-full-access + restricted + bash +"#; + + assert_eq!(context.serialize_to_xml(), expected); + } + #[test] fn serialize_full_access_environment_context() { let context = EnvironmentContext::new( diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index da113ae42..52a28d575 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -135,7 +135,9 @@ pub async fn process_exec_tool_call( stdout_stream: Option, ) -> Result { let sandbox_type = match &sandbox_policy { - SandboxPolicy::DangerFullAccess => SandboxType::None, + SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { + SandboxType::None + } _ => get_platform_sandbox().unwrap_or(SandboxType::None), }; tracing::debug!("Sandbox type: {sandbox_type:?}"); @@ -523,7 +525,10 @@ async fn exec( ) -> Result { #[cfg(target_os = "windows")] if sandbox == SandboxType::WindowsRestrictedToken - && !matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) + && !matches!( + sandbox_policy, + SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } + ) { return exec_windows_sandbox(params, sandbox_policy).await; } diff --git a/codex-rs/core/src/safety.rs b/codex-rs/core/src/safety.rs index 0f3fc9f4e..c3930b4f4 100644 --- a/codex-rs/core/src/safety.rs +++ b/codex-rs/core/src/safety.rs @@ -91,7 +91,10 @@ pub fn assess_patch_safety( if is_write_patch_constrained_to_writable_paths(action, sandbox_policy, cwd) || policy == AskForApproval::OnFailure { - if matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) { + if matches!( + sandbox_policy, + SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } + ) { // DangerFullAccess is intended to bypass sandboxing entirely. SafetyCheck::AutoApprove { sandbox_type: SandboxType::None, @@ -147,7 +150,7 @@ fn is_write_patch_constrained_to_writable_paths( SandboxPolicy::ReadOnly => { return false; } - SandboxPolicy::DangerFullAccess => { + SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { return true; } SandboxPolicy::WorkspaceWrite { .. } => sandbox_policy.get_writable_roots_with_cwd(cwd), @@ -262,4 +265,23 @@ mod tests { &cwd, )); } + + #[test] + fn external_sandbox_auto_approves_in_on_request() { + let tmp = TempDir::new().unwrap(); + let cwd = tmp.path().to_path_buf(); + let add_inside = ApplyPatchAction::new_add_for_test(&cwd.join("inner.txt"), "".to_string()); + + let policy = SandboxPolicy::ExternalSandbox { + network_access: codex_protocol::protocol::NetworkAccess::Enabled, + }; + + assert_eq!( + assess_patch_safety(&add_inside, AskForApproval::OnRequest, &policy, &cwd,), + SafetyCheck::AutoApprove { + sandbox_type: SandboxType::None, + user_explicitly_approved: false, + } + ); + } } diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index f751287b2..a2c8ad1e3 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -85,7 +85,9 @@ impl SandboxManager { crate::safety::get_platform_sandbox().unwrap_or(SandboxType::None) } SandboxablePreference::Auto => match policy { - SandboxPolicy::DangerFullAccess => SandboxType::None, + SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { + SandboxType::None + } _ => crate::safety::get_platform_sandbox().unwrap_or(SandboxType::None), }, } diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 96bc633c5..14dda62a8 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -132,7 +132,10 @@ pub(crate) fn default_exec_approval_requirement( ) -> ExecApprovalRequirement { let needs_approval = match policy { AskForApproval::Never | AskForApproval::OnFailure => false, - AskForApproval::OnRequest => !matches!(sandbox_policy, SandboxPolicy::DangerFullAccess), + AskForApproval::OnRequest => !matches!( + sandbox_policy, + SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } + ), AskForApproval::UnlessTrusted => true, }; @@ -253,3 +256,37 @@ impl<'a> SandboxAttempt<'a> { ) } } + +#[cfg(test)] +mod tests { + use super::*; + use codex_protocol::protocol::NetworkAccess; + use pretty_assertions::assert_eq; + + #[test] + fn external_sandbox_skips_exec_approval_on_request() { + assert_eq!( + default_exec_approval_requirement( + AskForApproval::OnRequest, + &SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Restricted, + }, + ), + ExecApprovalRequirement::Skip { + bypass_sandbox: false, + proposed_execpolicy_amendment: None, + } + ); + } + + #[test] + fn restricted_sandbox_requires_exec_approval_on_request() { + assert_eq!( + default_exec_approval_requirement(AskForApproval::OnRequest, &SandboxPolicy::ReadOnly), + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: None, + } + ); + } +} diff --git a/codex-rs/docs/codex_mcp_interface.md b/codex-rs/docs/codex_mcp_interface.md index a7236e363..124e2f91d 100644 --- a/codex-rs/docs/codex_mcp_interface.md +++ b/codex-rs/docs/codex_mcp_interface.md @@ -59,7 +59,7 @@ Request `newConversation` params (subset): - `profile`: optional named profile - `cwd`: optional working directory - `approvalPolicy`: `untrusted` | `on-request` | `on-failure` | `never` -- `sandbox`: `read-only` | `workspace-write` | `danger-full-access` +- `sandbox`: `read-only` | `workspace-write` | `external-sandbox` (honors `networkAccess` restricted/enabled) | `danger-full-access` - `config`: map of additional config overrides - `baseInstructions`: optional instruction override - `compactPrompt`: optional replacement for the default compaction prompt diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index d26d8318a..6417e1bce 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -268,6 +268,24 @@ pub enum AskForApproval { Never, } +/// Represents whether outbound network access is available to the agent. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Display, Default, JsonSchema, TS, +)] +#[serde(rename_all = "kebab-case")] +#[strum(serialize_all = "kebab-case")] +pub enum NetworkAccess { + #[default] + Restricted, + Enabled, +} + +impl NetworkAccess { + pub fn is_enabled(self) -> bool { + matches!(self, NetworkAccess::Enabled) + } +} + /// Determines execution restrictions for model shell commands. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Display, JsonSchema, TS)] #[strum(serialize_all = "kebab-case")] @@ -281,6 +299,15 @@ pub enum SandboxPolicy { #[serde(rename = "read-only")] ReadOnly, + /// Indicates the process is already in an external sandbox. Allows full + /// disk access while honoring the provided network setting. + #[serde(rename = "external-sandbox")] + ExternalSandbox { + /// Whether the external sandbox permits outbound network traffic. + #[serde(default)] + network_access: NetworkAccess, + }, + /// Same as `ReadOnly` but additionally grants write access to the current /// working directory ("workspace"). #[serde(rename = "workspace-write")] @@ -373,6 +400,7 @@ impl SandboxPolicy { pub fn has_full_disk_write_access(&self) -> bool { match self { SandboxPolicy::DangerFullAccess => true, + SandboxPolicy::ExternalSandbox { .. } => true, SandboxPolicy::ReadOnly => false, SandboxPolicy::WorkspaceWrite { .. } => false, } @@ -381,6 +409,7 @@ impl SandboxPolicy { pub fn has_full_network_access(&self) -> bool { match self { SandboxPolicy::DangerFullAccess => true, + SandboxPolicy::ExternalSandbox { network_access } => network_access.is_enabled(), SandboxPolicy::ReadOnly => false, SandboxPolicy::WorkspaceWrite { network_access, .. } => *network_access, } @@ -392,6 +421,7 @@ impl SandboxPolicy { pub fn get_writable_roots_with_cwd(&self, cwd: &Path) -> Vec { match self { SandboxPolicy::DangerFullAccess => Vec::new(), + SandboxPolicy::ExternalSandbox { .. } => Vec::new(), SandboxPolicy::ReadOnly => Vec::new(), SandboxPolicy::WorkspaceWrite { writable_roots, @@ -1830,6 +1860,21 @@ mod tests { use serde_json::json; use tempfile::NamedTempFile; + #[test] + fn external_sandbox_reports_full_access_flags() { + let restricted = SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Restricted, + }; + assert!(restricted.has_full_disk_write_access()); + assert!(!restricted.has_full_network_access()); + + let enabled = SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Enabled, + }; + assert!(enabled.has_full_disk_write_access()); + assert!(enabled.has_full_network_access()); + } + #[test] fn item_started_event_from_web_search_emits_begin_event() { let event = ItemStartedEvent { diff --git a/codex-rs/tui/src/additional_dirs.rs b/codex-rs/tui/src/additional_dirs.rs index cc43f3294..54746c170 100644 --- a/codex-rs/tui/src/additional_dirs.rs +++ b/codex-rs/tui/src/additional_dirs.rs @@ -13,7 +13,9 @@ pub fn add_dir_warning_message( } match sandbox_policy { - SandboxPolicy::WorkspaceWrite { .. } | SandboxPolicy::DangerFullAccess => None, + SandboxPolicy::WorkspaceWrite { .. } + | SandboxPolicy::DangerFullAccess + | SandboxPolicy::ExternalSandbox { .. } => None, SandboxPolicy::ReadOnly => Some(format_warning(additional_dirs)), } } @@ -32,6 +34,7 @@ fn format_warning(additional_dirs: &[PathBuf]) -> String { #[cfg(test)] mod tests { use super::add_dir_warning_message; + use codex_core::protocol::NetworkAccess; use codex_core::protocol::SandboxPolicy; use pretty_assertions::assert_eq; use std::path::PathBuf; @@ -50,6 +53,15 @@ mod tests { assert_eq!(add_dir_warning_message(&dirs, &sandbox), None); } + #[test] + fn returns_none_for_external_sandbox() { + let sandbox = SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Enabled, + }; + let dirs = vec![PathBuf::from("/tmp/example")]; + assert_eq!(add_dir_warning_message(&dirs, &sandbox), None); + } + #[test] fn warns_for_read_only() { let sandbox = SandboxPolicy::ReadOnly; diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index aac981c76..2b15d2200 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -8,6 +8,7 @@ use chrono::Local; use codex_common::create_config_summary_entries; use codex_core::config::Config; use codex_core::openai_models::model_family::ModelFamily; +use codex_core::protocol::NetworkAccess; use codex_core::protocol::SandboxPolicy; use codex_core::protocol::TokenUsage; use codex_protocol::ConversationId; @@ -122,6 +123,13 @@ impl StatusHistoryCell { SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(), SandboxPolicy::ReadOnly => "read-only".to_string(), SandboxPolicy::WorkspaceWrite { .. } => "workspace-write".to_string(), + SandboxPolicy::ExternalSandbox { network_access } => { + if matches!(network_access, NetworkAccess::Enabled) { + "external-sandbox (network access enabled)".to_string() + } else { + "external-sandbox".to_string() + } + } }; let agents_summary = compose_agents_summary(config); let account = compose_account_display(auth_manager, plan_type); diff --git a/codex-rs/tui2/src/additional_dirs.rs b/codex-rs/tui2/src/additional_dirs.rs index cc43f3294..54746c170 100644 --- a/codex-rs/tui2/src/additional_dirs.rs +++ b/codex-rs/tui2/src/additional_dirs.rs @@ -13,7 +13,9 @@ pub fn add_dir_warning_message( } match sandbox_policy { - SandboxPolicy::WorkspaceWrite { .. } | SandboxPolicy::DangerFullAccess => None, + SandboxPolicy::WorkspaceWrite { .. } + | SandboxPolicy::DangerFullAccess + | SandboxPolicy::ExternalSandbox { .. } => None, SandboxPolicy::ReadOnly => Some(format_warning(additional_dirs)), } } @@ -32,6 +34,7 @@ fn format_warning(additional_dirs: &[PathBuf]) -> String { #[cfg(test)] mod tests { use super::add_dir_warning_message; + use codex_core::protocol::NetworkAccess; use codex_core::protocol::SandboxPolicy; use pretty_assertions::assert_eq; use std::path::PathBuf; @@ -50,6 +53,15 @@ mod tests { assert_eq!(add_dir_warning_message(&dirs, &sandbox), None); } + #[test] + fn returns_none_for_external_sandbox() { + let sandbox = SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Enabled, + }; + let dirs = vec![PathBuf::from("/tmp/example")]; + assert_eq!(add_dir_warning_message(&dirs, &sandbox), None); + } + #[test] fn warns_for_read_only() { let sandbox = SandboxPolicy::ReadOnly; diff --git a/codex-rs/tui2/src/status/card.rs b/codex-rs/tui2/src/status/card.rs index aac981c76..2b15d2200 100644 --- a/codex-rs/tui2/src/status/card.rs +++ b/codex-rs/tui2/src/status/card.rs @@ -8,6 +8,7 @@ use chrono::Local; use codex_common::create_config_summary_entries; use codex_core::config::Config; use codex_core::openai_models::model_family::ModelFamily; +use codex_core::protocol::NetworkAccess; use codex_core::protocol::SandboxPolicy; use codex_core::protocol::TokenUsage; use codex_protocol::ConversationId; @@ -122,6 +123,13 @@ impl StatusHistoryCell { SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(), SandboxPolicy::ReadOnly => "read-only".to_string(), SandboxPolicy::WorkspaceWrite { .. } => "workspace-write".to_string(), + SandboxPolicy::ExternalSandbox { network_access } => { + if matches!(network_access, NetworkAccess::Enabled) { + "external-sandbox (network access enabled)".to_string() + } else { + "external-sandbox".to_string() + } + } }; let agents_summary = compose_agents_summary(config); let account = compose_account_display(auth_manager, plan_type); diff --git a/codex-rs/windows-sandbox-rs/Cargo.toml b/codex-rs/windows-sandbox-rs/Cargo.toml index 289988adb..eec3925ff 100644 --- a/codex-rs/windows-sandbox-rs/Cargo.toml +++ b/codex-rs/windows-sandbox-rs/Cargo.toml @@ -77,6 +77,7 @@ features = [ version = "0.52" [dev-dependencies] +pretty_assertions = { workspace = true } tempfile = "3" [build-dependencies] diff --git a/codex-rs/windows-sandbox-rs/src/audit.rs b/codex-rs/windows-sandbox-rs/src/audit.rs index 4385a3350..9e02f86c1 100644 --- a/codex-rs/windows-sandbox-rs/src/audit.rs +++ b/codex-rs/windows-sandbox-rs/src/audit.rs @@ -271,7 +271,7 @@ pub fn apply_capability_denies_for_world_writable( })?, Vec::new(), ), - SandboxPolicy::DangerFullAccess => { + SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { return Ok(()); } }; diff --git a/codex-rs/windows-sandbox-rs/src/command_runner_win.rs b/codex-rs/windows-sandbox-rs/src/command_runner_win.rs index 806a8777d..717138335 100644 --- a/codex-rs/windows-sandbox-rs/src/command_runner_win.rs +++ b/codex-rs/windows-sandbox-rs/src/command_runner_win.rs @@ -106,7 +106,9 @@ pub fn main() -> Result<()> { SandboxPolicy::WorkspaceWrite { .. } => { create_workspace_write_token_with_cap_from(base, psid_cap) } - SandboxPolicy::DangerFullAccess => unreachable!(), + SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { + unreachable!() + } } }; let (h_token, psid_to_use) = token_res?; diff --git a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs index bf3d50147..fb75e6f20 100644 --- a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs +++ b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs @@ -239,8 +239,11 @@ mod windows_impl { require_logon_sandbox_creds(&policy, sandbox_policy_cwd, cwd, &env_map, codex_home)?; log_note("cli creds ready", logs_base_dir); // Build capability SID for ACL grants. - if matches!(&policy, SandboxPolicy::DangerFullAccess) { - anyhow::bail!("DangerFullAccess is not supported for sandboxing") + if matches!( + &policy, + SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } + ) { + anyhow::bail!("DangerFullAccess and ExternalSandbox are not supported for sandboxing") } let caps = load_or_create_cap_sids(codex_home)?; let (psid_to_use, cap_sid_str) = match &policy { @@ -252,7 +255,9 @@ mod windows_impl { unsafe { convert_string_sid_to_sid(&caps.workspace).unwrap() }, caps.workspace.clone(), ), - SandboxPolicy::DangerFullAccess => unreachable!("DangerFullAccess handled above"), + SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { + unreachable!("DangerFullAccess handled above") + } }; let AllowDenyPaths { allow: _, deny: _ } = diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index 7373b7ad4..3a1c5c82a 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -194,8 +194,11 @@ mod windows_impl { log_start(&command, logs_base_dir); let is_workspace_write = matches!(&policy, SandboxPolicy::WorkspaceWrite { .. }); - if matches!(&policy, SandboxPolicy::DangerFullAccess) { - anyhow::bail!("DangerFullAccess is not supported for sandboxing") + if matches!( + &policy, + SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } + ) { + anyhow::bail!("DangerFullAccess and ExternalSandbox are not supported for sandboxing") } let caps = load_or_create_cap_sids(codex_home)?; let (h_token, psid_to_use): (HANDLE, *mut c_void) = unsafe { @@ -208,7 +211,9 @@ mod windows_impl { let psid = convert_string_sid_to_sid(&caps.workspace).unwrap(); super::token::create_workspace_write_token_with_cap(psid)? } - SandboxPolicy::DangerFullAccess => unreachable!("DangerFullAccess handled above"), + SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { + unreachable!("DangerFullAccess handled above") + } } }; diff --git a/codex-rs/windows-sandbox-rs/src/policy.rs b/codex-rs/windows-sandbox-rs/src/policy.rs index 4c62c71df..64fc56052 100644 --- a/codex-rs/windows-sandbox-rs/src/policy.rs +++ b/codex-rs/windows-sandbox-rs/src/policy.rs @@ -5,13 +5,53 @@ pub fn parse_policy(value: &str) -> Result { match value { "read-only" => Ok(SandboxPolicy::ReadOnly), "workspace-write" => Ok(SandboxPolicy::new_workspace_write_policy()), - "danger-full-access" => anyhow::bail!("DangerFullAccess is not supported for sandboxing"), + "danger-full-access" | "external-sandbox" => anyhow::bail!( + "DangerFullAccess and ExternalSandbox are not supported for sandboxing" + ), other => { let parsed: SandboxPolicy = serde_json::from_str(other)?; - if matches!(parsed, SandboxPolicy::DangerFullAccess) { - anyhow::bail!("DangerFullAccess is not supported for sandboxing"); + if matches!( + parsed, + SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } + ) { + anyhow::bail!( + "DangerFullAccess and ExternalSandbox are not supported for sandboxing" + ); } Ok(parsed) } } } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn rejects_external_sandbox_preset() { + let err = parse_policy("external-sandbox").unwrap_err(); + assert!(err + .to_string() + .contains("DangerFullAccess and ExternalSandbox are not supported")); + } + + #[test] + fn rejects_external_sandbox_json() { + let payload = serde_json::to_string( + &codex_protocol::protocol::SandboxPolicy::ExternalSandbox { + network_access: codex_protocol::protocol::NetworkAccess::Enabled, + }, + ) + .unwrap(); + let err = parse_policy(&payload).unwrap_err(); + assert!(err + .to_string() + .contains("DangerFullAccess and ExternalSandbox are not supported")); + } + + #[test] + fn parses_read_only_policy() { + assert_eq!(parse_policy("read-only").unwrap(), SandboxPolicy::ReadOnly); + } +} diff --git a/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs b/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs index a008fc721..c26d54481 100644 --- a/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs +++ b/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs @@ -52,7 +52,10 @@ pub fn run_setup_refresh( codex_home: &Path, ) -> Result<()> { // Skip in danger-full-access. - if matches!(policy, SandboxPolicy::DangerFullAccess) { + if matches!( + policy, + SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } + ) { return Ok(()); } let (read_roots, write_roots) = build_payload_roots(