From a494f4dfae909024e6a873891b85521ff8df9f35 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 19 Jun 2026 17:26:38 +0200 Subject: [PATCH 01/13] Carry sandbox intent to remote exec servers --- codex-rs/core/src/exec.rs | 2 + codex-rs/core/src/sandboxing/mod.rs | 7 +++ codex-rs/core/src/tasks/user_shell.rs | 2 + codex-rs/core/src/tools/orchestrator.rs | 39 ++++++++++++---- .../src/tools/runtimes/apply_patch_tests.rs | 2 + codex-rs/core/src/tools/runtimes/mod_tests.rs | 1 + .../tools/runtimes/shell/unix_escalation.rs | 4 ++ codex-rs/core/src/tools/sandboxing.rs | 19 +++++++- codex-rs/core/src/tools/sandboxing_tests.rs | 19 ++++++-- .../core/src/unified_exec/process_manager.rs | 2 + .../src/unified_exec/process_manager_tests.rs | 2 + codex-rs/exec-server/src/environment.rs | 2 + codex-rs/exec-server/src/local_process.rs | 2 + codex-rs/exec-server/src/protocol.rs | 6 +++ .../exec-server/src/server/handler/tests.rs | 2 + codex-rs/exec-server/src/server/processor.rs | 2 + codex-rs/exec-server/tests/exec_process.rs | 24 ++++++++++ codex-rs/exec-server/tests/relay.rs | 2 + .../rmcp-client/src/stdio_server_launcher.rs | 2 + codex-rs/sandboxing/src/manager.rs | 46 ++++++++++++------- 20 files changed, 156 insertions(+), 31 deletions(-) diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 9d55ae886401..99c87cdd99ea 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -456,6 +456,8 @@ pub(crate) async fn execute_exec_request( windows_sandbox_filesystem_overrides, network_environment_id, arg0, + exec_server_sandbox: _, + exec_server_enforce_managed_network: _, } = exec_request; // TODO(anp): Keep PathUri through the local process launch boundary. diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index c156b0d70eb4..d0006f8657ed 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -14,6 +14,7 @@ use crate::exec::execute_exec_request; #[cfg(target_os = "macos")] use crate::spawn::CODEX_SANDBOX_ENV_VAR; use crate::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; +use codex_file_system::FileSystemSandboxContext; use codex_network_proxy::NetworkProxy; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::exec_output::ExecToolCallOutput; @@ -60,6 +61,8 @@ pub struct ExecRequest { pub network_sandbox_policy: NetworkSandboxPolicy, pub(crate) windows_sandbox_filesystem_overrides: Option, pub arg0: Option, + pub(crate) exec_server_sandbox: Option, + pub(crate) exec_server_enforce_managed_network: bool, } impl ExecRequest { @@ -102,6 +105,8 @@ impl ExecRequest { network_sandbox_policy, windows_sandbox_filesystem_overrides: None, arg0, + exec_server_sandbox: None, + exec_server_enforce_managed_network: false, } } @@ -158,6 +163,8 @@ impl ExecRequest { network_sandbox_policy, windows_sandbox_filesystem_overrides: None, arg0, + exec_server_sandbox: None, + exec_server_enforce_managed_network: false, } } } diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index c5b44a64946c..844e327bdf90 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -224,6 +224,8 @@ pub(crate) async fn execute_user_shell_command( network_sandbox_policy: permission_profile.network_sandbox_policy(), windows_sandbox_filesystem_overrides: None, arg0: None, + exec_server_sandbox: None, + exec_server_enforce_managed_network: false, }; let stdout_stream = Some(StdoutStream { diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 803b8d6a9fd3..54a07c12b05a 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -84,6 +84,7 @@ impl ToolOrchestrator { }; let attempt_with_network_approval = SandboxAttempt { sandbox: attempt.sandbox, + sandbox_requested: attempt.sandbox_requested, permissions: attempt.permissions, enforce_managed_network: attempt.enforce_managed_network, manager: attempt.manager, @@ -225,16 +226,27 @@ impl ToolOrchestrator { &file_system_sandbox_policy, ); let managed_network_active = turn_ctx.network.is_some(); - let initial_sandbox = match sandbox_override { - SandboxOverride::BypassSandboxFirstAttempt => SandboxType::None, - SandboxOverride::NoOverride => self.sandbox.select_initial( + let sandbox_preference = tool.sandbox_preference(); + let sandbox_requested = match sandbox_override { + SandboxOverride::BypassSandboxFirstAttempt => false, + SandboxOverride::NoOverride => self.sandbox.should_sandbox( &file_system_sandbox_policy, network_sandbox_policy, - tool.sandbox_preference(), - turn_ctx.windows_sandbox_level, + sandbox_preference, managed_network_active, ), }; + let initial_sandbox = if sandbox_requested { + self.sandbox.select_initial( + &file_system_sandbox_policy, + network_sandbox_policy, + sandbox_preference, + turn_ctx.windows_sandbox_level, + managed_network_active, + ) + } else { + SandboxType::None + }; // Platform-specific flag gating is handled by SandboxManager::select_initial. let use_legacy_landlock = turn_ctx.config.features.use_legacy_landlock(); @@ -246,6 +258,7 @@ impl ToolOrchestrator { let workspace_roots = turn_ctx.config.effective_workspace_roots(); let initial_attempt = SandboxAttempt { sandbox: initial_sandbox, + sandbox_requested, permissions: &turn_ctx.permission_profile, enforce_managed_network: managed_network_active, manager: &self.sandbox, @@ -401,16 +414,23 @@ impl ToolOrchestrator { .await?; } - let retry_sandbox = if unsandboxed_allowed { - SandboxType::None - } else { + let retry_sandbox_requested = !unsandboxed_allowed + && self.sandbox.should_sandbox( + &file_system_sandbox_policy, + network_sandbox_policy, + sandbox_preference, + managed_network_active, + ); + let retry_sandbox = if retry_sandbox_requested { self.sandbox.select_initial( &file_system_sandbox_policy, network_sandbox_policy, - tool.sandbox_preference(), + sandbox_preference, turn_ctx.windows_sandbox_level, managed_network_active, ) + } else { + SandboxType::None }; let retry_codex_linux_sandbox_exe = if unsandboxed_allowed { None @@ -419,6 +439,7 @@ impl ToolOrchestrator { }; let retry_attempt = SandboxAttempt { sandbox: retry_sandbox, + sandbox_requested: retry_sandbox_requested, permissions: &turn_ctx.permission_profile, enforce_managed_network: managed_network_active, manager: &self.sandbox, diff --git a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs index 054f2c7c1518..7a90acc96518 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -220,6 +220,7 @@ async fn file_system_sandbox_context_uses_active_attempt() { let sandbox_policy_cwd = PathUri::from_abs_path(&path); let attempt = SandboxAttempt { sandbox: SandboxType::MacosSeatbelt, + sandbox_requested: true, permissions: &permissions, enforce_managed_network: false, manager: &manager, @@ -286,6 +287,7 @@ async fn no_sandbox_attempt_has_no_file_system_context() { let sandbox_policy_cwd = PathUri::from_abs_path(&path); let attempt = SandboxAttempt { sandbox: SandboxType::None, + sandbox_requested: false, permissions: &permissions, enforce_managed_network: false, manager: &manager, diff --git a/codex-rs/core/src/tools/runtimes/mod_tests.rs b/codex-rs/core/src/tools/runtimes/mod_tests.rs index 91db26aaaeab..0846b12580e2 100644 --- a/codex-rs/core/src/tools/runtimes/mod_tests.rs +++ b/codex-rs/core/src/tools/runtimes/mod_tests.rs @@ -106,6 +106,7 @@ async fn explicit_escalation_prepares_exec_without_managed_network() -> anyhow:: let manager = SandboxManager::new(); let attempt = SandboxAttempt { sandbox: SandboxType::None, + sandbox_requested: false, permissions: &permissions, enforce_managed_network: false, manager: &manager, diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index c08148c7ee15..a77645f941fe 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -166,6 +166,8 @@ pub(super) async fn try_run_zsh_fork( network_sandbox_policy, windows_sandbox_filesystem_overrides: _windows_sandbox_filesystem_overrides, arg0, + exec_server_sandbox: _, + exec_server_enforce_managed_network: _, } = sandbox_exec_request; let ParsedShellCommand { script, login, .. } = extract_shell_script(&command)?; let effective_timeout = Duration::from_millis( @@ -898,6 +900,8 @@ impl CoreShellCommandExecutor { network_sandbox_policy: self.network_sandbox_policy, windows_sandbox_filesystem_overrides: None, arg0: self.arg0.clone(), + exec_server_sandbox: None, + exec_server_enforce_managed_network: false, }, /*stdout_stream*/ None, after_spawn, diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 847a3c758436..ca88a6d66f96 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -11,6 +11,7 @@ use crate::session::turn_context::TurnContext; use crate::state::SessionServices; use crate::tools::hook_names::HookToolName; use crate::tools::network_approval::NetworkApprovalSpec; +use codex_file_system::FileSystemSandboxContext; use codex_network_proxy::NetworkProxy; use codex_protocol::approvals::ExecPolicyAmendment; use codex_protocol::approvals::NetworkApprovalContext; @@ -408,6 +409,8 @@ pub(crate) trait ToolRuntime: Approvable + Sandboxable { pub(crate) struct SandboxAttempt<'a> { pub sandbox: SandboxType, + /// Whether policy requested sandboxing, independent of this host's concrete wrapper. + pub sandbox_requested: bool, pub permissions: &'a codex_protocol::models::PermissionProfile, pub enforce_managed_network: bool, pub(crate) manager: &'a SandboxManager, @@ -477,11 +480,23 @@ impl<'a> SandboxAttempt<'a> { windows_sandbox_private_desktop: self.windows_sandbox_private_desktop, }) .map_err(CodexErr::from)?; - Ok(crate::sandboxing::ExecRequest::from_sandbox_exec_request( + let mut exec_request = crate::sandboxing::ExecRequest::from_sandbox_exec_request( request, options, self.workspace_roots.to_vec(), - )) + ); + if self.sandbox_requested { + exec_request.exec_server_sandbox = Some(FileSystemSandboxContext { + permissions: exec_request.permission_profile.clone().into(), + cwd: Some(exec_request.windows_sandbox_policy_cwd.clone()), + workspace_roots: Vec::new(), + windows_sandbox_level: self.windows_sandbox_level, + windows_sandbox_private_desktop: self.windows_sandbox_private_desktop, + use_legacy_landlock: self.use_legacy_landlock, + }); + exec_request.exec_server_enforce_managed_network = self.enforce_managed_network; + } + Ok(exec_request) } } diff --git a/codex-rs/core/src/tools/sandboxing_tests.rs b/codex-rs/core/src/tools/sandboxing_tests.rs index f4d6e6ce4273..edd6de38ebb3 100644 --- a/codex-rs/core/src/tools/sandboxing_tests.rs +++ b/codex-rs/core/src/tools/sandboxing_tests.rs @@ -202,7 +202,7 @@ fn deny_read_blocks_explicit_escalation_and_policy_bypass() { } #[test] -fn exec_server_env_keeps_command_native() { +fn exec_server_env_keeps_command_native_and_carries_sandbox_context() { let cwd: AbsolutePathBuf = std::env::current_dir() .expect("current dir") .try_into() @@ -214,9 +214,10 @@ fn exec_server_env_keeps_command_native() { ); let manager = SandboxManager::new(); let attempt = SandboxAttempt { - sandbox: SandboxType::MacosSeatbelt, + sandbox: SandboxType::None, + sandbox_requested: true, permissions: &permissions, - enforce_managed_network: false, + enforce_managed_network: true, manager: &manager, sandbox_cwd: &cwd_uri, workspace_roots: std::slice::from_ref(&cwd), @@ -252,4 +253,16 @@ fn exec_server_env_keeps_command_native() { ); assert_eq!(request.arg0, None); assert_eq!(request.sandbox, SandboxType::None); + assert_eq!( + request.exec_server_sandbox, + Some(codex_exec_server::FileSystemSandboxContext { + permissions: request.permission_profile.clone().into(), + cwd: Some(cwd_uri), + workspace_roots: Vec::new(), + windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, + windows_sandbox_private_desktop: false, + use_legacy_landlock: false, + }) + ); + assert!(request.exec_server_enforce_managed_network); } diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index fc5508eb80c6..a3b4bcf9c76e 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -166,6 +166,8 @@ fn exec_server_params_for_request( tty, pipe_stdin: false, arg0: request.arg0.clone(), + sandbox: request.exec_server_sandbox.clone(), + enforce_managed_network: request.exec_server_enforce_managed_network, } } diff --git a/codex-rs/core/src/unified_exec/process_manager_tests.rs b/codex-rs/core/src/unified_exec/process_manager_tests.rs index 5abd926c62fe..b6afdc85ba1a 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -111,6 +111,8 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { network_sandbox_policy, windows_sandbox_filesystem_overrides: None, arg0: None, + exec_server_sandbox: None, + exec_server_enforce_managed_network: false, }; let params = diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 9496bb8be627..d3e7e5e9815a 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -1156,6 +1156,8 @@ mod tests { tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await .expect("start process"); diff --git a/codex-rs/exec-server/src/local_process.rs b/codex-rs/exec-server/src/local_process.rs index 5e1b52b11c76..1a6f2cf00ff1 100644 --- a/codex-rs/exec-server/src/local_process.rs +++ b/codex-rs/exec-server/src/local_process.rs @@ -902,6 +902,8 @@ mod tests { tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, } } diff --git a/codex-rs/exec-server/src/protocol.rs b/codex-rs/exec-server/src/protocol.rs index 97ef86aad246..e05595f273f6 100644 --- a/codex-rs/exec-server/src/protocol.rs +++ b/codex-rs/exec-server/src/protocol.rs @@ -103,6 +103,12 @@ pub struct ExecParams { /// Optional process-visible argv0 override. Values such as `codex-linux-sandbox` are command /// names rather than paths, so this is not a [`PathUri`]. pub arg0: Option, + /// Portable sandbox intent. Concrete wrapper argv is resolved by the exec-server. + #[serde(default)] + pub sandbox: Option, + /// Whether the eventual executor-side sandbox must enforce managed networking. + #[serde(default)] + pub enforce_managed_network: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/codex-rs/exec-server/src/server/handler/tests.rs b/codex-rs/exec-server/src/server/handler/tests.rs index 0bd682bd96d1..9cbbf78d0055 100644 --- a/codex-rs/exec-server/src/server/handler/tests.rs +++ b/codex-rs/exec-server/src/server/handler/tests.rs @@ -33,6 +33,8 @@ fn exec_params_with_argv(process_id: &str, argv: Vec) -> ExecParams { tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, } } diff --git a/codex-rs/exec-server/src/server/processor.rs b/codex-rs/exec-server/src/server/processor.rs index 952f4142138c..b2daba7caf47 100644 --- a/codex-rs/exec-server/src/server/processor.rs +++ b/codex-rs/exec-server/src/server/processor.rs @@ -403,6 +403,8 @@ mod tests { tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, } } diff --git a/codex-rs/exec-server/tests/exec_process.rs b/codex-rs/exec-server/tests/exec_process.rs index 11a3f3ece7ba..ac38f50eafe7 100644 --- a/codex-rs/exec-server/tests/exec_process.rs +++ b/codex-rs/exec-server/tests/exec_process.rs @@ -81,6 +81,8 @@ async fn assert_exec_process_starts_and_exits(use_remote: bool) -> Result<()> { tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), "proc-1"); @@ -222,6 +224,8 @@ async fn assert_exec_process_streams_output(use_remote: bool) -> Result<()> { tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -253,6 +257,8 @@ async fn assert_exec_process_pushes_events(use_remote: bool) -> Result<()> { tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -300,6 +306,8 @@ async fn assert_exec_process_replays_events_after_close(use_remote: bool) -> Res tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -348,6 +356,8 @@ async fn assert_exec_process_retains_output_after_exit_until_streams_close( tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -421,6 +431,8 @@ async fn assert_exec_process_write_then_read(use_remote: bool) -> Result<()> { tty: true, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -458,6 +470,8 @@ async fn assert_exec_process_write_then_read_without_tty(use_remote: bool) -> Re tty: false, pipe_stdin: true, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -491,6 +505,8 @@ async fn assert_exec_process_rejects_write_without_pipe_stdin(use_remote: bool) tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -525,6 +541,8 @@ async fn assert_exec_process_signal_interrupts_process(use_remote: bool) -> Resu tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -578,6 +596,8 @@ async fn assert_exec_process_signal_reports_unsupported_on_windows(use_remote: b tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; @@ -618,6 +638,8 @@ async fn assert_exec_process_preserves_queued_events_before_subscribe( tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; @@ -676,6 +698,8 @@ async fn remote_exec_process_recovers_after_transport_disconnect() -> Result<()> tty: false, pipe_stdin: true, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; diff --git a/codex-rs/exec-server/tests/relay.rs b/codex-rs/exec-server/tests/relay.rs index 5e58618410e2..5f49655e3939 100644 --- a/codex-rs/exec-server/tests/relay.rs +++ b/codex-rs/exec-server/tests/relay.rs @@ -150,6 +150,8 @@ async fn remote_environment_routes_encrypted_exec_server_rpc() -> Result<()> { tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!( diff --git a/codex-rs/rmcp-client/src/stdio_server_launcher.rs b/codex-rs/rmcp-client/src/stdio_server_launcher.rs index 8ada806ff754..94aa45c1e7dd 100644 --- a/codex-rs/rmcp-client/src/stdio_server_launcher.rs +++ b/codex-rs/rmcp-client/src/stdio_server_launcher.rs @@ -503,6 +503,8 @@ impl ExecutorStdioServerLauncher { tty: false, pipe_stdin: true, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await .map_err(io::Error::other)?; diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs index dd7aba3614cd..29b1177b11de 100644 --- a/codex-rs/sandboxing/src/manager.rs +++ b/codex-rs/sandboxing/src/manager.rs @@ -282,24 +282,36 @@ impl SandboxManager { windows_sandbox_level: WindowsSandboxLevel, has_managed_network_requirements: bool, ) -> SandboxType { + if self.should_sandbox( + file_system_policy, + network_policy, + pref, + has_managed_network_requirements, + ) { + get_platform_sandbox(windows_sandbox_level != WindowsSandboxLevel::Disabled) + .unwrap_or(SandboxType::None) + } else { + SandboxType::None + } + } + + /// Returns whether the request needs a sandbox, independently of whether + /// this host can provide a concrete sandbox implementation. + pub fn should_sandbox( + &self, + file_system_policy: &FileSystemSandboxPolicy, + network_policy: NetworkSandboxPolicy, + pref: SandboxablePreference, + has_managed_network_requirements: bool, + ) -> bool { match pref { - SandboxablePreference::Forbid => SandboxType::None, - SandboxablePreference::Require => { - get_platform_sandbox(windows_sandbox_level != WindowsSandboxLevel::Disabled) - .unwrap_or(SandboxType::None) - } - SandboxablePreference::Auto => { - if should_require_platform_sandbox( - file_system_policy, - network_policy, - has_managed_network_requirements, - ) { - get_platform_sandbox(windows_sandbox_level != WindowsSandboxLevel::Disabled) - .unwrap_or(SandboxType::None) - } else { - SandboxType::None - } - } + SandboxablePreference::Forbid => false, + SandboxablePreference::Require => true, + SandboxablePreference::Auto => should_require_platform_sandbox( + file_system_policy, + network_policy, + has_managed_network_requirements, + ), } } From 67b2819b6b2c0c9de28af45c3080acf0fa98aab2 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 19 Jun 2026 18:15:05 +0200 Subject: [PATCH 02/13] Carry symbolic sandbox intent to exec servers --- codex-rs/core/src/tools/orchestrator.rs | 3 +++ .../core/src/tools/runtimes/apply_patch_tests.rs | 2 ++ codex-rs/core/src/tools/runtimes/mod_tests.rs | 1 + codex-rs/core/src/tools/sandboxing.rs | 15 +++++++++++++-- codex-rs/core/src/tools/sandboxing_tests.rs | 14 +++++++------- 5 files changed, 26 insertions(+), 9 deletions(-) diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 54a07c12b05a..aaa0dfe9d14a 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -86,6 +86,7 @@ impl ToolOrchestrator { sandbox: attempt.sandbox, sandbox_requested: attempt.sandbox_requested, permissions: attempt.permissions, + exec_server_permissions: attempt.exec_server_permissions, enforce_managed_network: attempt.enforce_managed_network, manager: attempt.manager, sandbox_cwd: attempt.sandbox_cwd, @@ -260,6 +261,7 @@ impl ToolOrchestrator { sandbox: initial_sandbox, sandbox_requested, permissions: &turn_ctx.permission_profile, + exec_server_permissions: turn_ctx.config.permissions.permission_profile(), enforce_managed_network: managed_network_active, manager: &self.sandbox, sandbox_cwd: &sandbox_policy_cwd, @@ -441,6 +443,7 @@ impl ToolOrchestrator { sandbox: retry_sandbox, sandbox_requested: retry_sandbox_requested, permissions: &turn_ctx.permission_profile, + exec_server_permissions: turn_ctx.config.permissions.permission_profile(), enforce_managed_network: managed_network_active, manager: &self.sandbox, sandbox_cwd: &sandbox_policy_cwd, diff --git a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs index 7a90acc96518..f1c6f43aa342 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -222,6 +222,7 @@ async fn file_system_sandbox_context_uses_active_attempt() { sandbox: SandboxType::MacosSeatbelt, sandbox_requested: true, permissions: &permissions, + exec_server_permissions: &permissions, enforce_managed_network: false, manager: &manager, sandbox_cwd: &sandbox_policy_cwd, @@ -289,6 +290,7 @@ async fn no_sandbox_attempt_has_no_file_system_context() { sandbox: SandboxType::None, sandbox_requested: false, permissions: &permissions, + exec_server_permissions: &permissions, enforce_managed_network: false, manager: &manager, sandbox_cwd: &sandbox_policy_cwd, diff --git a/codex-rs/core/src/tools/runtimes/mod_tests.rs b/codex-rs/core/src/tools/runtimes/mod_tests.rs index 0846b12580e2..9b485dbe58d3 100644 --- a/codex-rs/core/src/tools/runtimes/mod_tests.rs +++ b/codex-rs/core/src/tools/runtimes/mod_tests.rs @@ -108,6 +108,7 @@ async fn explicit_escalation_prepares_exec_without_managed_network() -> anyhow:: sandbox: SandboxType::None, sandbox_requested: false, permissions: &permissions, + exec_server_permissions: &permissions, enforce_managed_network: false, manager: &manager, sandbox_cwd: &sandbox_policy_cwd, diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index ca88a6d66f96..f9d21dccfa74 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -25,6 +25,7 @@ use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxTransformRequest; use codex_sandboxing::SandboxType; use codex_sandboxing::SandboxablePreference; +use codex_sandboxing::policy_transforms::effective_permission_profile; use codex_tools::ToolName; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; @@ -412,6 +413,8 @@ pub(crate) struct SandboxAttempt<'a> { /// Whether policy requested sandboxing, independent of this host's concrete wrapper. pub sandbox_requested: bool, pub permissions: &'a codex_protocol::models::PermissionProfile, + /// Canonical permissions before this host materializes workspace roots. + pub exec_server_permissions: &'a codex_protocol::models::PermissionProfile, pub enforce_managed_network: bool, pub(crate) manager: &'a SandboxManager, pub(crate) sandbox_cwd: &'a PathUri, @@ -463,6 +466,10 @@ impl<'a> SandboxAttempt<'a> { network: Option<&NetworkProxy>, environment_id: Option<&str>, ) -> Result { + let exec_server_permissions = effective_permission_profile( + self.exec_server_permissions, + command.additional_permissions.as_ref(), + ); let request = self .manager .transform(SandboxTransformRequest { @@ -487,9 +494,13 @@ impl<'a> SandboxAttempt<'a> { ); if self.sandbox_requested { exec_request.exec_server_sandbox = Some(FileSystemSandboxContext { - permissions: exec_request.permission_profile.clone().into(), + permissions: exec_server_permissions.into(), cwd: Some(exec_request.windows_sandbox_policy_cwd.clone()), - workspace_roots: Vec::new(), + workspace_roots: self + .workspace_roots + .iter() + .map(PathUri::from_abs_path) + .collect(), windows_sandbox_level: self.windows_sandbox_level, windows_sandbox_private_desktop: self.windows_sandbox_private_desktop, use_legacy_landlock: self.use_legacy_landlock, diff --git a/codex-rs/core/src/tools/sandboxing_tests.rs b/codex-rs/core/src/tools/sandboxing_tests.rs index edd6de38ebb3..47b0b4e89187 100644 --- a/codex-rs/core/src/tools/sandboxing_tests.rs +++ b/codex-rs/core/src/tools/sandboxing_tests.rs @@ -4,7 +4,6 @@ use crate::tools::hook_names::HookToolName; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; -use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::GranularApprovalConfig; use codex_sandboxing::SandboxCommand; use codex_sandboxing::SandboxManager; @@ -208,15 +207,16 @@ fn exec_server_env_keeps_command_native_and_carries_sandbox_context() { .try_into() .expect("absolute cwd"); let cwd_uri = PathUri::from_abs_path(&cwd); - let permissions = codex_protocol::models::PermissionProfile::from_runtime_permissions( - &FileSystemSandboxPolicy::default(), - NetworkSandboxPolicy::Restricted, - ); + let exec_server_permissions = codex_protocol::models::PermissionProfile::workspace_write(); + let permissions = exec_server_permissions + .clone() + .materialize_project_roots_with_workspace_roots(std::slice::from_ref(&cwd)); let manager = SandboxManager::new(); let attempt = SandboxAttempt { sandbox: SandboxType::None, sandbox_requested: true, permissions: &permissions, + exec_server_permissions: &exec_server_permissions, enforce_managed_network: true, manager: &manager, sandbox_cwd: &cwd_uri, @@ -256,9 +256,9 @@ fn exec_server_env_keeps_command_native_and_carries_sandbox_context() { assert_eq!( request.exec_server_sandbox, Some(codex_exec_server::FileSystemSandboxContext { - permissions: request.permission_profile.clone().into(), + permissions: exec_server_permissions.into(), cwd: Some(cwd_uri), - workspace_roots: Vec::new(), + workspace_roots: vec![PathUri::from_abs_path(&cwd)], windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, use_legacy_landlock: false, From e6169bca39b9d8adc50c49fe3cab6eab5b869ef6 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 19 Jun 2026 18:06:57 +0200 Subject: [PATCH 03/13] Apply sandbox intent inside remote exec servers --- codex-rs/exec-server/src/lib.rs | 1 + codex-rs/exec-server/src/local_process.rs | 49 ++++---- codex-rs/exec-server/src/process_sandbox.rs | 112 ++++++++++++++++++ .../exec-server/src/process_sandbox_tests.rs | 107 +++++++++++++++++ codex-rs/exec-server/src/server/handler.rs | 10 +- .../exec-server/src/server/process_handler.rs | 8 +- .../src/server/session_registry.rs | 4 +- 7 files changed, 266 insertions(+), 25 deletions(-) create mode 100644 codex-rs/exec-server/src/process_sandbox.rs create mode 100644 codex-rs/exec-server/src/process_sandbox_tests.rs diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index c96c44344744..d5c71674acd1 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -16,6 +16,7 @@ mod noise_channel; mod noise_relay; mod process; mod process_id; +mod process_sandbox; mod protocol; mod regular_file; mod relay; diff --git a/codex-rs/exec-server/src/local_process.rs b/codex-rs/exec-server/src/local_process.rs index 1a6f2cf00ff1..f97aa7c42b43 100644 --- a/codex-rs/exec-server/src/local_process.rs +++ b/codex-rs/exec-server/src/local_process.rs @@ -26,9 +26,11 @@ use crate::ExecProcessEvent; use crate::ExecProcessEventReceiver; use crate::ExecProcessFuture; use crate::ExecServerError; +use crate::ExecServerRuntimePaths; use crate::ProcessId; use crate::StartedExecProcess; use crate::process::ExecProcessEventLog; +use crate::process_sandbox::prepare_exec_request; use crate::protocol::EXEC_CLOSED_METHOD; use crate::protocol::ExecClosedNotification; use crate::protocol::ExecEnvPolicy; @@ -133,6 +135,7 @@ struct Inner { #[derive(Clone)] pub(crate) struct LocalProcess { inner: Arc, + runtime_paths: Option, } struct LocalExecProcess { @@ -147,17 +150,28 @@ impl Default for LocalProcess { let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(NOTIFICATION_CHANNEL_CAPACITY); tokio::spawn(async move { while outgoing_rx.recv().await.is_some() {} }); - Self::new(RpcNotificationSender::new(outgoing_tx)) + Self::with_runtime_paths(RpcNotificationSender::new(outgoing_tx), None) } } impl LocalProcess { - pub(crate) fn new(notifications: RpcNotificationSender) -> Self { + pub(crate) fn new( + notifications: RpcNotificationSender, + runtime_paths: ExecServerRuntimePaths, + ) -> Self { + Self::with_runtime_paths(notifications, Some(runtime_paths)) + } + + fn with_runtime_paths( + notifications: RpcNotificationSender, + runtime_paths: Option, + ) -> Self { Self { inner: Arc::new(Inner { notifications: std::sync::RwLock::new(Some(notifications)), processes: Mutex::new(HashMap::new()), }), + runtime_paths, } } @@ -191,16 +205,12 @@ impl LocalProcess { params: ExecParams, ) -> Result<(ExecResponse, watch::Sender, ExecProcessEventLog), JSONRPCErrorError> { let process_id = params.process_id.clone(); - let (program, args) = params - .argv + let prepared = + prepare_exec_request(¶ms, child_env(¶ms), self.runtime_paths.as_ref())?; + let (program, args) = prepared + .command .split_first() .ok_or_else(|| invalid_params("argv must not be empty".to_string()))?; - let native_cwd = params.cwd.to_abs_path().map_err(|err| { - invalid_params(format!( - "cwd URI `{}` is not valid on this exec-server host: {err}", - params.cwd - )) - })?; let start = Arc::new(ProcessStart); { @@ -216,14 +226,13 @@ impl LocalProcess { ); } - let env = child_env(¶ms); let spawned_result = if params.tty { codex_utils_pty::spawn_pty_process( program, args, - native_cwd.as_path(), - &env, - ¶ms.arg0, + prepared.cwd.as_path(), + &prepared.env, + &prepared.arg0, TerminalSize::default(), ) .await @@ -231,18 +240,18 @@ impl LocalProcess { codex_utils_pty::spawn_pipe_process( program, args, - native_cwd.as_path(), - &env, - ¶ms.arg0, + prepared.cwd.as_path(), + &prepared.env, + &prepared.arg0, ) .await } else { codex_utils_pty::spawn_pipe_process_no_stdin( program, args, - native_cwd.as_path(), - &env, - ¶ms.arg0, + prepared.cwd.as_path(), + &prepared.env, + &prepared.arg0, ) .await }; diff --git a/codex-rs/exec-server/src/process_sandbox.rs b/codex-rs/exec-server/src/process_sandbox.rs new file mode 100644 index 000000000000..4f7d01d92199 --- /dev/null +++ b/codex-rs/exec-server/src/process_sandbox.rs @@ -0,0 +1,112 @@ +use std::collections::HashMap; + +use codex_app_server_protocol::JSONRPCErrorError; +use codex_protocol::models::PermissionProfile; +use codex_sandboxing::SandboxCommand; +use codex_sandboxing::SandboxDirectSpawnTransformRequest; +use codex_sandboxing::SandboxManager; +use codex_sandboxing::SandboxTransformRequest; +use codex_sandboxing::SandboxablePreference; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; + +use crate::ExecServerRuntimePaths; +use crate::protocol::ExecParams; +use crate::rpc::invalid_params; + +pub(crate) struct PreparedExecRequest { + pub(crate) command: Vec, + pub(crate) cwd: AbsolutePathBuf, + pub(crate) env: HashMap, + pub(crate) arg0: Option, +} + +pub(crate) fn prepare_exec_request( + params: &ExecParams, + env: HashMap, + runtime_paths: Option<&ExecServerRuntimePaths>, +) -> Result { + let Some(sandbox_context) = params.sandbox.as_ref() else { + return Ok(PreparedExecRequest { + command: params.argv.clone(), + cwd: native_path(¶ms.cwd, "cwd")?, + env, + arg0: params.arg0.clone(), + }); + }; + let runtime_paths = runtime_paths + .ok_or_else(|| invalid_params("sandbox runtime paths are not configured".to_string()))?; + let permissions: PermissionProfile = sandbox_context + .permissions + .clone() + .try_into() + .map_err(|err| invalid_params(format!("invalid sandbox permission path URI: {err}")))?; + let sandbox_policy_cwd = sandbox_context.cwd.as_ref().unwrap_or(¶ms.cwd); + let native_sandbox_policy_cwd = native_path(sandbox_policy_cwd, "sandbox cwd")?; + let native_workspace_roots = sandbox_context + .workspace_roots + .iter() + .map(|root| native_path(root, "sandbox workspace root")) + .collect::, _>>()?; + let workspace_roots = if native_workspace_roots.is_empty() { + std::slice::from_ref(&native_sandbox_policy_cwd) + } else { + native_workspace_roots.as_slice() + }; + let permissions = permissions.materialize_project_roots_with_workspace_roots(workspace_roots); + let (file_system_policy, network_policy) = permissions.to_runtime_permissions(); + let sandbox_manager = SandboxManager::new(); + let sandbox = sandbox_manager.select_initial( + &file_system_policy, + network_policy, + SandboxablePreference::Require, + sandbox_context.windows_sandbox_level, + params.enforce_managed_network, + ); + let (program, args) = params + .argv + .split_first() + .ok_or_else(|| invalid_params("argv must not be empty".to_string()))?; + let request = sandbox_manager + .transform_for_direct_spawn(SandboxDirectSpawnTransformRequest { + workspace_roots, + transform: SandboxTransformRequest { + command: SandboxCommand { + program: program.into(), + args: args.to_vec(), + cwd: params.cwd.clone(), + env, + additional_permissions: None, + }, + permissions: &permissions, + sandbox, + enforce_managed_network: params.enforce_managed_network, + environment_id: None, + network: None, + sandbox_policy_cwd, + codex_linux_sandbox_exe: runtime_paths.codex_linux_sandbox_exe.as_deref(), + use_legacy_landlock: sandbox_context.use_legacy_landlock, + windows_sandbox_level: sandbox_context.windows_sandbox_level, + windows_sandbox_private_desktop: sandbox_context.windows_sandbox_private_desktop, + }, + }) + .map_err(|err| invalid_params(format!("failed to prepare process sandbox: {err}")))?; + Ok(PreparedExecRequest { + command: request.command, + cwd: native_path(&request.cwd, "cwd")?, + env: request.env, + arg0: request.arg0, + }) +} + +fn native_path(path: &PathUri, label: &str) -> Result { + path.to_abs_path().map_err(|err| { + invalid_params(format!( + "{label} URI `{path}` is not valid on this exec-server host: {err}" + )) + }) +} + +#[cfg(test)] +#[path = "process_sandbox_tests.rs"] +mod tests; diff --git a/codex-rs/exec-server/src/process_sandbox_tests.rs b/codex-rs/exec-server/src/process_sandbox_tests.rs new file mode 100644 index 000000000000..078147936267 --- /dev/null +++ b/codex-rs/exec-server/src/process_sandbox_tests.rs @@ -0,0 +1,107 @@ +use std::collections::HashMap; + +use codex_protocol::models::PermissionProfile; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; +use pretty_assertions::assert_eq; + +use super::prepare_exec_request; +use crate::ExecParams; +use crate::ExecServerRuntimePaths; +use crate::FileSystemSandboxContext; +use crate::ProcessId; + +#[cfg(unix)] +#[test] +fn sandbox_request_wraps_native_argv_on_executor() { + let cwd: AbsolutePathBuf = std::env::current_dir() + .expect("current directory") + .try_into() + .expect("absolute cwd"); + let cwd_uri = PathUri::from_abs_path(&cwd); + let self_exe = std::env::current_exe().expect("current executable"); + let runtime_paths = + ExecServerRuntimePaths::new(self_exe.clone(), Some(self_exe)).expect("runtime paths"); + let mut sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + PermissionProfile::workspace_write(), + cwd_uri.clone(), + ); + sandbox.workspace_roots = vec![cwd_uri.clone()]; + let params = ExecParams { + process_id: ProcessId::from("process-1"), + argv: vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "pwd".to_string(), + ], + cwd: cwd_uri, + env_policy: None, + env: HashMap::new(), + tty: false, + pipe_stdin: false, + arg0: None, + sandbox: Some(sandbox), + enforce_managed_network: false, + }; + + let prepared = prepare_exec_request(¶ms, HashMap::new(), Some(&runtime_paths)) + .expect("prepare sandboxed request"); + + assert_ne!(prepared.command, params.argv); + assert_eq!(prepared.cwd, cwd); + #[cfg(target_os = "linux")] + { + assert_eq!( + prepared.command.first(), + Some(&runtime_paths.codex_self_exe.to_string_lossy().into_owned()) + ); + let permission_profile_json = prepared + .command + .iter() + .position(|arg| arg == "--permission-profile") + .and_then(|index| prepared.command.get(index + 1)) + .expect("sandbox wrapper permission profile"); + let permission_profile: PermissionProfile = + serde_json::from_str(permission_profile_json).expect("permission profile JSON"); + assert_eq!( + permission_profile, + PermissionProfile::workspace_write() + .materialize_project_roots_with_workspace_roots(std::slice::from_ref(&cwd)) + ); + } + #[cfg(target_os = "macos")] + assert_eq!( + prepared.command.first().map(String::as_str), + Some("/usr/bin/sandbox-exec") + ); +} + +#[test] +fn native_request_preserves_native_launch_fields() { + let cwd: AbsolutePathBuf = std::env::current_dir() + .expect("current directory") + .try_into() + .expect("absolute cwd"); + let cwd_uri = PathUri::from_abs_path(&cwd); + let env = HashMap::from([("TEST_ENV".to_string(), "value".to_string())]); + let params = ExecParams { + process_id: ProcessId::from("process-1"), + argv: vec!["echo".to_string(), "hello".to_string()], + cwd: cwd_uri, + env_policy: None, + env: HashMap::new(), + tty: false, + pipe_stdin: false, + arg0: Some("custom-arg0".to_string()), + sandbox: None, + enforce_managed_network: false, + }; + + let prepared = + prepare_exec_request(¶ms, env.clone(), None).expect("prepare native request"); + + assert_eq!(prepared.command, params.argv); + assert_eq!(prepared.cwd, cwd); + assert_eq!(prepared.env, env); + assert_eq!(prepared.arg0, params.arg0); +} diff --git a/codex-rs/exec-server/src/server/handler.rs b/codex-rs/exec-server/src/server/handler.rs index e0ede94c7742..07575a1c42eb 100644 --- a/codex-rs/exec-server/src/server/handler.rs +++ b/codex-rs/exec-server/src/server/handler.rs @@ -66,6 +66,7 @@ pub(crate) struct ExecServerHandler { background_task_shutdown: CancellationToken, background_tasks: TaskTracker, file_system: FileSystemHandler, + runtime_paths: ExecServerRuntimePaths, initialize_requested: AtomicBool, initialized: AtomicBool, } @@ -83,7 +84,8 @@ impl ExecServerHandler { active_body_stream_ids: Mutex::new(HashSet::new()), background_task_shutdown: CancellationToken::new(), background_tasks: TaskTracker::new(), - file_system: FileSystemHandler::new(runtime_paths), + file_system: FileSystemHandler::new(runtime_paths.clone()), + runtime_paths, initialize_requested: AtomicBool::new(false), initialized: AtomicBool::new(false), } @@ -116,7 +118,11 @@ impl ExecServerHandler { let session = match self .session_registry - .attach(params.resume_session_id.clone(), self.notifications.clone()) + .attach( + params.resume_session_id.clone(), + self.notifications.clone(), + self.runtime_paths.clone(), + ) .await { Ok(session) => session, diff --git a/codex-rs/exec-server/src/server/process_handler.rs b/codex-rs/exec-server/src/server/process_handler.rs index 9fced9c166aa..f628b242b072 100644 --- a/codex-rs/exec-server/src/server/process_handler.rs +++ b/codex-rs/exec-server/src/server/process_handler.rs @@ -1,5 +1,6 @@ use codex_app_server_protocol::JSONRPCErrorError; +use crate::ExecServerRuntimePaths; use crate::local_process::LocalProcess; use crate::protocol::ExecParams; use crate::protocol::ExecResponse; @@ -19,9 +20,12 @@ pub(crate) struct ProcessHandler { } impl ProcessHandler { - pub(crate) fn new(notifications: RpcNotificationSender) -> Self { + pub(crate) fn new( + notifications: RpcNotificationSender, + runtime_paths: ExecServerRuntimePaths, + ) -> Self { Self { - process: LocalProcess::new(notifications), + process: LocalProcess::new(notifications, runtime_paths), } } diff --git a/codex-rs/exec-server/src/server/session_registry.rs b/codex-rs/exec-server/src/server/session_registry.rs index 59da3b50a731..73b14ff7cd78 100644 --- a/codex-rs/exec-server/src/server/session_registry.rs +++ b/codex-rs/exec-server/src/server/session_registry.rs @@ -7,6 +7,7 @@ use codex_app_server_protocol::JSONRPCErrorError; use tokio::sync::Mutex; use uuid::Uuid; +use crate::ExecServerRuntimePaths; use crate::rpc::RpcNotificationSender; use crate::rpc::invalid_request; use crate::rpc::session_already_attached; @@ -60,6 +61,7 @@ impl SessionRegistry { self: &Arc, resume_session_id: Option, notifications: RpcNotificationSender, + runtime_paths: ExecServerRuntimePaths, ) -> Result { enum AttachOutcome { Attached(Arc), @@ -95,7 +97,7 @@ impl SessionRegistry { let session_id = Uuid::new_v4().to_string(); let entry = Arc::new(SessionEntry::new( session_id.clone(), - ProcessHandler::new(notifications), + ProcessHandler::new(notifications, runtime_paths), connection_id, )); sessions.insert(session_id, Arc::clone(&entry)); From 4c6ef017b08e4a6f8d221682bfb04bc2c59eb4de Mon Sep 17 00:00:00 2001 From: jif-oai Date: Sun, 21 Jun 2026 13:52:44 +0200 Subject: [PATCH 04/13] Preserve executor sandbox type for remote processes --- codex-rs/Cargo.lock | 1 + codex-rs/core/src/unified_exec/mod_tests.rs | 20 ++++----- codex-rs/core/src/unified_exec/process.rs | 2 +- .../core/src/unified_exec/process_manager.rs | 2 +- .../core/src/unified_exec/process_tests.rs | 44 ++++++++++++++++++- codex-rs/exec-server/src/client.rs | 8 ++-- codex-rs/exec-server/src/local_process.rs | 16 ++++++- codex-rs/exec-server/src/process.rs | 4 ++ codex-rs/exec-server/src/process_sandbox.rs | 4 ++ .../exec-server/src/process_sandbox_tests.rs | 7 ++- codex-rs/exec-server/src/protocol.rs | 6 +++ codex-rs/exec-server/src/remote_process.rs | 3 +- codex-rs/exec-server/tests/exec_process.rs | 18 ++++---- codex-rs/exec-server/tests/process.rs | 6 ++- codex-rs/exec-server/tests/relay.rs | 3 +- codex-rs/sandboxing/Cargo.toml | 1 + codex-rs/sandboxing/src/manager.rs | 3 +- 17 files changed, 112 insertions(+), 36 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 55d02cd8b155..32a7309ce10e 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3801,6 +3801,7 @@ dependencies = [ "libc", "pretty_assertions", "regex-lite", + "serde", "serde_json", "tempfile", "tokio", diff --git a/codex-rs/core/src/unified_exec/mod_tests.rs b/codex-rs/core/src/unified_exec/mod_tests.rs index d5b813cb42b5..8e87aa7ecc9e 100644 --- a/codex-rs/core/src/unified_exec/mod_tests.rs +++ b/codex-rs/core/src/unified_exec/mod_tests.rs @@ -286,17 +286,15 @@ async fn blocking_terminate_unified_process( ) -> anyhow::Result> { let (wake_tx, _wake_rx) = watch::channel(0); Ok(Arc::new( - UnifiedExecProcess::from_exec_server_started( - StartedExecProcess { - process: Arc::new(BlockingTerminateExecProcess { - process_id: process_id.to_string().into(), - terminate_started, - allow_terminate, - wake_tx, - }), - }, - SandboxType::None, - ) + UnifiedExecProcess::from_exec_server_started(StartedExecProcess { + process: Arc::new(BlockingTerminateExecProcess { + process_id: process_id.to_string().into(), + terminate_started, + allow_terminate, + wake_tx, + }), + sandbox: SandboxType::None, + }) .await?, )) } diff --git a/codex-rs/core/src/unified_exec/process.rs b/codex-rs/core/src/unified_exec/process.rs index 725be9eed228..bfa159e33286 100644 --- a/codex-rs/core/src/unified_exec/process.rs +++ b/codex-rs/core/src/unified_exec/process.rs @@ -374,8 +374,8 @@ impl UnifiedExecProcess { pub(super) async fn from_exec_server_started( started: StartedExecProcess, - sandbox_type: SandboxType, ) -> Result { + let sandbox_type = started.sandbox; let process_handle = ProcessHandle::ExecServer(Arc::clone(&started.process)); let mut managed = Self::new(process_handle, sandbox_type, /*spawn_lifecycle*/ None); let output_handles = managed.output_handles(); diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index a3b4bcf9c76e..f3750ecf862e 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -1041,7 +1041,7 @@ impl UnifiedExecProcessManager { .await .map_err(|err| UnifiedExecError::create_process(err.to_string()))?; spawn_lifecycle.after_spawn(); - return UnifiedExecProcess::from_exec_server_started(started, request.sandbox).await; + return UnifiedExecProcess::from_exec_server_started(started).await; } // TODO(anp): Keep PathUri through the local PTY/process launch boundary. diff --git a/codex-rs/core/src/unified_exec/process_tests.rs b/codex-rs/core/src/unified_exec/process_tests.rs index 37ec5d858b22..1e5863b6ce0e 100644 --- a/codex-rs/core/src/unified_exec/process_tests.rs +++ b/codex-rs/core/src/unified_exec/process_tests.rs @@ -1,10 +1,13 @@ use super::process::UnifiedExecProcess; use crate::unified_exec::UnifiedExecError; +use codex_exec_server::ByteChunk; +use codex_exec_server::ExecOutputStream; use codex_exec_server::ExecProcess; use codex_exec_server::ExecProcessEventReceiver; use codex_exec_server::ExecProcessFuture; use codex_exec_server::ExecServerError; use codex_exec_server::ProcessId; +use codex_exec_server::ProcessOutputChunk; use codex_exec_server::ProcessSignal; use codex_exec_server::ReadResponse; use codex_exec_server::StartedExecProcess; @@ -101,9 +104,10 @@ async fn remote_process( terminate_error, wake_tx, }), + sandbox: SandboxType::None, }; - UnifiedExecProcess::from_exec_server_started(started, SandboxType::None) + UnifiedExecProcess::from_exec_server_started(started) .await .expect("remote process should start") } @@ -194,6 +198,7 @@ async fn remote_process_waits_for_early_exit_event() { terminate_error: None, wake_tx: wake_tx.clone(), }), + sandbox: SandboxType::None, }; tokio::spawn(async move { @@ -201,10 +206,45 @@ async fn remote_process_waits_for_early_exit_event() { let _ = wake_tx.send(1); }); - let process = UnifiedExecProcess::from_exec_server_started(started, SandboxType::None) + let process = UnifiedExecProcess::from_exec_server_started(started) .await .expect("remote process should observe early exit"); assert!(process.has_exited()); assert_eq!(process.exit_code(), Some(17)); } + +#[tokio::test] +async fn remote_process_uses_executor_sandbox_for_denial_detection() { + let (wake_tx, _wake_rx) = watch::channel(0); + let started = StartedExecProcess { + process: Arc::new(MockExecProcess { + process_id: "test-process".to_string().into(), + write_response: WriteResponse { + status: WriteStatus::Accepted, + }, + read_responses: Mutex::new(VecDeque::from([ReadResponse { + chunks: vec![ProcessOutputChunk { + seq: 1, + stream: ExecOutputStream::Stderr, + chunk: ByteChunk::from(b"Permission denied".to_vec()), + }], + next_seq: 2, + exited: true, + exit_code: Some(1), + closed: true, + failure: None, + }])), + terminate_error: None, + wake_tx, + }), + sandbox: SandboxType::LinuxSeccomp, + }; + + let result = UnifiedExecProcess::from_exec_server_started(started).await; + + assert!(matches!( + result, + Err(UnifiedExecError::SandboxDenied { .. }) + )); +} diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index 88c0c8034288..8dce72f15e6b 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -99,6 +99,7 @@ use crate::protocol::WriteParams; use crate::protocol::WriteResponse; use crate::rpc::RpcCallError; use crate::rpc::RpcClient; +use codex_sandboxing::SandboxType; pub(crate) mod http_client; #[path = "client_recovery.rs"] @@ -634,7 +635,7 @@ impl ExecServerClient { pub(crate) async fn start_process( &self, params: ExecParams, - ) -> Result { + ) -> Result<(Session, SandboxType), ExecServerError> { loop { let rpc_client = self.inner.rpc_client().await?; if !self.inner.begin_process_start(&rpc_client) { @@ -658,14 +659,15 @@ impl ExecServerClient { .call_rpc::<_, ExecResponse>(&rpc_client, EXEC_METHOD, ¶ms) .await { - Ok(_) => { + Ok(response) => { state.recoverable.store(true, Ordering::Release); let session = Session { client: client.clone(), process_id: process_id.clone(), state: Arc::clone(&state), }; - if result_tx.send(Ok(session)).is_err() { + let sandbox = response.sandbox.unwrap_or(SandboxType::None); + if result_tx.send(Ok((session, sandbox))).is_err() { state.recoverable.store(false, Ordering::Release); tokio::spawn(async move { cleanup_process_start(&client, &process_id, &state).await; diff --git a/codex-rs/exec-server/src/local_process.rs b/codex-rs/exec-server/src/local_process.rs index f97aa7c42b43..205546310fc4 100644 --- a/codex-rs/exec-server/src/local_process.rs +++ b/codex-rs/exec-server/src/local_process.rs @@ -11,6 +11,7 @@ use codex_app_server_protocol::JSONRPCErrorError; use codex_protocol::config_types::EnvironmentVariablePattern; use codex_protocol::config_types::ShellEnvironmentPolicy; use codex_protocol::shell_environment; +use codex_sandboxing::SandboxType; use codex_utils_pty::ExecCommandSession; use codex_utils_pty::ProcessSignal as PtyProcessSignal; use codex_utils_pty::TerminalSize; @@ -150,7 +151,10 @@ impl Default for LocalProcess { let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(NOTIFICATION_CHANNEL_CAPACITY); tokio::spawn(async move { while outgoing_rx.recv().await.is_some() {} }); - Self::with_runtime_paths(RpcNotificationSender::new(outgoing_tx), None) + Self::with_runtime_paths( + RpcNotificationSender::new(outgoing_tx), + /*runtime_paths*/ None, + ) } } @@ -338,7 +342,14 @@ impl LocalProcess { output_notify, )); - Ok((ExecResponse { process_id }, wake_tx, events)) + Ok(( + ExecResponse { + process_id, + sandbox: Some(prepared.sandbox), + }, + wake_tx, + events, + )) } pub(crate) async fn exec(&self, params: ExecParams) -> Result { @@ -582,6 +593,7 @@ impl LocalProcess { wake_tx, events, }), + sandbox: response.sandbox.unwrap_or(SandboxType::None), }) } } diff --git a/codex-rs/exec-server/src/process.rs b/codex-rs/exec-server/src/process.rs index b136ef4682fa..ea5c8c118019 100644 --- a/codex-rs/exec-server/src/process.rs +++ b/codex-rs/exec-server/src/process.rs @@ -7,6 +7,8 @@ use std::sync::Mutex as StdMutex; use tokio::sync::broadcast; use tokio::sync::watch; +use codex_sandboxing::SandboxType; + use crate::ExecServerError; use crate::ProcessId; use crate::protocol::ExecParams; @@ -17,6 +19,8 @@ use crate::protocol::WriteResponse; pub struct StartedExecProcess { pub process: Arc, + /// Concrete sandbox selected by the executor that owns `process`. + pub sandbox: SandboxType, } /// Pushed process events for consumers that want to follow process output as it diff --git a/codex-rs/exec-server/src/process_sandbox.rs b/codex-rs/exec-server/src/process_sandbox.rs index 4f7d01d92199..de4b17ef6e83 100644 --- a/codex-rs/exec-server/src/process_sandbox.rs +++ b/codex-rs/exec-server/src/process_sandbox.rs @@ -6,6 +6,7 @@ use codex_sandboxing::SandboxCommand; use codex_sandboxing::SandboxDirectSpawnTransformRequest; use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxTransformRequest; +use codex_sandboxing::SandboxType; use codex_sandboxing::SandboxablePreference; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; @@ -19,6 +20,7 @@ pub(crate) struct PreparedExecRequest { pub(crate) cwd: AbsolutePathBuf, pub(crate) env: HashMap, pub(crate) arg0: Option, + pub(crate) sandbox: SandboxType, } pub(crate) fn prepare_exec_request( @@ -32,6 +34,7 @@ pub(crate) fn prepare_exec_request( cwd: native_path(¶ms.cwd, "cwd")?, env, arg0: params.arg0.clone(), + sandbox: SandboxType::None, }); }; let runtime_paths = runtime_paths @@ -96,6 +99,7 @@ pub(crate) fn prepare_exec_request( cwd: native_path(&request.cwd, "cwd")?, env: request.env, arg0: request.arg0, + sandbox: request.sandbox, }) } diff --git a/codex-rs/exec-server/src/process_sandbox_tests.rs b/codex-rs/exec-server/src/process_sandbox_tests.rs index 078147936267..600a2260ffa8 100644 --- a/codex-rs/exec-server/src/process_sandbox_tests.rs +++ b/codex-rs/exec-server/src/process_sandbox_tests.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; +#[cfg(unix)] use codex_protocol::models::PermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; @@ -7,7 +8,9 @@ use pretty_assertions::assert_eq; use super::prepare_exec_request; use crate::ExecParams; +#[cfg(unix)] use crate::ExecServerRuntimePaths; +#[cfg(unix)] use crate::FileSystemSandboxContext; use crate::ProcessId; @@ -97,8 +100,8 @@ fn native_request_preserves_native_launch_fields() { enforce_managed_network: false, }; - let prepared = - prepare_exec_request(¶ms, env.clone(), None).expect("prepare native request"); + let prepared = prepare_exec_request(¶ms, env.clone(), /*runtime_paths*/ None) + .expect("prepare native request"); assert_eq!(prepared.command, params.argv); assert_eq!(prepared.cwd, cwd); diff --git a/codex-rs/exec-server/src/protocol.rs b/codex-rs/exec-server/src/protocol.rs index e05595f273f6..fdce9d5d2cca 100644 --- a/codex-rs/exec-server/src/protocol.rs +++ b/codex-rs/exec-server/src/protocol.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_file_system::FileSystemSandboxContext; use codex_protocol::config_types::ShellEnvironmentPolicyInherit; +use codex_sandboxing::SandboxType; use codex_utils_path_uri::PathUri; use serde::Deserialize; use serde::Serialize; @@ -125,6 +126,11 @@ pub struct ExecEnvPolicy { #[serde(rename_all = "camelCase")] pub struct ExecResponse { pub process_id: ProcessId, + /// Concrete sandbox selected by this executor. + /// + /// Older exec servers omit this field, so clients must treat `None` as unknown. + #[serde(default)] + pub sandbox: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/codex-rs/exec-server/src/remote_process.rs b/codex-rs/exec-server/src/remote_process.rs index 72f41ae70e09..23f7b42fca4e 100644 --- a/codex-rs/exec-server/src/remote_process.rs +++ b/codex-rs/exec-server/src/remote_process.rs @@ -36,10 +36,11 @@ impl RemoteProcess { params: ExecParams, ) -> Result { let client = self.client.get().await?; - let session = client.start_process(params).await?; + let (session, sandbox) = client.start_process(params).await?; Ok(StartedExecProcess { process: Arc::new(RemoteExecProcess { session }), + sandbox, }) } } diff --git a/codex-rs/exec-server/tests/exec_process.rs b/codex-rs/exec-server/tests/exec_process.rs index ac38f50eafe7..f35923815d45 100644 --- a/codex-rs/exec-server/tests/exec_process.rs +++ b/codex-rs/exec-server/tests/exec_process.rs @@ -230,7 +230,7 @@ async fn assert_exec_process_streams_output(use_remote: bool) -> Result<()> { .await?; assert_eq!(session.process.process_id().as_str(), process_id); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?; assert_eq!(output, "session output\n"); @@ -263,7 +263,7 @@ async fn assert_exec_process_pushes_events(use_remote: bool) -> Result<()> { .await?; assert_eq!(session.process.process_id().as_str(), process_id); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let actual = collect_process_event_snapshots(process).await?; assert_eq!( actual, @@ -312,7 +312,7 @@ async fn assert_exec_process_replays_events_after_close(use_remote: bool) -> Res .await?; assert_eq!(session.process.process_id().as_str(), process_id); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let read_result = collect_process_output_from_reads(Arc::clone(&process), wake_rx).await?; assert_eq!( @@ -362,7 +362,7 @@ async fn assert_exec_process_retains_output_after_exit_until_streams_close( .await?; assert_eq!(session.process.process_id().as_str(), process_id); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let exit_response = timeout( Duration::from_secs(2), @@ -439,7 +439,7 @@ async fn assert_exec_process_write_then_read(use_remote: bool) -> Result<()> { tokio::time::sleep(Duration::from_millis(200)).await; session.process.write(b"hello\n".to_vec()).await?; - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?; @@ -479,7 +479,7 @@ async fn assert_exec_process_write_then_read_without_tty(use_remote: bool) -> Re tokio::time::sleep(Duration::from_millis(200)).await; let write_response = session.process.write(b"hello\n".to_vec()).await?; assert_eq!(write_response.status, WriteStatus::Accepted); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let actual = collect_process_output_from_reads(process, wake_rx).await?; @@ -513,7 +513,7 @@ async fn assert_exec_process_rejects_write_without_pipe_stdin(use_remote: bool) let write_response = session.process.write(b"ignored\n".to_vec()).await?; assert_eq!(write_response.status, WriteStatus::StdinClosed); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?; @@ -547,7 +547,7 @@ async fn assert_exec_process_signal_interrupts_process(use_remote: bool) -> Resu .await?; assert_eq!(session.process.process_id().as_str(), process_id); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let mut wake_rx = process.subscribe_wake(); let mut ready_output = String::new(); let mut after_seq = None; @@ -645,7 +645,7 @@ async fn assert_exec_process_preserves_queued_events_before_subscribe( tokio::time::sleep(Duration::from_millis(200)).await; - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?; assert_eq!(output, "queued output\n"); diff --git a/codex-rs/exec-server/tests/process.rs b/codex-rs/exec-server/tests/process.rs index 571fd427fa8a..cc2772cb328f 100644 --- a/codex-rs/exec-server/tests/process.rs +++ b/codex-rs/exec-server/tests/process.rs @@ -70,7 +70,8 @@ async fn exec_server_starts_process_over_websocket() -> anyhow::Result<()> { assert_eq!( process_start_response, ExecResponse { - process_id: ProcessId::from("proc-1") + process_id: ProcessId::from("proc-1"), + sandbox: Some(codex_sandboxing::SandboxType::None), } ); @@ -135,7 +136,8 @@ async fn exec_server_defaults_omitted_pipe_stdin_to_closed_stdin() -> anyhow::Re assert_eq!( process_start_response, ExecResponse { - process_id: ProcessId::from("proc-default-stdin") + process_id: ProcessId::from("proc-default-stdin"), + sandbox: Some(codex_sandboxing::SandboxType::None), } ); diff --git a/codex-rs/exec-server/tests/relay.rs b/codex-rs/exec-server/tests/relay.rs index 5f49655e3939..8ace8bbb4f2a 100644 --- a/codex-rs/exec-server/tests/relay.rs +++ b/codex-rs/exec-server/tests/relay.rs @@ -157,7 +157,8 @@ async fn remote_environment_routes_encrypted_exec_server_rpc() -> Result<()> { assert_eq!( response, ExecResponse { - process_id: ProcessId::from("proc-1") + process_id: ProcessId::from("proc-1"), + sandbox: Some(codex_sandboxing::SandboxType::None), } ); diff --git a/codex-rs/sandboxing/Cargo.toml b/codex-rs/sandboxing/Cargo.toml index 3185d782baa6..ed7746d3b6f0 100644 --- a/codex-rs/sandboxing/Cargo.toml +++ b/codex-rs/sandboxing/Cargo.toml @@ -22,6 +22,7 @@ dunce = { workspace = true } libc = { workspace = true } serde_json = { workspace = true } regex-lite = { workspace = true } +serde = { workspace = true } tracing = { workspace = true, features = ["log"] } url = { workspace = true } which = { workspace = true } diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs index 29b1177b11de..cb17a73fed89 100644 --- a/codex-rs/sandboxing/src/manager.rs +++ b/codex-rs/sandboxing/src/manager.rs @@ -30,7 +30,8 @@ use std::path::Path; #[cfg(target_os = "windows")] const WINDOWS_SANDBOX_WRAPPER_SETUP_ENV_ALLOWLIST: &[&str] = &["USERNAME", "USERPROFILE"]; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] pub enum SandboxType { None, MacosSeatbelt, From 1692611869d5f216055d72aebfeee44e566a53d6 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Sun, 21 Jun 2026 21:01:29 +0200 Subject: [PATCH 05/13] Report remote sandbox denials semantically --- codex-rs/Cargo.lock | 1 - codex-rs/core/src/exec.rs | 63 +-------------- codex-rs/core/src/unified_exec/mod_tests.rs | 2 +- codex-rs/core/src/unified_exec/process.rs | 19 ++++- .../core/src/unified_exec/process_state.rs | 3 + .../core/src/unified_exec/process_tests.rs | 9 +-- codex-rs/exec-server/src/client.rs | 10 +-- codex-rs/exec-server/src/client_recovery.rs | 1 + codex-rs/exec-server/src/local_process.rs | 77 ++++++++++++++++--- codex-rs/exec-server/src/process.rs | 4 - codex-rs/exec-server/src/process_sandbox.rs | 13 ++++ codex-rs/exec-server/src/protocol.rs | 8 +- codex-rs/exec-server/src/remote_process.rs | 3 +- codex-rs/exec-server/tests/process.rs | 2 - codex-rs/exec-server/tests/relay.rs | 1 - codex-rs/sandboxing/Cargo.toml | 1 - codex-rs/sandboxing/src/denial.rs | 57 ++++++++++++++ codex-rs/sandboxing/src/lib.rs | 2 + codex-rs/sandboxing/src/manager.rs | 3 +- 19 files changed, 171 insertions(+), 108 deletions(-) create mode 100644 codex-rs/sandboxing/src/denial.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 32a7309ce10e..55d02cd8b155 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3801,7 +3801,6 @@ dependencies = [ "libc", "pretty_assertions", "regex-lite", - "serde", "serde_json", "tempfile", "tokio", diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 99c87cdd99ea..647203db2e9f 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -42,6 +42,7 @@ use codex_sandboxing::SandboxTransformRequest; use codex_sandboxing::SandboxType; use codex_sandboxing::SandboxablePreference; use codex_sandboxing::WindowsSandboxFilesystemOverrides; +pub(crate) use codex_sandboxing::is_likely_sandbox_denied; #[cfg(test)] use codex_sandboxing::permission_profile_supports_windows_restricted_token_sandbox; use codex_sandboxing::resolve_windows_elevated_filesystem_overrides; @@ -819,68 +820,6 @@ fn finalize_exec_result( } } -/// We don't have a fully deterministic way to tell if our command failed -/// because of the sandbox - a command in the user's zshrc file might hit an -/// error, but the command itself might fail or succeed for other reasons. -/// For now, we conservatively check for well known command failure exit codes and -/// also look for common sandbox denial keywords in the command output. -pub(crate) fn is_likely_sandbox_denied( - sandbox_type: SandboxType, - exec_output: &ExecToolCallOutput, -) -> bool { - if sandbox_type == SandboxType::None || exec_output.exit_code == 0 { - return false; - } - - // Quick rejects: well-known non-sandbox shell exit codes - // 2: misuse of shell builtins - // 126: permission denied - // 127: command not found - const SANDBOX_DENIED_KEYWORDS: [&str; 7] = [ - "operation not permitted", - "permission denied", - "read-only file system", - "seccomp", - "sandbox", - "landlock", - "failed to write file", - ]; - - let has_sandbox_keyword = [ - &exec_output.stderr.text, - &exec_output.stdout.text, - &exec_output.aggregated_output.text, - ] - .into_iter() - .any(|section| { - let lower = section.to_lowercase(); - SANDBOX_DENIED_KEYWORDS - .iter() - .any(|needle| lower.contains(needle)) - }); - - if has_sandbox_keyword { - return true; - } - - const QUICK_REJECT_EXIT_CODES: [i32; 3] = [2, 126, 127]; - if QUICK_REJECT_EXIT_CODES.contains(&exec_output.exit_code) { - return false; - } - - #[cfg(unix)] - { - const SIGSYS_CODE: i32 = libc::SIGSYS; - if sandbox_type == SandboxType::LinuxSeccomp - && exec_output.exit_code == EXIT_CODE_SIGNAL_BASE + SIGSYS_CODE - { - return true; - } - } - - false -} - #[derive(Debug)] struct RawExecToolCallOutput { pub exit_status: ExitStatus, diff --git a/codex-rs/core/src/unified_exec/mod_tests.rs b/codex-rs/core/src/unified_exec/mod_tests.rs index 8e87aa7ecc9e..2dc0afd2654f 100644 --- a/codex-rs/core/src/unified_exec/mod_tests.rs +++ b/codex-rs/core/src/unified_exec/mod_tests.rs @@ -228,6 +228,7 @@ impl BlockingTerminateExecProcess { exit_code: None, closed: false, failure: None, + sandbox_denied: false, }) } @@ -293,7 +294,6 @@ async fn blocking_terminate_unified_process( allow_terminate, wake_tx, }), - sandbox: SandboxType::None, }) .await?, )) diff --git a/codex-rs/core/src/unified_exec/process.rs b/codex-rs/core/src/unified_exec/process.rs index bfa159e33286..5f785adfa75a 100644 --- a/codex-rs/core/src/unified_exec/process.rs +++ b/codex-rs/core/src/unified_exec/process.rs @@ -285,8 +285,9 @@ impl UnifiedExecProcess { &self, text: &str, ) -> Result<(), UnifiedExecError> { + let executor_reported_denial = self.state_rx.borrow().sandbox_denied; let sandbox_type = self.sandbox_type(); - if sandbox_type == SandboxType::None || !self.has_exited() { + if !self.has_exited() || (!executor_reported_denial && sandbox_type == SandboxType::None) { return Ok(()); } @@ -297,7 +298,7 @@ impl UnifiedExecProcess { aggregated_output: StreamOutput::new(text.to_string()), ..Default::default() }; - if is_likely_sandbox_denied(sandbox_type, &exec_output) { + if executor_reported_denial || is_likely_sandbox_denied(sandbox_type, &exec_output) { let snippet = formatted_truncate_text( text, TruncationPolicy::Tokens(UNIFIED_EXEC_OUTPUT_MAX_TOKENS), @@ -375,9 +376,12 @@ impl UnifiedExecProcess { pub(super) async fn from_exec_server_started( started: StartedExecProcess, ) -> Result { - let sandbox_type = started.sandbox; let process_handle = ProcessHandle::ExecServer(Arc::clone(&started.process)); - let mut managed = Self::new(process_handle, sandbox_type, /*spawn_lifecycle*/ None); + let mut managed = Self::new( + process_handle, + SandboxType::None, + /*spawn_lifecycle*/ None, + ); let output_handles = managed.output_handles(); managed.output_task = Some(Self::spawn_exec_server_output_task( started, @@ -437,6 +441,7 @@ impl UnifiedExecProcess { exit_code, closed, failure, + sandbox_denied, } = response; for chunk in chunks { @@ -457,6 +462,12 @@ impl UnifiedExecProcess { break; } + if sandbox_denied { + let mut state = state_tx.borrow().clone(); + state.sandbox_denied = true; + let _ = state_tx.send_replace(state); + } + if exited { let state = state_tx.borrow().clone(); let _ = state_tx.send_replace(state.exited(exit_code)); diff --git a/codex-rs/core/src/unified_exec/process_state.rs b/codex-rs/core/src/unified_exec/process_state.rs index 267406da29ba..65e11b6f3e20 100644 --- a/codex-rs/core/src/unified_exec/process_state.rs +++ b/codex-rs/core/src/unified_exec/process_state.rs @@ -3,6 +3,7 @@ pub(crate) struct ProcessState { pub(crate) has_exited: bool, pub(crate) exit_code: Option, pub(crate) failure_message: Option, + pub(crate) sandbox_denied: bool, } impl ProcessState { @@ -11,6 +12,7 @@ impl ProcessState { has_exited: true, exit_code, failure_message: self.failure_message.clone(), + sandbox_denied: self.sandbox_denied, } } @@ -19,6 +21,7 @@ impl ProcessState { has_exited: true, exit_code: self.exit_code, failure_message: Some(message), + sandbox_denied: self.sandbox_denied, } } } diff --git a/codex-rs/core/src/unified_exec/process_tests.rs b/codex-rs/core/src/unified_exec/process_tests.rs index 1e5863b6ce0e..8232a436597c 100644 --- a/codex-rs/core/src/unified_exec/process_tests.rs +++ b/codex-rs/core/src/unified_exec/process_tests.rs @@ -13,7 +13,6 @@ use codex_exec_server::ReadResponse; use codex_exec_server::StartedExecProcess; use codex_exec_server::WriteResponse; use codex_exec_server::WriteStatus; -use codex_sandboxing::SandboxType; use pretty_assertions::assert_eq; use std::collections::VecDeque; use std::sync::Arc; @@ -43,6 +42,7 @@ impl MockExecProcess { exit_code: None, closed: false, failure: None, + sandbox_denied: false, })) } @@ -104,7 +104,6 @@ async fn remote_process( terminate_error, wake_tx, }), - sandbox: SandboxType::None, }; UnifiedExecProcess::from_exec_server_started(started) @@ -194,11 +193,11 @@ async fn remote_process_waits_for_early_exit_event() { exit_code: Some(17), closed: true, failure: None, + sandbox_denied: false, }])), terminate_error: None, wake_tx: wake_tx.clone(), }), - sandbox: SandboxType::None, }; tokio::spawn(async move { @@ -215,7 +214,7 @@ async fn remote_process_waits_for_early_exit_event() { } #[tokio::test] -async fn remote_process_uses_executor_sandbox_for_denial_detection() { +async fn remote_process_uses_executor_denial_classification() { let (wake_tx, _wake_rx) = watch::channel(0); let started = StartedExecProcess { process: Arc::new(MockExecProcess { @@ -234,11 +233,11 @@ async fn remote_process_uses_executor_sandbox_for_denial_detection() { exit_code: Some(1), closed: true, failure: None, + sandbox_denied: true, }])), terminate_error: None, wake_tx, }), - sandbox: SandboxType::LinuxSeccomp, }; let result = UnifiedExecProcess::from_exec_server_started(started).await; diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index 8dce72f15e6b..25d1b54e8bba 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -99,7 +99,6 @@ use crate::protocol::WriteParams; use crate::protocol::WriteResponse; use crate::rpc::RpcCallError; use crate::rpc::RpcClient; -use codex_sandboxing::SandboxType; pub(crate) mod http_client; #[path = "client_recovery.rs"] @@ -635,7 +634,7 @@ impl ExecServerClient { pub(crate) async fn start_process( &self, params: ExecParams, - ) -> Result<(Session, SandboxType), ExecServerError> { + ) -> Result { loop { let rpc_client = self.inner.rpc_client().await?; if !self.inner.begin_process_start(&rpc_client) { @@ -659,15 +658,14 @@ impl ExecServerClient { .call_rpc::<_, ExecResponse>(&rpc_client, EXEC_METHOD, ¶ms) .await { - Ok(response) => { + Ok(_) => { state.recoverable.store(true, Ordering::Release); let session = Session { client: client.clone(), process_id: process_id.clone(), state: Arc::clone(&state), }; - let sandbox = response.sandbox.unwrap_or(SandboxType::None); - if result_tx.send(Ok((session, sandbox))).is_err() { + if result_tx.send(Ok(session)).is_err() { state.recoverable.store(false, Ordering::Release); tokio::spawn(async move { cleanup_process_start(&client, &process_id, &state).await; @@ -928,6 +926,7 @@ impl SessionState { exit_code: None, closed: true, failure: Some(message), + sandbox_denied: false, } } @@ -1871,6 +1870,7 @@ mod tests { exit_code: None, closed: false, failure: None, + sandbox_denied: false, }) .expect("read response should serialize"), }), diff --git a/codex-rs/exec-server/src/client_recovery.rs b/codex-rs/exec-server/src/client_recovery.rs index 77feb0a4baea..f58e365728cd 100644 --- a/codex-rs/exec-server/src/client_recovery.rs +++ b/codex-rs/exec-server/src/client_recovery.rs @@ -58,6 +58,7 @@ impl SessionState { exit_code, closed, failure, + sandbox_denied: _, } = response; if let Some(message) = failure { return Err(ExecServerError::Protocol(format!( diff --git a/codex-rs/exec-server/src/local_process.rs b/codex-rs/exec-server/src/local_process.rs index 205546310fc4..fc02aa25d5f4 100644 --- a/codex-rs/exec-server/src/local_process.rs +++ b/codex-rs/exec-server/src/local_process.rs @@ -10,8 +10,11 @@ use std::time::Duration; use codex_app_server_protocol::JSONRPCErrorError; use codex_protocol::config_types::EnvironmentVariablePattern; use codex_protocol::config_types::ShellEnvironmentPolicy; +use codex_protocol::exec_output::ExecToolCallOutput; +use codex_protocol::exec_output::StreamOutput; use codex_protocol::shell_environment; use codex_sandboxing::SandboxType; +use codex_sandboxing::is_likely_sandbox_denied; use codex_utils_pty::ExecCommandSession; use codex_utils_pty::ProcessSignal as PtyProcessSignal; use codex_utils_pty::TerminalSize; @@ -88,6 +91,8 @@ struct RunningProcess { output_notify: Arc, open_streams: usize, closed: bool, + sandbox: SandboxType, + sandbox_denied: bool, } /// Bounded cache of stdin write ids that have already been accepted for one process. @@ -309,6 +314,8 @@ impl LocalProcess { output_notify: Arc::clone(&output_notify), open_streams: 2, closed: false, + sandbox: prepared.sandbox, + sandbox_denied: false, })), ); } @@ -342,14 +349,7 @@ impl LocalProcess { output_notify, )); - Ok(( - ExecResponse { - process_id, - sandbox: Some(prepared.sandbox), - }, - wake_tx, - events, - )) + Ok((ExecResponse { process_id }, wake_tx, events)) } pub(crate) async fn exec(&self, params: ExecParams) -> Result { @@ -410,6 +410,7 @@ impl LocalProcess { exit_code: process.exit_code, closed: process.closed, failure: None, + sandbox_denied: process.sandbox_denied, }, Arc::clone(&process.output_notify), ) @@ -593,7 +594,6 @@ impl LocalProcess { wake_tx, events, }), - sandbox: response.sandbox.unwrap_or(SandboxType::None), }) } } @@ -858,6 +858,30 @@ async fn maybe_emit_closed(process_id: ProcessId, inner: Arc) { return; } + if process.sandbox != SandboxType::None { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let mut aggregated = Vec::new(); + for chunk in &process.output { + match chunk.stream { + ExecOutputStream::Stdout | ExecOutputStream::Pty => { + stdout.extend_from_slice(&chunk.chunk); + } + ExecOutputStream::Stderr => stderr.extend_from_slice(&chunk.chunk), + } + aggregated.extend_from_slice(&chunk.chunk); + } + let exec_output = ExecToolCallOutput { + exit_code: process.exit_code.unwrap_or(-1), + stdout: StreamOutput::new(String::from_utf8_lossy(&stdout).into_owned()), + stderr: StreamOutput::new(String::from_utf8_lossy(&stderr).into_owned()), + aggregated_output: StreamOutput::new( + String::from_utf8_lossy(&aggregated).into_owned(), + ), + ..Default::default() + }; + process.sandbox_denied = is_likely_sandbox_denied(process.sandbox, &exec_output); + } process.closed = true; let seq = process.next_seq; process.next_seq += 1; @@ -990,7 +1014,7 @@ mod tests { #[tokio::test] async fn exited_process_retains_late_output_past_retention() { let backend = LocalProcess::default(); - let mut process = spawn_test_process(&backend, "proc-late-output").await; + let mut process = spawn_test_process(&backend, "proc-late-output", SandboxType::None).await; process.exit(/*exit_code*/ 0); let exit_response = @@ -1004,6 +1028,7 @@ mod tests { exit_code: Some(0), closed: false, failure: None, + sandbox_denied: false, } ); @@ -1051,7 +1076,8 @@ mod tests { #[tokio::test] async fn closed_process_is_evicted_after_retention() { let backend = LocalProcess::default(); - let mut process = spawn_test_process(&backend, "proc-closed-eviction").await; + let mut process = + spawn_test_process(&backend, "proc-closed-eviction", SandboxType::None).await; let process_id = process.process_id.clone(); process.exit(/*exit_code*/ 0); @@ -1082,6 +1108,27 @@ mod tests { backend.shutdown().await; } + #[tokio::test] + async fn closed_sandboxed_process_reports_denial() { + let backend = LocalProcess::default(); + let mut process = + spawn_test_process(&backend, "proc-sandbox-denied", SandboxType::LinuxSeccomp).await; + + process + .stderr_tx + .send(b"Permission denied\n".to_vec()) + .await + .expect("send stderr"); + process.exit(/*exit_code*/ 1); + drop(process.stdout_tx); + drop(process.stderr_tx); + + let response = read_process_until_closed(&backend, &process.process_id).await; + + assert!(response.sandbox_denied); + backend.shutdown().await; + } + struct TestProcess { process_id: ProcessId, stdout_tx: mpsc::Sender>, @@ -1099,7 +1146,11 @@ mod tests { } } - async fn spawn_test_process(backend: &LocalProcess, process_id: &str) -> TestProcess { + async fn spawn_test_process( + backend: &LocalProcess, + process_id: &str, + sandbox: SandboxType, + ) -> TestProcess { let process_id = ProcessId::from(process_id); let (stdout_tx, stdout_rx) = mpsc::channel(16); let (stderr_tx, stderr_rx) = mpsc::channel(16); @@ -1128,6 +1179,8 @@ mod tests { output_notify: Arc::clone(&output_notify), open_streams: 2, closed: false, + sandbox, + sandbox_denied: false, })), ); assert!(previous.is_none()); diff --git a/codex-rs/exec-server/src/process.rs b/codex-rs/exec-server/src/process.rs index ea5c8c118019..b136ef4682fa 100644 --- a/codex-rs/exec-server/src/process.rs +++ b/codex-rs/exec-server/src/process.rs @@ -7,8 +7,6 @@ use std::sync::Mutex as StdMutex; use tokio::sync::broadcast; use tokio::sync::watch; -use codex_sandboxing::SandboxType; - use crate::ExecServerError; use crate::ProcessId; use crate::protocol::ExecParams; @@ -19,8 +17,6 @@ use crate::protocol::WriteResponse; pub struct StartedExecProcess { pub process: Arc, - /// Concrete sandbox selected by the executor that owns `process`. - pub sandbox: SandboxType, } /// Pushed process events for consumers that want to follow process output as it diff --git a/codex-rs/exec-server/src/process_sandbox.rs b/codex-rs/exec-server/src/process_sandbox.rs index de4b17ef6e83..1fc1cd26ebdb 100644 --- a/codex-rs/exec-server/src/process_sandbox.rs +++ b/codex-rs/exec-server/src/process_sandbox.rs @@ -66,6 +66,19 @@ pub(crate) fn prepare_exec_request( sandbox_context.windows_sandbox_level, params.enforce_managed_network, ); + match sandbox { + SandboxType::None => { + return Err(invalid_params( + "sandbox intent cannot be enforced on this executor".to_string(), + )); + } + SandboxType::WindowsRestrictedToken => { + return Err(invalid_params( + "sandboxed remote process launch is not supported on Windows".to_string(), + )); + } + SandboxType::MacosSeatbelt | SandboxType::LinuxSeccomp => {} + } let (program, args) = params .argv .split_first() diff --git a/codex-rs/exec-server/src/protocol.rs b/codex-rs/exec-server/src/protocol.rs index fdce9d5d2cca..4e6660e63cd8 100644 --- a/codex-rs/exec-server/src/protocol.rs +++ b/codex-rs/exec-server/src/protocol.rs @@ -3,7 +3,6 @@ use std::collections::HashMap; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_file_system::FileSystemSandboxContext; use codex_protocol::config_types::ShellEnvironmentPolicyInherit; -use codex_sandboxing::SandboxType; use codex_utils_path_uri::PathUri; use serde::Deserialize; use serde::Serialize; @@ -126,11 +125,6 @@ pub struct ExecEnvPolicy { #[serde(rename_all = "camelCase")] pub struct ExecResponse { pub process_id: ProcessId, - /// Concrete sandbox selected by this executor. - /// - /// Older exec servers omit this field, so clients must treat `None` as unknown. - #[serde(default)] - pub sandbox: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -159,6 +153,8 @@ pub struct ReadResponse { pub exit_code: Option, pub closed: bool, pub failure: Option, + /// Whether the executor classified the process failure as a sandbox denial. + pub sandbox_denied: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/codex-rs/exec-server/src/remote_process.rs b/codex-rs/exec-server/src/remote_process.rs index 23f7b42fca4e..72f41ae70e09 100644 --- a/codex-rs/exec-server/src/remote_process.rs +++ b/codex-rs/exec-server/src/remote_process.rs @@ -36,11 +36,10 @@ impl RemoteProcess { params: ExecParams, ) -> Result { let client = self.client.get().await?; - let (session, sandbox) = client.start_process(params).await?; + let session = client.start_process(params).await?; Ok(StartedExecProcess { process: Arc::new(RemoteExecProcess { session }), - sandbox, }) } } diff --git a/codex-rs/exec-server/tests/process.rs b/codex-rs/exec-server/tests/process.rs index cc2772cb328f..ba7f64a61058 100644 --- a/codex-rs/exec-server/tests/process.rs +++ b/codex-rs/exec-server/tests/process.rs @@ -71,7 +71,6 @@ async fn exec_server_starts_process_over_websocket() -> anyhow::Result<()> { process_start_response, ExecResponse { process_id: ProcessId::from("proc-1"), - sandbox: Some(codex_sandboxing::SandboxType::None), } ); @@ -137,7 +136,6 @@ async fn exec_server_defaults_omitted_pipe_stdin_to_closed_stdin() -> anyhow::Re process_start_response, ExecResponse { process_id: ProcessId::from("proc-default-stdin"), - sandbox: Some(codex_sandboxing::SandboxType::None), } ); diff --git a/codex-rs/exec-server/tests/relay.rs b/codex-rs/exec-server/tests/relay.rs index 8ace8bbb4f2a..c9f873c158f7 100644 --- a/codex-rs/exec-server/tests/relay.rs +++ b/codex-rs/exec-server/tests/relay.rs @@ -158,7 +158,6 @@ async fn remote_environment_routes_encrypted_exec_server_rpc() -> Result<()> { response, ExecResponse { process_id: ProcessId::from("proc-1"), - sandbox: Some(codex_sandboxing::SandboxType::None), } ); diff --git a/codex-rs/sandboxing/Cargo.toml b/codex-rs/sandboxing/Cargo.toml index ed7746d3b6f0..3185d782baa6 100644 --- a/codex-rs/sandboxing/Cargo.toml +++ b/codex-rs/sandboxing/Cargo.toml @@ -22,7 +22,6 @@ dunce = { workspace = true } libc = { workspace = true } serde_json = { workspace = true } regex-lite = { workspace = true } -serde = { workspace = true } tracing = { workspace = true, features = ["log"] } url = { workspace = true } which = { workspace = true } diff --git a/codex-rs/sandboxing/src/denial.rs b/codex-rs/sandboxing/src/denial.rs new file mode 100644 index 000000000000..f355fa1c023b --- /dev/null +++ b/codex-rs/sandboxing/src/denial.rs @@ -0,0 +1,57 @@ +use codex_protocol::exec_output::ExecToolCallOutput; + +use crate::SandboxType; + +/// Returns whether a failed command was likely denied by the selected sandbox. +pub fn is_likely_sandbox_denied( + sandbox_type: SandboxType, + exec_output: &ExecToolCallOutput, +) -> bool { + if sandbox_type == SandboxType::None || exec_output.exit_code == 0 { + return false; + } + + const SANDBOX_DENIED_KEYWORDS: [&str; 7] = [ + "operation not permitted", + "permission denied", + "read-only file system", + "seccomp", + "sandbox", + "landlock", + "failed to write file", + ]; + + let has_sandbox_keyword = [ + &exec_output.stderr.text, + &exec_output.stdout.text, + &exec_output.aggregated_output.text, + ] + .into_iter() + .any(|section| { + let lower = section.to_lowercase(); + SANDBOX_DENIED_KEYWORDS + .iter() + .any(|needle| lower.contains(needle)) + }); + + if has_sandbox_keyword { + return true; + } + + const QUICK_REJECT_EXIT_CODES: [i32; 3] = [2, 126, 127]; + if QUICK_REJECT_EXIT_CODES.contains(&exec_output.exit_code) { + return false; + } + + #[cfg(unix)] + { + const EXIT_CODE_SIGNAL_BASE: i32 = 128; + if sandbox_type == SandboxType::LinuxSeccomp + && exec_output.exit_code == EXIT_CODE_SIGNAL_BASE + libc::SIGSYS + { + return true; + } + } + + false +} diff --git a/codex-rs/sandboxing/src/lib.rs b/codex-rs/sandboxing/src/lib.rs index 22c4984c5a6f..0688d0f9204a 100644 --- a/codex-rs/sandboxing/src/lib.rs +++ b/codex-rs/sandboxing/src/lib.rs @@ -1,5 +1,6 @@ #[cfg(target_os = "linux")] mod bwrap; +mod denial; pub mod landlock; mod manager; pub mod policy_transforms; @@ -11,6 +12,7 @@ mod windows; pub use bwrap::find_system_bwrap_in_path; #[cfg(target_os = "linux")] pub use bwrap::system_bwrap_warning; +pub use denial::is_likely_sandbox_denied; pub use manager::SandboxCommand; pub use manager::SandboxDirectSpawnTransformRequest; pub use manager::SandboxExecRequest; diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs index cb17a73fed89..29b1177b11de 100644 --- a/codex-rs/sandboxing/src/manager.rs +++ b/codex-rs/sandboxing/src/manager.rs @@ -30,8 +30,7 @@ use std::path::Path; #[cfg(target_os = "windows")] const WINDOWS_SANDBOX_WRAPPER_SETUP_ENV_ALLOWLIST: &[&str] = &["USERNAME", "USERPROFILE"]; -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize)] -#[serde(rename_all = "camelCase")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SandboxType { None, MacosSeatbelt, From f4288f8c15f96e2ddfe06abf25c9973bcc37444a Mon Sep 17 00:00:00 2001 From: jif-oai Date: Sun, 21 Jun 2026 21:20:26 +0200 Subject: [PATCH 06/13] Document Windows remote sandbox follow-up --- codex-rs/exec-server/src/process_sandbox.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/codex-rs/exec-server/src/process_sandbox.rs b/codex-rs/exec-server/src/process_sandbox.rs index 1fc1cd26ebdb..918af8b32547 100644 --- a/codex-rs/exec-server/src/process_sandbox.rs +++ b/codex-rs/exec-server/src/process_sandbox.rs @@ -73,6 +73,8 @@ pub(crate) fn prepare_exec_request( )); } SandboxType::WindowsRestrictedToken => { + // TODO(jif): Launch generic remote commands through the Windows sandbox session API + // while preserving argv and TTY behavior and passing the child environment out of band. return Err(invalid_params( "sandboxed remote process launch is not supported on Windows".to_string(), )); From 4eab617347753faf97485cad2ccf9f6723472954 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 22 Jun 2026 10:42:01 +0200 Subject: [PATCH 07/13] Let executors derive remote workspace roots --- codex-rs/core/src/tools/sandboxing.rs | 6 +----- codex-rs/core/src/tools/sandboxing_tests.rs | 2 +- codex-rs/exec-server/src/process_sandbox_tests.rs | 3 +-- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index f9d21dccfa74..77f68d96b511 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -496,11 +496,7 @@ impl<'a> SandboxAttempt<'a> { exec_request.exec_server_sandbox = Some(FileSystemSandboxContext { permissions: exec_server_permissions.into(), cwd: Some(exec_request.windows_sandbox_policy_cwd.clone()), - workspace_roots: self - .workspace_roots - .iter() - .map(PathUri::from_abs_path) - .collect(), + workspace_roots: Vec::new(), windows_sandbox_level: self.windows_sandbox_level, windows_sandbox_private_desktop: self.windows_sandbox_private_desktop, use_legacy_landlock: self.use_legacy_landlock, diff --git a/codex-rs/core/src/tools/sandboxing_tests.rs b/codex-rs/core/src/tools/sandboxing_tests.rs index 47b0b4e89187..c647e1c40113 100644 --- a/codex-rs/core/src/tools/sandboxing_tests.rs +++ b/codex-rs/core/src/tools/sandboxing_tests.rs @@ -258,7 +258,7 @@ fn exec_server_env_keeps_command_native_and_carries_sandbox_context() { Some(codex_exec_server::FileSystemSandboxContext { permissions: exec_server_permissions.into(), cwd: Some(cwd_uri), - workspace_roots: vec![PathUri::from_abs_path(&cwd)], + workspace_roots: Vec::new(), windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, use_legacy_landlock: false, diff --git a/codex-rs/exec-server/src/process_sandbox_tests.rs b/codex-rs/exec-server/src/process_sandbox_tests.rs index 600a2260ffa8..1b0408afd85d 100644 --- a/codex-rs/exec-server/src/process_sandbox_tests.rs +++ b/codex-rs/exec-server/src/process_sandbox_tests.rs @@ -25,11 +25,10 @@ fn sandbox_request_wraps_native_argv_on_executor() { let self_exe = std::env::current_exe().expect("current executable"); let runtime_paths = ExecServerRuntimePaths::new(self_exe.clone(), Some(self_exe)).expect("runtime paths"); - let mut sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( PermissionProfile::workspace_write(), cwd_uri.clone(), ); - sandbox.workspace_roots = vec![cwd_uri.clone()]; let params = ExecParams { process_id: ProcessId::from("process-1"), argv: vec![ From 673e7870607edcfcb504a573fe372619b38072f0 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 22 Jun 2026 11:20:19 +0200 Subject: [PATCH 08/13] Revert "Report remote sandbox denials semantically" This reverts commit 1692611869d5f216055d72aebfeee44e566a53d6. --- codex-rs/Cargo.lock | 1 + codex-rs/core/src/exec.rs | 63 ++++++++++++++- codex-rs/core/src/unified_exec/mod_tests.rs | 2 +- codex-rs/core/src/unified_exec/process.rs | 19 +---- .../core/src/unified_exec/process_state.rs | 3 - .../core/src/unified_exec/process_tests.rs | 9 ++- codex-rs/exec-server/src/client.rs | 10 +-- codex-rs/exec-server/src/client_recovery.rs | 1 - codex-rs/exec-server/src/local_process.rs | 77 +++---------------- codex-rs/exec-server/src/process.rs | 4 + codex-rs/exec-server/src/protocol.rs | 8 +- codex-rs/exec-server/src/remote_process.rs | 3 +- codex-rs/exec-server/tests/process.rs | 2 + codex-rs/exec-server/tests/relay.rs | 1 + codex-rs/sandboxing/Cargo.toml | 1 + codex-rs/sandboxing/src/denial.rs | 57 -------------- codex-rs/sandboxing/src/lib.rs | 2 - codex-rs/sandboxing/src/manager.rs | 3 +- 18 files changed, 108 insertions(+), 158 deletions(-) delete mode 100644 codex-rs/sandboxing/src/denial.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index f35402efd08e..9e67b9104ffc 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3802,6 +3802,7 @@ dependencies = [ "libc", "pretty_assertions", "regex-lite", + "serde", "serde_json", "tempfile", "tokio", diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 647203db2e9f..99c87cdd99ea 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -42,7 +42,6 @@ use codex_sandboxing::SandboxTransformRequest; use codex_sandboxing::SandboxType; use codex_sandboxing::SandboxablePreference; use codex_sandboxing::WindowsSandboxFilesystemOverrides; -pub(crate) use codex_sandboxing::is_likely_sandbox_denied; #[cfg(test)] use codex_sandboxing::permission_profile_supports_windows_restricted_token_sandbox; use codex_sandboxing::resolve_windows_elevated_filesystem_overrides; @@ -820,6 +819,68 @@ fn finalize_exec_result( } } +/// We don't have a fully deterministic way to tell if our command failed +/// because of the sandbox - a command in the user's zshrc file might hit an +/// error, but the command itself might fail or succeed for other reasons. +/// For now, we conservatively check for well known command failure exit codes and +/// also look for common sandbox denial keywords in the command output. +pub(crate) fn is_likely_sandbox_denied( + sandbox_type: SandboxType, + exec_output: &ExecToolCallOutput, +) -> bool { + if sandbox_type == SandboxType::None || exec_output.exit_code == 0 { + return false; + } + + // Quick rejects: well-known non-sandbox shell exit codes + // 2: misuse of shell builtins + // 126: permission denied + // 127: command not found + const SANDBOX_DENIED_KEYWORDS: [&str; 7] = [ + "operation not permitted", + "permission denied", + "read-only file system", + "seccomp", + "sandbox", + "landlock", + "failed to write file", + ]; + + let has_sandbox_keyword = [ + &exec_output.stderr.text, + &exec_output.stdout.text, + &exec_output.aggregated_output.text, + ] + .into_iter() + .any(|section| { + let lower = section.to_lowercase(); + SANDBOX_DENIED_KEYWORDS + .iter() + .any(|needle| lower.contains(needle)) + }); + + if has_sandbox_keyword { + return true; + } + + const QUICK_REJECT_EXIT_CODES: [i32; 3] = [2, 126, 127]; + if QUICK_REJECT_EXIT_CODES.contains(&exec_output.exit_code) { + return false; + } + + #[cfg(unix)] + { + const SIGSYS_CODE: i32 = libc::SIGSYS; + if sandbox_type == SandboxType::LinuxSeccomp + && exec_output.exit_code == EXIT_CODE_SIGNAL_BASE + SIGSYS_CODE + { + return true; + } + } + + false +} + #[derive(Debug)] struct RawExecToolCallOutput { pub exit_status: ExitStatus, diff --git a/codex-rs/core/src/unified_exec/mod_tests.rs b/codex-rs/core/src/unified_exec/mod_tests.rs index 2dc0afd2654f..8e87aa7ecc9e 100644 --- a/codex-rs/core/src/unified_exec/mod_tests.rs +++ b/codex-rs/core/src/unified_exec/mod_tests.rs @@ -228,7 +228,6 @@ impl BlockingTerminateExecProcess { exit_code: None, closed: false, failure: None, - sandbox_denied: false, }) } @@ -294,6 +293,7 @@ async fn blocking_terminate_unified_process( allow_terminate, wake_tx, }), + sandbox: SandboxType::None, }) .await?, )) diff --git a/codex-rs/core/src/unified_exec/process.rs b/codex-rs/core/src/unified_exec/process.rs index 5f785adfa75a..bfa159e33286 100644 --- a/codex-rs/core/src/unified_exec/process.rs +++ b/codex-rs/core/src/unified_exec/process.rs @@ -285,9 +285,8 @@ impl UnifiedExecProcess { &self, text: &str, ) -> Result<(), UnifiedExecError> { - let executor_reported_denial = self.state_rx.borrow().sandbox_denied; let sandbox_type = self.sandbox_type(); - if !self.has_exited() || (!executor_reported_denial && sandbox_type == SandboxType::None) { + if sandbox_type == SandboxType::None || !self.has_exited() { return Ok(()); } @@ -298,7 +297,7 @@ impl UnifiedExecProcess { aggregated_output: StreamOutput::new(text.to_string()), ..Default::default() }; - if executor_reported_denial || is_likely_sandbox_denied(sandbox_type, &exec_output) { + if is_likely_sandbox_denied(sandbox_type, &exec_output) { let snippet = formatted_truncate_text( text, TruncationPolicy::Tokens(UNIFIED_EXEC_OUTPUT_MAX_TOKENS), @@ -376,12 +375,9 @@ impl UnifiedExecProcess { pub(super) async fn from_exec_server_started( started: StartedExecProcess, ) -> Result { + let sandbox_type = started.sandbox; let process_handle = ProcessHandle::ExecServer(Arc::clone(&started.process)); - let mut managed = Self::new( - process_handle, - SandboxType::None, - /*spawn_lifecycle*/ None, - ); + let mut managed = Self::new(process_handle, sandbox_type, /*spawn_lifecycle*/ None); let output_handles = managed.output_handles(); managed.output_task = Some(Self::spawn_exec_server_output_task( started, @@ -441,7 +437,6 @@ impl UnifiedExecProcess { exit_code, closed, failure, - sandbox_denied, } = response; for chunk in chunks { @@ -462,12 +457,6 @@ impl UnifiedExecProcess { break; } - if sandbox_denied { - let mut state = state_tx.borrow().clone(); - state.sandbox_denied = true; - let _ = state_tx.send_replace(state); - } - if exited { let state = state_tx.borrow().clone(); let _ = state_tx.send_replace(state.exited(exit_code)); diff --git a/codex-rs/core/src/unified_exec/process_state.rs b/codex-rs/core/src/unified_exec/process_state.rs index 65e11b6f3e20..267406da29ba 100644 --- a/codex-rs/core/src/unified_exec/process_state.rs +++ b/codex-rs/core/src/unified_exec/process_state.rs @@ -3,7 +3,6 @@ pub(crate) struct ProcessState { pub(crate) has_exited: bool, pub(crate) exit_code: Option, pub(crate) failure_message: Option, - pub(crate) sandbox_denied: bool, } impl ProcessState { @@ -12,7 +11,6 @@ impl ProcessState { has_exited: true, exit_code, failure_message: self.failure_message.clone(), - sandbox_denied: self.sandbox_denied, } } @@ -21,7 +19,6 @@ impl ProcessState { has_exited: true, exit_code: self.exit_code, failure_message: Some(message), - sandbox_denied: self.sandbox_denied, } } } diff --git a/codex-rs/core/src/unified_exec/process_tests.rs b/codex-rs/core/src/unified_exec/process_tests.rs index 8232a436597c..1e5863b6ce0e 100644 --- a/codex-rs/core/src/unified_exec/process_tests.rs +++ b/codex-rs/core/src/unified_exec/process_tests.rs @@ -13,6 +13,7 @@ use codex_exec_server::ReadResponse; use codex_exec_server::StartedExecProcess; use codex_exec_server::WriteResponse; use codex_exec_server::WriteStatus; +use codex_sandboxing::SandboxType; use pretty_assertions::assert_eq; use std::collections::VecDeque; use std::sync::Arc; @@ -42,7 +43,6 @@ impl MockExecProcess { exit_code: None, closed: false, failure: None, - sandbox_denied: false, })) } @@ -104,6 +104,7 @@ async fn remote_process( terminate_error, wake_tx, }), + sandbox: SandboxType::None, }; UnifiedExecProcess::from_exec_server_started(started) @@ -193,11 +194,11 @@ async fn remote_process_waits_for_early_exit_event() { exit_code: Some(17), closed: true, failure: None, - sandbox_denied: false, }])), terminate_error: None, wake_tx: wake_tx.clone(), }), + sandbox: SandboxType::None, }; tokio::spawn(async move { @@ -214,7 +215,7 @@ async fn remote_process_waits_for_early_exit_event() { } #[tokio::test] -async fn remote_process_uses_executor_denial_classification() { +async fn remote_process_uses_executor_sandbox_for_denial_detection() { let (wake_tx, _wake_rx) = watch::channel(0); let started = StartedExecProcess { process: Arc::new(MockExecProcess { @@ -233,11 +234,11 @@ async fn remote_process_uses_executor_denial_classification() { exit_code: Some(1), closed: true, failure: None, - sandbox_denied: true, }])), terminate_error: None, wake_tx, }), + sandbox: SandboxType::LinuxSeccomp, }; let result = UnifiedExecProcess::from_exec_server_started(started).await; diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index 25d1b54e8bba..8dce72f15e6b 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -99,6 +99,7 @@ use crate::protocol::WriteParams; use crate::protocol::WriteResponse; use crate::rpc::RpcCallError; use crate::rpc::RpcClient; +use codex_sandboxing::SandboxType; pub(crate) mod http_client; #[path = "client_recovery.rs"] @@ -634,7 +635,7 @@ impl ExecServerClient { pub(crate) async fn start_process( &self, params: ExecParams, - ) -> Result { + ) -> Result<(Session, SandboxType), ExecServerError> { loop { let rpc_client = self.inner.rpc_client().await?; if !self.inner.begin_process_start(&rpc_client) { @@ -658,14 +659,15 @@ impl ExecServerClient { .call_rpc::<_, ExecResponse>(&rpc_client, EXEC_METHOD, ¶ms) .await { - Ok(_) => { + Ok(response) => { state.recoverable.store(true, Ordering::Release); let session = Session { client: client.clone(), process_id: process_id.clone(), state: Arc::clone(&state), }; - if result_tx.send(Ok(session)).is_err() { + let sandbox = response.sandbox.unwrap_or(SandboxType::None); + if result_tx.send(Ok((session, sandbox))).is_err() { state.recoverable.store(false, Ordering::Release); tokio::spawn(async move { cleanup_process_start(&client, &process_id, &state).await; @@ -926,7 +928,6 @@ impl SessionState { exit_code: None, closed: true, failure: Some(message), - sandbox_denied: false, } } @@ -1870,7 +1871,6 @@ mod tests { exit_code: None, closed: false, failure: None, - sandbox_denied: false, }) .expect("read response should serialize"), }), diff --git a/codex-rs/exec-server/src/client_recovery.rs b/codex-rs/exec-server/src/client_recovery.rs index f58e365728cd..77feb0a4baea 100644 --- a/codex-rs/exec-server/src/client_recovery.rs +++ b/codex-rs/exec-server/src/client_recovery.rs @@ -58,7 +58,6 @@ impl SessionState { exit_code, closed, failure, - sandbox_denied: _, } = response; if let Some(message) = failure { return Err(ExecServerError::Protocol(format!( diff --git a/codex-rs/exec-server/src/local_process.rs b/codex-rs/exec-server/src/local_process.rs index fc02aa25d5f4..205546310fc4 100644 --- a/codex-rs/exec-server/src/local_process.rs +++ b/codex-rs/exec-server/src/local_process.rs @@ -10,11 +10,8 @@ use std::time::Duration; use codex_app_server_protocol::JSONRPCErrorError; use codex_protocol::config_types::EnvironmentVariablePattern; use codex_protocol::config_types::ShellEnvironmentPolicy; -use codex_protocol::exec_output::ExecToolCallOutput; -use codex_protocol::exec_output::StreamOutput; use codex_protocol::shell_environment; use codex_sandboxing::SandboxType; -use codex_sandboxing::is_likely_sandbox_denied; use codex_utils_pty::ExecCommandSession; use codex_utils_pty::ProcessSignal as PtyProcessSignal; use codex_utils_pty::TerminalSize; @@ -91,8 +88,6 @@ struct RunningProcess { output_notify: Arc, open_streams: usize, closed: bool, - sandbox: SandboxType, - sandbox_denied: bool, } /// Bounded cache of stdin write ids that have already been accepted for one process. @@ -314,8 +309,6 @@ impl LocalProcess { output_notify: Arc::clone(&output_notify), open_streams: 2, closed: false, - sandbox: prepared.sandbox, - sandbox_denied: false, })), ); } @@ -349,7 +342,14 @@ impl LocalProcess { output_notify, )); - Ok((ExecResponse { process_id }, wake_tx, events)) + Ok(( + ExecResponse { + process_id, + sandbox: Some(prepared.sandbox), + }, + wake_tx, + events, + )) } pub(crate) async fn exec(&self, params: ExecParams) -> Result { @@ -410,7 +410,6 @@ impl LocalProcess { exit_code: process.exit_code, closed: process.closed, failure: None, - sandbox_denied: process.sandbox_denied, }, Arc::clone(&process.output_notify), ) @@ -594,6 +593,7 @@ impl LocalProcess { wake_tx, events, }), + sandbox: response.sandbox.unwrap_or(SandboxType::None), }) } } @@ -858,30 +858,6 @@ async fn maybe_emit_closed(process_id: ProcessId, inner: Arc) { return; } - if process.sandbox != SandboxType::None { - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - let mut aggregated = Vec::new(); - for chunk in &process.output { - match chunk.stream { - ExecOutputStream::Stdout | ExecOutputStream::Pty => { - stdout.extend_from_slice(&chunk.chunk); - } - ExecOutputStream::Stderr => stderr.extend_from_slice(&chunk.chunk), - } - aggregated.extend_from_slice(&chunk.chunk); - } - let exec_output = ExecToolCallOutput { - exit_code: process.exit_code.unwrap_or(-1), - stdout: StreamOutput::new(String::from_utf8_lossy(&stdout).into_owned()), - stderr: StreamOutput::new(String::from_utf8_lossy(&stderr).into_owned()), - aggregated_output: StreamOutput::new( - String::from_utf8_lossy(&aggregated).into_owned(), - ), - ..Default::default() - }; - process.sandbox_denied = is_likely_sandbox_denied(process.sandbox, &exec_output); - } process.closed = true; let seq = process.next_seq; process.next_seq += 1; @@ -1014,7 +990,7 @@ mod tests { #[tokio::test] async fn exited_process_retains_late_output_past_retention() { let backend = LocalProcess::default(); - let mut process = spawn_test_process(&backend, "proc-late-output", SandboxType::None).await; + let mut process = spawn_test_process(&backend, "proc-late-output").await; process.exit(/*exit_code*/ 0); let exit_response = @@ -1028,7 +1004,6 @@ mod tests { exit_code: Some(0), closed: false, failure: None, - sandbox_denied: false, } ); @@ -1076,8 +1051,7 @@ mod tests { #[tokio::test] async fn closed_process_is_evicted_after_retention() { let backend = LocalProcess::default(); - let mut process = - spawn_test_process(&backend, "proc-closed-eviction", SandboxType::None).await; + let mut process = spawn_test_process(&backend, "proc-closed-eviction").await; let process_id = process.process_id.clone(); process.exit(/*exit_code*/ 0); @@ -1108,27 +1082,6 @@ mod tests { backend.shutdown().await; } - #[tokio::test] - async fn closed_sandboxed_process_reports_denial() { - let backend = LocalProcess::default(); - let mut process = - spawn_test_process(&backend, "proc-sandbox-denied", SandboxType::LinuxSeccomp).await; - - process - .stderr_tx - .send(b"Permission denied\n".to_vec()) - .await - .expect("send stderr"); - process.exit(/*exit_code*/ 1); - drop(process.stdout_tx); - drop(process.stderr_tx); - - let response = read_process_until_closed(&backend, &process.process_id).await; - - assert!(response.sandbox_denied); - backend.shutdown().await; - } - struct TestProcess { process_id: ProcessId, stdout_tx: mpsc::Sender>, @@ -1146,11 +1099,7 @@ mod tests { } } - async fn spawn_test_process( - backend: &LocalProcess, - process_id: &str, - sandbox: SandboxType, - ) -> TestProcess { + async fn spawn_test_process(backend: &LocalProcess, process_id: &str) -> TestProcess { let process_id = ProcessId::from(process_id); let (stdout_tx, stdout_rx) = mpsc::channel(16); let (stderr_tx, stderr_rx) = mpsc::channel(16); @@ -1179,8 +1128,6 @@ mod tests { output_notify: Arc::clone(&output_notify), open_streams: 2, closed: false, - sandbox, - sandbox_denied: false, })), ); assert!(previous.is_none()); diff --git a/codex-rs/exec-server/src/process.rs b/codex-rs/exec-server/src/process.rs index b136ef4682fa..ea5c8c118019 100644 --- a/codex-rs/exec-server/src/process.rs +++ b/codex-rs/exec-server/src/process.rs @@ -7,6 +7,8 @@ use std::sync::Mutex as StdMutex; use tokio::sync::broadcast; use tokio::sync::watch; +use codex_sandboxing::SandboxType; + use crate::ExecServerError; use crate::ProcessId; use crate::protocol::ExecParams; @@ -17,6 +19,8 @@ use crate::protocol::WriteResponse; pub struct StartedExecProcess { pub process: Arc, + /// Concrete sandbox selected by the executor that owns `process`. + pub sandbox: SandboxType, } /// Pushed process events for consumers that want to follow process output as it diff --git a/codex-rs/exec-server/src/protocol.rs b/codex-rs/exec-server/src/protocol.rs index 4e6660e63cd8..fdce9d5d2cca 100644 --- a/codex-rs/exec-server/src/protocol.rs +++ b/codex-rs/exec-server/src/protocol.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_file_system::FileSystemSandboxContext; use codex_protocol::config_types::ShellEnvironmentPolicyInherit; +use codex_sandboxing::SandboxType; use codex_utils_path_uri::PathUri; use serde::Deserialize; use serde::Serialize; @@ -125,6 +126,11 @@ pub struct ExecEnvPolicy { #[serde(rename_all = "camelCase")] pub struct ExecResponse { pub process_id: ProcessId, + /// Concrete sandbox selected by this executor. + /// + /// Older exec servers omit this field, so clients must treat `None` as unknown. + #[serde(default)] + pub sandbox: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -153,8 +159,6 @@ pub struct ReadResponse { pub exit_code: Option, pub closed: bool, pub failure: Option, - /// Whether the executor classified the process failure as a sandbox denial. - pub sandbox_denied: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/codex-rs/exec-server/src/remote_process.rs b/codex-rs/exec-server/src/remote_process.rs index 72f41ae70e09..23f7b42fca4e 100644 --- a/codex-rs/exec-server/src/remote_process.rs +++ b/codex-rs/exec-server/src/remote_process.rs @@ -36,10 +36,11 @@ impl RemoteProcess { params: ExecParams, ) -> Result { let client = self.client.get().await?; - let session = client.start_process(params).await?; + let (session, sandbox) = client.start_process(params).await?; Ok(StartedExecProcess { process: Arc::new(RemoteExecProcess { session }), + sandbox, }) } } diff --git a/codex-rs/exec-server/tests/process.rs b/codex-rs/exec-server/tests/process.rs index ba7f64a61058..cc2772cb328f 100644 --- a/codex-rs/exec-server/tests/process.rs +++ b/codex-rs/exec-server/tests/process.rs @@ -71,6 +71,7 @@ async fn exec_server_starts_process_over_websocket() -> anyhow::Result<()> { process_start_response, ExecResponse { process_id: ProcessId::from("proc-1"), + sandbox: Some(codex_sandboxing::SandboxType::None), } ); @@ -136,6 +137,7 @@ async fn exec_server_defaults_omitted_pipe_stdin_to_closed_stdin() -> anyhow::Re process_start_response, ExecResponse { process_id: ProcessId::from("proc-default-stdin"), + sandbox: Some(codex_sandboxing::SandboxType::None), } ); diff --git a/codex-rs/exec-server/tests/relay.rs b/codex-rs/exec-server/tests/relay.rs index c9f873c158f7..8ace8bbb4f2a 100644 --- a/codex-rs/exec-server/tests/relay.rs +++ b/codex-rs/exec-server/tests/relay.rs @@ -158,6 +158,7 @@ async fn remote_environment_routes_encrypted_exec_server_rpc() -> Result<()> { response, ExecResponse { process_id: ProcessId::from("proc-1"), + sandbox: Some(codex_sandboxing::SandboxType::None), } ); diff --git a/codex-rs/sandboxing/Cargo.toml b/codex-rs/sandboxing/Cargo.toml index 3185d782baa6..ed7746d3b6f0 100644 --- a/codex-rs/sandboxing/Cargo.toml +++ b/codex-rs/sandboxing/Cargo.toml @@ -22,6 +22,7 @@ dunce = { workspace = true } libc = { workspace = true } serde_json = { workspace = true } regex-lite = { workspace = true } +serde = { workspace = true } tracing = { workspace = true, features = ["log"] } url = { workspace = true } which = { workspace = true } diff --git a/codex-rs/sandboxing/src/denial.rs b/codex-rs/sandboxing/src/denial.rs deleted file mode 100644 index f355fa1c023b..000000000000 --- a/codex-rs/sandboxing/src/denial.rs +++ /dev/null @@ -1,57 +0,0 @@ -use codex_protocol::exec_output::ExecToolCallOutput; - -use crate::SandboxType; - -/// Returns whether a failed command was likely denied by the selected sandbox. -pub fn is_likely_sandbox_denied( - sandbox_type: SandboxType, - exec_output: &ExecToolCallOutput, -) -> bool { - if sandbox_type == SandboxType::None || exec_output.exit_code == 0 { - return false; - } - - const SANDBOX_DENIED_KEYWORDS: [&str; 7] = [ - "operation not permitted", - "permission denied", - "read-only file system", - "seccomp", - "sandbox", - "landlock", - "failed to write file", - ]; - - let has_sandbox_keyword = [ - &exec_output.stderr.text, - &exec_output.stdout.text, - &exec_output.aggregated_output.text, - ] - .into_iter() - .any(|section| { - let lower = section.to_lowercase(); - SANDBOX_DENIED_KEYWORDS - .iter() - .any(|needle| lower.contains(needle)) - }); - - if has_sandbox_keyword { - return true; - } - - const QUICK_REJECT_EXIT_CODES: [i32; 3] = [2, 126, 127]; - if QUICK_REJECT_EXIT_CODES.contains(&exec_output.exit_code) { - return false; - } - - #[cfg(unix)] - { - const EXIT_CODE_SIGNAL_BASE: i32 = 128; - if sandbox_type == SandboxType::LinuxSeccomp - && exec_output.exit_code == EXIT_CODE_SIGNAL_BASE + libc::SIGSYS - { - return true; - } - } - - false -} diff --git a/codex-rs/sandboxing/src/lib.rs b/codex-rs/sandboxing/src/lib.rs index 0688d0f9204a..22c4984c5a6f 100644 --- a/codex-rs/sandboxing/src/lib.rs +++ b/codex-rs/sandboxing/src/lib.rs @@ -1,6 +1,5 @@ #[cfg(target_os = "linux")] mod bwrap; -mod denial; pub mod landlock; mod manager; pub mod policy_transforms; @@ -12,7 +11,6 @@ mod windows; pub use bwrap::find_system_bwrap_in_path; #[cfg(target_os = "linux")] pub use bwrap::system_bwrap_warning; -pub use denial::is_likely_sandbox_denied; pub use manager::SandboxCommand; pub use manager::SandboxDirectSpawnTransformRequest; pub use manager::SandboxExecRequest; diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs index 29b1177b11de..cb17a73fed89 100644 --- a/codex-rs/sandboxing/src/manager.rs +++ b/codex-rs/sandboxing/src/manager.rs @@ -30,7 +30,8 @@ use std::path::Path; #[cfg(target_os = "windows")] const WINDOWS_SANDBOX_WRAPPER_SETUP_ENV_ALLOWLIST: &[&str] = &["USERNAME", "USERPROFILE"]; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] pub enum SandboxType { None, MacosSeatbelt, From 30fa6519287fc7cc9ea3f41547cb966a49169824 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 22 Jun 2026 11:20:31 +0200 Subject: [PATCH 09/13] Revert "Preserve executor sandbox type for remote processes" This reverts commit 4c6ef017b08e4a6f8d221682bfb04bc2c59eb4de. --- codex-rs/Cargo.lock | 1 - codex-rs/core/src/unified_exec/mod_tests.rs | 20 +++++---- codex-rs/core/src/unified_exec/process.rs | 2 +- .../core/src/unified_exec/process_manager.rs | 2 +- .../core/src/unified_exec/process_tests.rs | 44 +------------------ codex-rs/exec-server/src/client.rs | 8 ++-- codex-rs/exec-server/src/local_process.rs | 16 +------ codex-rs/exec-server/src/process.rs | 4 -- codex-rs/exec-server/src/process_sandbox.rs | 4 -- .../exec-server/src/process_sandbox_tests.rs | 7 +-- codex-rs/exec-server/src/protocol.rs | 6 --- codex-rs/exec-server/src/remote_process.rs | 3 +- codex-rs/exec-server/tests/exec_process.rs | 18 ++++---- codex-rs/exec-server/tests/process.rs | 6 +-- codex-rs/exec-server/tests/relay.rs | 3 +- codex-rs/sandboxing/Cargo.toml | 1 - codex-rs/sandboxing/src/manager.rs | 3 +- 17 files changed, 36 insertions(+), 112 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9e67b9104ffc..f35402efd08e 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3802,7 +3802,6 @@ dependencies = [ "libc", "pretty_assertions", "regex-lite", - "serde", "serde_json", "tempfile", "tokio", diff --git a/codex-rs/core/src/unified_exec/mod_tests.rs b/codex-rs/core/src/unified_exec/mod_tests.rs index 8e87aa7ecc9e..d5b813cb42b5 100644 --- a/codex-rs/core/src/unified_exec/mod_tests.rs +++ b/codex-rs/core/src/unified_exec/mod_tests.rs @@ -286,15 +286,17 @@ async fn blocking_terminate_unified_process( ) -> anyhow::Result> { let (wake_tx, _wake_rx) = watch::channel(0); Ok(Arc::new( - UnifiedExecProcess::from_exec_server_started(StartedExecProcess { - process: Arc::new(BlockingTerminateExecProcess { - process_id: process_id.to_string().into(), - terminate_started, - allow_terminate, - wake_tx, - }), - sandbox: SandboxType::None, - }) + UnifiedExecProcess::from_exec_server_started( + StartedExecProcess { + process: Arc::new(BlockingTerminateExecProcess { + process_id: process_id.to_string().into(), + terminate_started, + allow_terminate, + wake_tx, + }), + }, + SandboxType::None, + ) .await?, )) } diff --git a/codex-rs/core/src/unified_exec/process.rs b/codex-rs/core/src/unified_exec/process.rs index bfa159e33286..725be9eed228 100644 --- a/codex-rs/core/src/unified_exec/process.rs +++ b/codex-rs/core/src/unified_exec/process.rs @@ -374,8 +374,8 @@ impl UnifiedExecProcess { pub(super) async fn from_exec_server_started( started: StartedExecProcess, + sandbox_type: SandboxType, ) -> Result { - let sandbox_type = started.sandbox; let process_handle = ProcessHandle::ExecServer(Arc::clone(&started.process)); let mut managed = Self::new(process_handle, sandbox_type, /*spawn_lifecycle*/ None); let output_handles = managed.output_handles(); diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index f3750ecf862e..a3b4bcf9c76e 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -1041,7 +1041,7 @@ impl UnifiedExecProcessManager { .await .map_err(|err| UnifiedExecError::create_process(err.to_string()))?; spawn_lifecycle.after_spawn(); - return UnifiedExecProcess::from_exec_server_started(started).await; + return UnifiedExecProcess::from_exec_server_started(started, request.sandbox).await; } // TODO(anp): Keep PathUri through the local PTY/process launch boundary. diff --git a/codex-rs/core/src/unified_exec/process_tests.rs b/codex-rs/core/src/unified_exec/process_tests.rs index 1e5863b6ce0e..37ec5d858b22 100644 --- a/codex-rs/core/src/unified_exec/process_tests.rs +++ b/codex-rs/core/src/unified_exec/process_tests.rs @@ -1,13 +1,10 @@ use super::process::UnifiedExecProcess; use crate::unified_exec::UnifiedExecError; -use codex_exec_server::ByteChunk; -use codex_exec_server::ExecOutputStream; use codex_exec_server::ExecProcess; use codex_exec_server::ExecProcessEventReceiver; use codex_exec_server::ExecProcessFuture; use codex_exec_server::ExecServerError; use codex_exec_server::ProcessId; -use codex_exec_server::ProcessOutputChunk; use codex_exec_server::ProcessSignal; use codex_exec_server::ReadResponse; use codex_exec_server::StartedExecProcess; @@ -104,10 +101,9 @@ async fn remote_process( terminate_error, wake_tx, }), - sandbox: SandboxType::None, }; - UnifiedExecProcess::from_exec_server_started(started) + UnifiedExecProcess::from_exec_server_started(started, SandboxType::None) .await .expect("remote process should start") } @@ -198,7 +194,6 @@ async fn remote_process_waits_for_early_exit_event() { terminate_error: None, wake_tx: wake_tx.clone(), }), - sandbox: SandboxType::None, }; tokio::spawn(async move { @@ -206,45 +201,10 @@ async fn remote_process_waits_for_early_exit_event() { let _ = wake_tx.send(1); }); - let process = UnifiedExecProcess::from_exec_server_started(started) + let process = UnifiedExecProcess::from_exec_server_started(started, SandboxType::None) .await .expect("remote process should observe early exit"); assert!(process.has_exited()); assert_eq!(process.exit_code(), Some(17)); } - -#[tokio::test] -async fn remote_process_uses_executor_sandbox_for_denial_detection() { - let (wake_tx, _wake_rx) = watch::channel(0); - let started = StartedExecProcess { - process: Arc::new(MockExecProcess { - process_id: "test-process".to_string().into(), - write_response: WriteResponse { - status: WriteStatus::Accepted, - }, - read_responses: Mutex::new(VecDeque::from([ReadResponse { - chunks: vec![ProcessOutputChunk { - seq: 1, - stream: ExecOutputStream::Stderr, - chunk: ByteChunk::from(b"Permission denied".to_vec()), - }], - next_seq: 2, - exited: true, - exit_code: Some(1), - closed: true, - failure: None, - }])), - terminate_error: None, - wake_tx, - }), - sandbox: SandboxType::LinuxSeccomp, - }; - - let result = UnifiedExecProcess::from_exec_server_started(started).await; - - assert!(matches!( - result, - Err(UnifiedExecError::SandboxDenied { .. }) - )); -} diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index 8dce72f15e6b..88c0c8034288 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -99,7 +99,6 @@ use crate::protocol::WriteParams; use crate::protocol::WriteResponse; use crate::rpc::RpcCallError; use crate::rpc::RpcClient; -use codex_sandboxing::SandboxType; pub(crate) mod http_client; #[path = "client_recovery.rs"] @@ -635,7 +634,7 @@ impl ExecServerClient { pub(crate) async fn start_process( &self, params: ExecParams, - ) -> Result<(Session, SandboxType), ExecServerError> { + ) -> Result { loop { let rpc_client = self.inner.rpc_client().await?; if !self.inner.begin_process_start(&rpc_client) { @@ -659,15 +658,14 @@ impl ExecServerClient { .call_rpc::<_, ExecResponse>(&rpc_client, EXEC_METHOD, ¶ms) .await { - Ok(response) => { + Ok(_) => { state.recoverable.store(true, Ordering::Release); let session = Session { client: client.clone(), process_id: process_id.clone(), state: Arc::clone(&state), }; - let sandbox = response.sandbox.unwrap_or(SandboxType::None); - if result_tx.send(Ok((session, sandbox))).is_err() { + if result_tx.send(Ok(session)).is_err() { state.recoverable.store(false, Ordering::Release); tokio::spawn(async move { cleanup_process_start(&client, &process_id, &state).await; diff --git a/codex-rs/exec-server/src/local_process.rs b/codex-rs/exec-server/src/local_process.rs index 205546310fc4..f97aa7c42b43 100644 --- a/codex-rs/exec-server/src/local_process.rs +++ b/codex-rs/exec-server/src/local_process.rs @@ -11,7 +11,6 @@ use codex_app_server_protocol::JSONRPCErrorError; use codex_protocol::config_types::EnvironmentVariablePattern; use codex_protocol::config_types::ShellEnvironmentPolicy; use codex_protocol::shell_environment; -use codex_sandboxing::SandboxType; use codex_utils_pty::ExecCommandSession; use codex_utils_pty::ProcessSignal as PtyProcessSignal; use codex_utils_pty::TerminalSize; @@ -151,10 +150,7 @@ impl Default for LocalProcess { let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(NOTIFICATION_CHANNEL_CAPACITY); tokio::spawn(async move { while outgoing_rx.recv().await.is_some() {} }); - Self::with_runtime_paths( - RpcNotificationSender::new(outgoing_tx), - /*runtime_paths*/ None, - ) + Self::with_runtime_paths(RpcNotificationSender::new(outgoing_tx), None) } } @@ -342,14 +338,7 @@ impl LocalProcess { output_notify, )); - Ok(( - ExecResponse { - process_id, - sandbox: Some(prepared.sandbox), - }, - wake_tx, - events, - )) + Ok((ExecResponse { process_id }, wake_tx, events)) } pub(crate) async fn exec(&self, params: ExecParams) -> Result { @@ -593,7 +582,6 @@ impl LocalProcess { wake_tx, events, }), - sandbox: response.sandbox.unwrap_or(SandboxType::None), }) } } diff --git a/codex-rs/exec-server/src/process.rs b/codex-rs/exec-server/src/process.rs index ea5c8c118019..b136ef4682fa 100644 --- a/codex-rs/exec-server/src/process.rs +++ b/codex-rs/exec-server/src/process.rs @@ -7,8 +7,6 @@ use std::sync::Mutex as StdMutex; use tokio::sync::broadcast; use tokio::sync::watch; -use codex_sandboxing::SandboxType; - use crate::ExecServerError; use crate::ProcessId; use crate::protocol::ExecParams; @@ -19,8 +17,6 @@ use crate::protocol::WriteResponse; pub struct StartedExecProcess { pub process: Arc, - /// Concrete sandbox selected by the executor that owns `process`. - pub sandbox: SandboxType, } /// Pushed process events for consumers that want to follow process output as it diff --git a/codex-rs/exec-server/src/process_sandbox.rs b/codex-rs/exec-server/src/process_sandbox.rs index 918af8b32547..861164a19e04 100644 --- a/codex-rs/exec-server/src/process_sandbox.rs +++ b/codex-rs/exec-server/src/process_sandbox.rs @@ -6,7 +6,6 @@ use codex_sandboxing::SandboxCommand; use codex_sandboxing::SandboxDirectSpawnTransformRequest; use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxTransformRequest; -use codex_sandboxing::SandboxType; use codex_sandboxing::SandboxablePreference; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; @@ -20,7 +19,6 @@ pub(crate) struct PreparedExecRequest { pub(crate) cwd: AbsolutePathBuf, pub(crate) env: HashMap, pub(crate) arg0: Option, - pub(crate) sandbox: SandboxType, } pub(crate) fn prepare_exec_request( @@ -34,7 +32,6 @@ pub(crate) fn prepare_exec_request( cwd: native_path(¶ms.cwd, "cwd")?, env, arg0: params.arg0.clone(), - sandbox: SandboxType::None, }); }; let runtime_paths = runtime_paths @@ -114,7 +111,6 @@ pub(crate) fn prepare_exec_request( cwd: native_path(&request.cwd, "cwd")?, env: request.env, arg0: request.arg0, - sandbox: request.sandbox, }) } diff --git a/codex-rs/exec-server/src/process_sandbox_tests.rs b/codex-rs/exec-server/src/process_sandbox_tests.rs index 1b0408afd85d..ad7718f7dbc9 100644 --- a/codex-rs/exec-server/src/process_sandbox_tests.rs +++ b/codex-rs/exec-server/src/process_sandbox_tests.rs @@ -1,6 +1,5 @@ use std::collections::HashMap; -#[cfg(unix)] use codex_protocol::models::PermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; @@ -8,9 +7,7 @@ use pretty_assertions::assert_eq; use super::prepare_exec_request; use crate::ExecParams; -#[cfg(unix)] use crate::ExecServerRuntimePaths; -#[cfg(unix)] use crate::FileSystemSandboxContext; use crate::ProcessId; @@ -99,8 +96,8 @@ fn native_request_preserves_native_launch_fields() { enforce_managed_network: false, }; - let prepared = prepare_exec_request(¶ms, env.clone(), /*runtime_paths*/ None) - .expect("prepare native request"); + let prepared = + prepare_exec_request(¶ms, env.clone(), None).expect("prepare native request"); assert_eq!(prepared.command, params.argv); assert_eq!(prepared.cwd, cwd); diff --git a/codex-rs/exec-server/src/protocol.rs b/codex-rs/exec-server/src/protocol.rs index fdce9d5d2cca..e05595f273f6 100644 --- a/codex-rs/exec-server/src/protocol.rs +++ b/codex-rs/exec-server/src/protocol.rs @@ -3,7 +3,6 @@ use std::collections::HashMap; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_file_system::FileSystemSandboxContext; use codex_protocol::config_types::ShellEnvironmentPolicyInherit; -use codex_sandboxing::SandboxType; use codex_utils_path_uri::PathUri; use serde::Deserialize; use serde::Serialize; @@ -126,11 +125,6 @@ pub struct ExecEnvPolicy { #[serde(rename_all = "camelCase")] pub struct ExecResponse { pub process_id: ProcessId, - /// Concrete sandbox selected by this executor. - /// - /// Older exec servers omit this field, so clients must treat `None` as unknown. - #[serde(default)] - pub sandbox: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/codex-rs/exec-server/src/remote_process.rs b/codex-rs/exec-server/src/remote_process.rs index 23f7b42fca4e..72f41ae70e09 100644 --- a/codex-rs/exec-server/src/remote_process.rs +++ b/codex-rs/exec-server/src/remote_process.rs @@ -36,11 +36,10 @@ impl RemoteProcess { params: ExecParams, ) -> Result { let client = self.client.get().await?; - let (session, sandbox) = client.start_process(params).await?; + let session = client.start_process(params).await?; Ok(StartedExecProcess { process: Arc::new(RemoteExecProcess { session }), - sandbox, }) } } diff --git a/codex-rs/exec-server/tests/exec_process.rs b/codex-rs/exec-server/tests/exec_process.rs index f35923815d45..ac38f50eafe7 100644 --- a/codex-rs/exec-server/tests/exec_process.rs +++ b/codex-rs/exec-server/tests/exec_process.rs @@ -230,7 +230,7 @@ async fn assert_exec_process_streams_output(use_remote: bool) -> Result<()> { .await?; assert_eq!(session.process.process_id().as_str(), process_id); - let StartedExecProcess { process, .. } = session; + let StartedExecProcess { process } = session; let wake_rx = process.subscribe_wake(); let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?; assert_eq!(output, "session output\n"); @@ -263,7 +263,7 @@ async fn assert_exec_process_pushes_events(use_remote: bool) -> Result<()> { .await?; assert_eq!(session.process.process_id().as_str(), process_id); - let StartedExecProcess { process, .. } = session; + let StartedExecProcess { process } = session; let actual = collect_process_event_snapshots(process).await?; assert_eq!( actual, @@ -312,7 +312,7 @@ async fn assert_exec_process_replays_events_after_close(use_remote: bool) -> Res .await?; assert_eq!(session.process.process_id().as_str(), process_id); - let StartedExecProcess { process, .. } = session; + let StartedExecProcess { process } = session; let wake_rx = process.subscribe_wake(); let read_result = collect_process_output_from_reads(Arc::clone(&process), wake_rx).await?; assert_eq!( @@ -362,7 +362,7 @@ async fn assert_exec_process_retains_output_after_exit_until_streams_close( .await?; assert_eq!(session.process.process_id().as_str(), process_id); - let StartedExecProcess { process, .. } = session; + let StartedExecProcess { process } = session; let exit_response = timeout( Duration::from_secs(2), @@ -439,7 +439,7 @@ async fn assert_exec_process_write_then_read(use_remote: bool) -> Result<()> { tokio::time::sleep(Duration::from_millis(200)).await; session.process.write(b"hello\n".to_vec()).await?; - let StartedExecProcess { process, .. } = session; + let StartedExecProcess { process } = session; let wake_rx = process.subscribe_wake(); let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?; @@ -479,7 +479,7 @@ async fn assert_exec_process_write_then_read_without_tty(use_remote: bool) -> Re tokio::time::sleep(Duration::from_millis(200)).await; let write_response = session.process.write(b"hello\n".to_vec()).await?; assert_eq!(write_response.status, WriteStatus::Accepted); - let StartedExecProcess { process, .. } = session; + let StartedExecProcess { process } = session; let wake_rx = process.subscribe_wake(); let actual = collect_process_output_from_reads(process, wake_rx).await?; @@ -513,7 +513,7 @@ async fn assert_exec_process_rejects_write_without_pipe_stdin(use_remote: bool) let write_response = session.process.write(b"ignored\n".to_vec()).await?; assert_eq!(write_response.status, WriteStatus::StdinClosed); - let StartedExecProcess { process, .. } = session; + let StartedExecProcess { process } = session; let wake_rx = process.subscribe_wake(); let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?; @@ -547,7 +547,7 @@ async fn assert_exec_process_signal_interrupts_process(use_remote: bool) -> Resu .await?; assert_eq!(session.process.process_id().as_str(), process_id); - let StartedExecProcess { process, .. } = session; + let StartedExecProcess { process } = session; let mut wake_rx = process.subscribe_wake(); let mut ready_output = String::new(); let mut after_seq = None; @@ -645,7 +645,7 @@ async fn assert_exec_process_preserves_queued_events_before_subscribe( tokio::time::sleep(Duration::from_millis(200)).await; - let StartedExecProcess { process, .. } = session; + let StartedExecProcess { process } = session; let wake_rx = process.subscribe_wake(); let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?; assert_eq!(output, "queued output\n"); diff --git a/codex-rs/exec-server/tests/process.rs b/codex-rs/exec-server/tests/process.rs index cc2772cb328f..571fd427fa8a 100644 --- a/codex-rs/exec-server/tests/process.rs +++ b/codex-rs/exec-server/tests/process.rs @@ -70,8 +70,7 @@ async fn exec_server_starts_process_over_websocket() -> anyhow::Result<()> { assert_eq!( process_start_response, ExecResponse { - process_id: ProcessId::from("proc-1"), - sandbox: Some(codex_sandboxing::SandboxType::None), + process_id: ProcessId::from("proc-1") } ); @@ -136,8 +135,7 @@ async fn exec_server_defaults_omitted_pipe_stdin_to_closed_stdin() -> anyhow::Re assert_eq!( process_start_response, ExecResponse { - process_id: ProcessId::from("proc-default-stdin"), - sandbox: Some(codex_sandboxing::SandboxType::None), + process_id: ProcessId::from("proc-default-stdin") } ); diff --git a/codex-rs/exec-server/tests/relay.rs b/codex-rs/exec-server/tests/relay.rs index 8ace8bbb4f2a..5f49655e3939 100644 --- a/codex-rs/exec-server/tests/relay.rs +++ b/codex-rs/exec-server/tests/relay.rs @@ -157,8 +157,7 @@ async fn remote_environment_routes_encrypted_exec_server_rpc() -> Result<()> { assert_eq!( response, ExecResponse { - process_id: ProcessId::from("proc-1"), - sandbox: Some(codex_sandboxing::SandboxType::None), + process_id: ProcessId::from("proc-1") } ); diff --git a/codex-rs/sandboxing/Cargo.toml b/codex-rs/sandboxing/Cargo.toml index ed7746d3b6f0..3185d782baa6 100644 --- a/codex-rs/sandboxing/Cargo.toml +++ b/codex-rs/sandboxing/Cargo.toml @@ -22,7 +22,6 @@ dunce = { workspace = true } libc = { workspace = true } serde_json = { workspace = true } regex-lite = { workspace = true } -serde = { workspace = true } tracing = { workspace = true, features = ["log"] } url = { workspace = true } which = { workspace = true } diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs index cb17a73fed89..29b1177b11de 100644 --- a/codex-rs/sandboxing/src/manager.rs +++ b/codex-rs/sandboxing/src/manager.rs @@ -30,8 +30,7 @@ use std::path::Path; #[cfg(target_os = "windows")] const WINDOWS_SANDBOX_WRAPPER_SETUP_ENV_ALLOWLIST: &[&str] = &["USERNAME", "USERPROFILE"]; -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Deserialize, serde::Serialize)] -#[serde(rename_all = "camelCase")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SandboxType { None, MacosSeatbelt, From e9c548f3172623154fa90896bfaaf8de35687f78 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 22 Jun 2026 11:21:18 +0200 Subject: [PATCH 10/13] Document remote sandbox portability follow-ups --- codex-rs/exec-server/src/process_sandbox.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/codex-rs/exec-server/src/process_sandbox.rs b/codex-rs/exec-server/src/process_sandbox.rs index 861164a19e04..be95b92d34fa 100644 --- a/codex-rs/exec-server/src/process_sandbox.rs +++ b/codex-rs/exec-server/src/process_sandbox.rs @@ -6,6 +6,7 @@ use codex_sandboxing::SandboxCommand; use codex_sandboxing::SandboxDirectSpawnTransformRequest; use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxTransformRequest; +use codex_sandboxing::SandboxType; use codex_sandboxing::SandboxablePreference; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; @@ -36,6 +37,8 @@ pub(crate) fn prepare_exec_request( }; let runtime_paths = runtime_paths .ok_or_else(|| invalid_params("sandbox runtime paths are not configured".to_string()))?; + // TODO(jif): Transport permissions before orchestrator-local paths are materialized, + // then resolve executor-local helper and workspace paths here. let permissions: PermissionProfile = sandbox_context .permissions .clone() @@ -86,6 +89,8 @@ pub(crate) fn prepare_exec_request( .transform_for_direct_spawn(SandboxDirectSpawnTransformRequest { workspace_roots, transform: SandboxTransformRequest { + // TODO(jif): Preserve params.arg0 for the inner command across the sandbox + // wrapper, or reject sandboxed requests with a custom arg0. command: SandboxCommand { program: program.into(), args: args.to_vec(), From 6534b0adf6db0c81c6685d2704bf5e5ab02358b5 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 22 Jun 2026 12:03:41 +0200 Subject: [PATCH 11/13] Fix argument comments for runtime paths --- codex-rs/exec-server/src/local_process.rs | 5 ++++- codex-rs/exec-server/src/process_sandbox_tests.rs | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/codex-rs/exec-server/src/local_process.rs b/codex-rs/exec-server/src/local_process.rs index f97aa7c42b43..20855d6f7e15 100644 --- a/codex-rs/exec-server/src/local_process.rs +++ b/codex-rs/exec-server/src/local_process.rs @@ -150,7 +150,10 @@ impl Default for LocalProcess { let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(NOTIFICATION_CHANNEL_CAPACITY); tokio::spawn(async move { while outgoing_rx.recv().await.is_some() {} }); - Self::with_runtime_paths(RpcNotificationSender::new(outgoing_tx), None) + Self::with_runtime_paths( + RpcNotificationSender::new(outgoing_tx), + /*runtime_paths*/ None, + ) } } diff --git a/codex-rs/exec-server/src/process_sandbox_tests.rs b/codex-rs/exec-server/src/process_sandbox_tests.rs index ad7718f7dbc9..7d6a12dc7b48 100644 --- a/codex-rs/exec-server/src/process_sandbox_tests.rs +++ b/codex-rs/exec-server/src/process_sandbox_tests.rs @@ -96,8 +96,8 @@ fn native_request_preserves_native_launch_fields() { enforce_managed_network: false, }; - let prepared = - prepare_exec_request(¶ms, env.clone(), None).expect("prepare native request"); + let prepared = prepare_exec_request(¶ms, env.clone(), /*runtime_paths*/ None) + .expect("prepare native request"); assert_eq!(prepared.command, params.argv); assert_eq!(prepared.cwd, cwd); From 9c303c6736f852ab2b044da7e43879fb014a5782 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 22 Jun 2026 12:20:27 +0200 Subject: [PATCH 12/13] Pass runtime paths to local exec backend --- codex-rs/exec-server/src/environment.rs | 48 ++++++++++++++++++++++- codex-rs/exec-server/src/local_process.rs | 17 +++++--- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index d3e7e5e9815a..5990d8811dff 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -488,7 +488,9 @@ impl Environment { exec_server_url: None, remote_client: None, startup_task: Arc::new(Mutex::new(None)), - exec_backend: Arc::new(LocalProcess::default()), + exec_backend: Arc::new(LocalProcess::with_local_runtime_paths( + local_runtime_paths.clone(), + )), filesystem: Arc::new(LocalFileSystem::with_runtime_paths( local_runtime_paths.clone(), )), @@ -1165,6 +1167,50 @@ mod tests { assert_eq!(response.process.process_id().as_str(), "default-env-proc"); } + #[tokio::test] + async fn local_environment_passes_runtime_paths_to_exec_backend() { + let environment = Environment::local(test_runtime_paths()); + #[cfg(unix)] + let uri = "file://server/share/checkout"; + #[cfg(windows)] + let uri = "file:///usr/local/checkout"; + let sandbox_cwd = PathUri::parse(uri).expect("non-native sandbox cwd URI"); + let source = sandbox_cwd + .to_abs_path() + .expect_err("sandbox cwd should not be native to this host"); + let sandbox = crate::FileSystemSandboxContext::from_permission_profile_with_cwd( + codex_protocol::models::PermissionProfile::workspace_write(), + sandbox_cwd.clone(), + ); + + let result = environment + .get_exec_backend() + .start(crate::ExecParams { + process_id: ProcessId::from("local-sandbox-proc"), + argv: vec!["true".to_string()], + cwd: PathUri::from_path(std::env::current_dir().expect("read current dir")) + .expect("cwd URI"), + env_policy: None, + env: Default::default(), + tty: false, + pipe_stdin: false, + arg0: None, + sandbox: Some(sandbox), + enforce_managed_network: false, + }) + .await; + let Err(err) = result else { + panic!("sandbox cwd should be rejected after resolving runtime paths"); + }; + + assert_eq!( + err.to_string(), + format!( + "exec-server rejected request (-32602): sandbox cwd URI `{sandbox_cwd}` is not valid on this exec-server host: {source}" + ) + ); + } + #[tokio::test] async fn test_environment_rejects_sandboxed_filesystem_without_runtime_paths() { let environment = Environment::default_for_tests(); diff --git a/codex-rs/exec-server/src/local_process.rs b/codex-rs/exec-server/src/local_process.rs index 20855d6f7e15..802c8e69cff2 100644 --- a/codex-rs/exec-server/src/local_process.rs +++ b/codex-rs/exec-server/src/local_process.rs @@ -147,17 +147,22 @@ struct LocalExecProcess { impl Default for LocalProcess { fn default() -> Self { + Self::with_discarded_notifications(/*runtime_paths*/ None) + } +} + +impl LocalProcess { + pub(crate) fn with_local_runtime_paths(runtime_paths: ExecServerRuntimePaths) -> Self { + Self::with_discarded_notifications(Some(runtime_paths)) + } + + fn with_discarded_notifications(runtime_paths: Option) -> Self { let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(NOTIFICATION_CHANNEL_CAPACITY); tokio::spawn(async move { while outgoing_rx.recv().await.is_some() {} }); - Self::with_runtime_paths( - RpcNotificationSender::new(outgoing_tx), - /*runtime_paths*/ None, - ) + Self::with_runtime_paths(RpcNotificationSender::new(outgoing_tx), runtime_paths) } -} -impl LocalProcess { pub(crate) fn new( notifications: RpcNotificationSender, runtime_paths: ExecServerRuntimePaths, From 4b31996db7614a03ab011453284767b8eecb1f38 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 22 Jun 2026 12:34:28 +0200 Subject: [PATCH 13/13] Gate sandbox test imports on Unix --- codex-rs/exec-server/src/process_sandbox_tests.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/codex-rs/exec-server/src/process_sandbox_tests.rs b/codex-rs/exec-server/src/process_sandbox_tests.rs index 7d6a12dc7b48..1b0408afd85d 100644 --- a/codex-rs/exec-server/src/process_sandbox_tests.rs +++ b/codex-rs/exec-server/src/process_sandbox_tests.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; +#[cfg(unix)] use codex_protocol::models::PermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; @@ -7,7 +8,9 @@ use pretty_assertions::assert_eq; use super::prepare_exec_request; use crate::ExecParams; +#[cfg(unix)] use crate::ExecServerRuntimePaths; +#[cfg(unix)] use crate::FileSystemSandboxContext; use crate::ProcessId;