diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 5f6c97cecd..8c20146199 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -569,6 +569,10 @@ pub async fn run_main_with_transport_options( } }; config.auth_config().validate()?; + #[cfg(target_os = "macos")] + let local_runtime_paths = local_runtime_paths.with_allowed_symlinked_codex_home( + codex_config::allowed_symlinked_codex_home(&config.config_layer_stack, &config.codex_home), + ); let code_mode_session_provider: Option> = match &runtime_options.code_mode_host_transport { CodeModeHostTransport::Local => None, diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index e339cc2ea1..c70b52d2dd 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1881,6 +1881,13 @@ async fn run_exec_server_command( ) .await?; let (_otel, telemetry) = exec_server_telemetry::init(Some(&config)); + #[cfg(target_os = "macos")] + let runtime_paths = runtime_paths.with_allowed_symlinked_codex_home( + codex_config::allowed_symlinked_codex_home( + &config.config_layer_stack, + &config.codex_home, + ), + ); run_remote_exec_server( cmd, base_url, @@ -1903,6 +1910,14 @@ async fn run_exec_server_command( config_result.ok() }; let (_otel, telemetry) = exec_server_telemetry::init(config.as_ref()); + #[cfg(target_os = "macos")] + let runtime_paths = + runtime_paths.with_allowed_symlinked_codex_home(config.as_ref().and_then(|config| { + codex_config::allowed_symlinked_codex_home( + &config.config_layer_stack, + &config.codex_home, + ) + })); let http_client_factory = config .as_ref() .map(Config::http_client_factory) diff --git a/codex-rs/config/src/codex_home_symlink.rs b/codex-rs/config/src/codex_home_symlink.rs new file mode 100644 index 0000000000..bf544f4920 --- /dev/null +++ b/codex-rs/config/src/codex_home_symlink.rs @@ -0,0 +1,28 @@ +//! Resolves the host's symlink opt-out from its already-loaded user config. + +use crate::ConfigLayerSource; +use crate::ConfigLayerStack; +use codex_utils_absolute_path::AbsolutePathBuf; + +/// Returns the opted-in home without consulting project config or rereading config files. +pub fn allowed_symlinked_codex_home( + config_layer_stack: &ConfigLayerStack, + codex_home: &AbsolutePathBuf, +) -> Option { + let enabled = config_layer_stack + .layers_low_to_high() + .find(|layer| matches!(layer.name, ConfigLayerSource::User { profile: None, .. }))? + .config + .get("allow_symlinked_codex_home")? + .as_bool()?; + if !enabled { + return None; + } + + // Preserve a configured alias only when lexical normalization did not change its target. + let alias = std::env::var_os("CODEX_HOME") + .filter(|path| !path.is_empty()) + .and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok()) + .filter(|path| path.canonicalize().ok().as_ref() == Some(codex_home)); + Some(alias.unwrap_or_else(|| codex_home.clone())) +} diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 2698bda7fc..7bf981482f 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -204,6 +204,13 @@ pub struct ConfigToml { /// Sandbox mode to use. pub sandbox_mode: Option, + /// Allow macOS sandbox writable roots at or beneath CODEX_HOME to traverse + /// symlinks. Read only from the host's user config at startup; defaults to false. + /// This grants no write access by itself, but trusts symlink targets even if + /// they change between commands or lie outside CODEX_HOME. + /// This setting has no effect on Linux or Windows. + pub allow_symlinked_codex_home: Option, + /// Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`. pub sandbox_workspace_write: Option, diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index 6f5c4d8715..b7f03b70ff 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -4,6 +4,8 @@ mod browser_computer_use_requirements; mod browser_use; mod cloud_config_bundle; mod cloud_config_layers; +#[cfg(target_os = "macos")] +mod codex_home_symlink; mod computer_use; mod config_layer_source; mod config_requirements; @@ -66,6 +68,8 @@ pub use cloud_config_layers::CloudConfigFragmentSource; pub use cloud_config_layers::CloudConfigLayerError; pub use cloud_config_layers::cloud_config_layers_from_fragments; pub use codex_execpolicy::RequirementsExecPolicy; +#[cfg(target_os = "macos")] +pub use codex_home_symlink::allowed_symlinked_codex_home; pub use codex_protocol::config_types::ProfileV2Name; pub use codex_protocol::config_types::ProfileV2NameParseError; pub use codex_protocol::config_types::ToolExposureSurface; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 6f912f3b2a..2450cdbb59 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -5806,6 +5806,10 @@ "description": "Whether the model may request a login shell for shell-based tools. Default to `true`\n\nIf `true`, the model may request a login shell (`login = true`), and omitting `login` defaults to using a login shell. If `false`, the model can never use a login shell: `login = true` requests are rejected, and omitting `login` defaults to a non-login shell.", "type": "boolean" }, + "allow_symlinked_codex_home": { + "description": "Allow macOS sandbox writable roots at or beneath CODEX_HOME to traverse symlinks. Read only from the host's user config at startup; defaults to false. This grants no write access by itself, but trusts symlink targets even if they change between commands or lie outside CODEX_HOME. This setting has no effect on Linux or Windows.", + "type": "boolean" + }, "analytics": { "allOf": [ { diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 3b3757171b..ad5086f500 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -37,9 +37,7 @@ use codex_sandboxing::policy_transforms::effective_network_sandbox_policy; use std::sync::Arc; use std::time::Instant; -pub(crate) struct ToolOrchestrator { - sandbox: SandboxManager, -} +pub(crate) struct ToolOrchestrator; pub(crate) struct OrchestratorRunResult { pub output: Out, @@ -48,9 +46,7 @@ pub(crate) struct OrchestratorRunResult { impl ToolOrchestrator { pub fn new() -> Self { - Self { - sandbox: SandboxManager::new(), - } + Self } async fn run_attempt( @@ -145,6 +141,14 @@ impl ToolOrchestrator { let mut already_approved = false; let environment = tool.turn_environment(req); + let sandbox_manager = SandboxManager::new(); + #[cfg(target_os = "macos")] + let sandbox_manager = sandbox_manager.with_allowed_symlinked_codex_home( + environment + .environment + .local_runtime_paths() + .and_then(|paths| paths.allowed_symlinked_codex_home.clone()), + ); let sandbox_config = environment.config(); let owner_network_policy = sandbox_config.network_policy.is_some(); if owner_network_policy @@ -264,14 +268,14 @@ impl ToolOrchestrator { let sandbox_preference = tool.sandbox_preference(); let sandbox_requested = match sandbox_override { SandboxOverride::BypassSandboxFirstAttempt => false, - SandboxOverride::NoOverride => self.sandbox.should_sandbox( + SandboxOverride::NoOverride => sandbox_manager.should_sandbox( &permissions, sandbox_preference, managed_network_active, ), }; let initial_sandbox = if sandbox_requested && !executor_managed_process_sandbox { - self.sandbox.select_initial( + sandbox_manager.select_initial( &permissions, sandbox_preference, sandbox_config.windows_sandbox_level, @@ -291,7 +295,7 @@ impl ToolOrchestrator { permissions: &permissions, exec_server_permissions: permission_profile, enforce_managed_network: managed_network_active, - manager: &self.sandbox, + manager: &sandbox_manager, sandbox_cwd: &sandbox_policy_cwd, workspace_roots, codex_linux_sandbox_exe: turn_ctx.config.codex_linux_sandbox_exe.as_ref(), @@ -438,14 +442,14 @@ impl ToolOrchestrator { } let retry_sandbox_requested = !unsandboxed_allowed - && self.sandbox.should_sandbox( + && sandbox_manager.should_sandbox( &permissions, sandbox_preference, managed_network_active, ); let retry_sandbox = if retry_sandbox_requested && !executor_managed_process_sandbox { - self.sandbox.select_initial( + sandbox_manager.select_initial( &permissions, sandbox_preference, sandbox_config.windows_sandbox_level, @@ -465,7 +469,7 @@ impl ToolOrchestrator { permissions: &permissions, exec_server_permissions: permission_profile, enforce_managed_network: managed_network_active, - manager: &self.sandbox, + manager: &sandbox_manager, sandbox_cwd: &sandbox_policy_cwd, workspace_roots, codex_linux_sandbox_exe: retry_codex_linux_sandbox_exe, diff --git a/codex-rs/core/src/tools/runtimes/zsh_fork/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/zsh_fork/unix_escalation.rs index 0c3ae3dc4c..dfd66f583c 100644 --- a/codex-rs/core/src/tools/runtimes/zsh_fork/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/zsh_fork/unix_escalation.rs @@ -84,7 +84,7 @@ fn approval_sandbox_permissions( pub(crate) async fn prepare_unified_exec_zsh_fork( req: &crate::tools::runtimes::unified_exec::UnifiedExecRequest, - _attempt: &SandboxAttempt<'_>, + attempt: &SandboxAttempt<'_>, ctx: &ToolCtx, exec_request: ExecRequest, shell_zsh_path: &std::path::Path, @@ -128,6 +128,7 @@ pub(crate) async fn prepare_unified_exec_zsh_fork( .to_abs_path() .map_err(|err| ToolError::Rejected(err.to_string()))?; let command_executor = CoreShellCommandExecutor { + sandbox_manager: attempt.manager.clone(), command: exec_request.command.clone(), cwd, permission_profile: exec_request.permission_profile.clone(), @@ -565,6 +566,7 @@ fn commands_for_intercepted_exec_policy( // TODO(anp): Capture these Windows and Landlock settings from // TurnEnvironment::sandbox_context when preparing this executor, preserving its snapshot. struct CoreShellCommandExecutor { + sandbox_manager: SandboxManager, command: Vec, cwd: AbsolutePathBuf, permission_profile: PermissionProfile, @@ -757,7 +759,7 @@ impl CoreShellCommandExecutor { let (program, args) = command .split_first() .ok_or_else(|| anyhow::anyhow!("prepared command must not be empty"))?; - let sandbox_manager = SandboxManager::new(); + let sandbox_manager = &self.sandbox_manager; let sandbox = sandbox_manager.select_initial( permission_profile, SandboxablePreference::Auto, diff --git a/codex-rs/core/src/tools/runtimes/zsh_fork/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/zsh_fork/unix_escalation_tests.rs index 84343cd3b3..9d3c5e8ade 100644 --- a/codex-rs/core/src/tools/runtimes/zsh_fork/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/zsh_fork/unix_escalation_tests.rs @@ -358,6 +358,7 @@ async fn unsandboxed_intercepted_exec_strips_managed_network_env() -> anyhow::Re let workdir = test_sandbox_cwd(); let network = test_credential_broker_network_proxy().await?; let executor = CoreShellCommandExecutor { + sandbox_manager: codex_sandboxing::SandboxManager::new(), command: Vec::new(), cwd: workdir.clone(), permission_profile: PermissionProfile::workspace_write(), diff --git a/codex-rs/core/tests/suite/agents_md.rs b/codex-rs/core/tests/suite/agents_md.rs index 4a93b247f4..9790773706 100644 --- a/codex-rs/core/tests/suite/agents_md.rs +++ b/codex-rs/core/tests/suite/agents_md.rs @@ -686,12 +686,15 @@ async fn symlinked_writable_root_reports_sandbox_failure_instead_of_session_corr -> Result<()> { let server = start_mock_server().await; let home = Arc::new(TempDir::new()?); + let home_path = home.path().display().to_string(); + let canonical_home_path = home.path().canonicalize()?.display().to_string(); let visualization_target = home.path().join("visualization-target"); std::fs::create_dir(&visualization_target)?; let visualization_root = home.path().join("visualizations"); create_directory_symlink(&visualization_target, &visualization_root); let mut builder = test_codex().with_home(home).with_config(move |config| { + config.project_doc_max_bytes = 1; let mut file_system_policy = FileSystemSandboxPolicy::read_only(); file_system_policy.entries.push(FileSystemSandboxEntry::new( config.cwd.join("private.txt").into(), @@ -727,6 +730,13 @@ async fn symlinked_writable_root_reports_sandbox_failure_instead_of_session_corr !error.contains("Session data under"), "sandbox preparation failure should not be diagnosed as session corruption: {error}" ); + let error = error + .replace(&canonical_home_path, "$CODEX_HOME") + .replace(&home_path, "$CODEX_HOME"); + insta::assert_snapshot!(error, @" + Fatal error: Failed to initialize session: failed to load AGENTS.md instructions for environment `local`: failed to prepare fs sandbox: failed to prepare Seatbelt sandbox: writable root $CODEX_HOME/visualizations contains symlink component $CODEX_HOME/visualizations; symlinked writable roots are not supported. + If this writable root is at or beneath CODEX_HOME and you trust its symlink targets, set `allow_symlinked_codex_home = true` at the top level of `$CODEX_HOME/config.toml` (normally `~/.codex/config.toml`) on the execution host, then restart Codex or its executor. This opt-out trusts targets outside CODEX_HOME and targets changed between commands. It does not apply to other writable roots. + "); Ok(()) } diff --git a/codex-rs/exec-server/src/fs_sandbox.rs b/codex-rs/exec-server/src/fs_sandbox.rs index 98cf5dfb4c..c9a960945f 100644 --- a/codex-rs/exec-server/src/fs_sandbox.rs +++ b/codex-rs/exec-server/src/fs_sandbox.rs @@ -129,6 +129,10 @@ impl FileSystemSandboxRunner { ) -> Result { let helper = &self.runtime_paths.codex_self_exe; let sandbox_manager = SandboxManager::for_file_system_helpers(); + #[cfg(target_os = "macos")] + let sandbox_manager = sandbox_manager.with_allowed_symlinked_codex_home( + self.runtime_paths.allowed_symlinked_codex_home.clone(), + ); let sandbox = sandbox_manager.select_initial( permission_profile, SandboxablePreference::Require, diff --git a/codex-rs/exec-server/src/process_sandbox.rs b/codex-rs/exec-server/src/process_sandbox.rs index 99d96a7339..a16c182c73 100644 --- a/codex-rs/exec-server/src/process_sandbox.rs +++ b/codex-rs/exec-server/src/process_sandbox.rs @@ -185,6 +185,9 @@ pub(crate) async fn prepare_exec_request_with_telemetry( network_policy, ); let sandbox_manager = SandboxManager::new(); + #[cfg(target_os = "macos")] + let sandbox_manager = sandbox_manager + .with_allowed_symlinked_codex_home(runtime_paths.allowed_symlinked_codex_home.clone()); let sandbox = sandbox_manager.select_initial( &permissions, SandboxablePreference::Require, diff --git a/codex-rs/exec-server/src/runtime_paths.rs b/codex-rs/exec-server/src/runtime_paths.rs index 1d3713b1c1..0947ed8772 100644 --- a/codex-rs/exec-server/src/runtime_paths.rs +++ b/codex-rs/exec-server/src/runtime_paths.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use codex_utils_absolute_path::AbsolutePathBuf; -/// Runtime paths needed by exec-server child processes. +/// Paths and sandbox settings initialized when creating an executor. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ExecServerRuntimePaths { /// Stable path to the Codex executable used to launch hidden helper modes. @@ -10,6 +10,9 @@ pub struct ExecServerRuntimePaths { /// Path to the Linux sandbox helper alias used when the platform sandbox /// needs to re-enter Codex by argv0. pub codex_linux_sandbox_exe: Option, + /// User-config opt-out of writable-root symlink checks beneath this host's home. + #[cfg(target_os = "macos")] + pub allowed_symlinked_codex_home: Option, } impl ExecServerRuntimePaths { @@ -33,8 +36,20 @@ impl ExecServerRuntimePaths { Ok(Self { codex_self_exe: absolute_path(codex_self_exe)?, codex_linux_sandbox_exe: codex_linux_sandbox_exe.map(absolute_path).transpose()?, + #[cfg(target_os = "macos")] + allowed_symlinked_codex_home: None, }) } + + /// Applies the symlink opt-in resolved by the execution host's config loader. + #[cfg(target_os = "macos")] + pub fn with_allowed_symlinked_codex_home( + mut self, + allowed_symlinked_codex_home: Option, + ) -> Self { + self.allowed_symlinked_codex_home = allowed_symlinked_codex_home; + self + } } fn absolute_path(path: PathBuf) -> std::io::Result { diff --git a/codex-rs/exec-server/tests/common/exec_server.rs b/codex-rs/exec-server/tests/common/exec_server.rs index 439466f4fb..ceba4238ba 100644 --- a/codex-rs/exec-server/tests/common/exec_server.rs +++ b/codex-rs/exec-server/tests/common/exec_server.rs @@ -102,7 +102,13 @@ impl ExecServerHarness { command.stdout(Stdio::piped()); command.stderr(Stdio::inherit()); command.kill_on_drop(true); - command.env("CODEX_HOME", codex_home.path()); + if !command + .as_std() + .get_envs() + .any(|(key, value)| key == "CODEX_HOME" && value.is_some()) + { + command.env("CODEX_HOME", codex_home.path()); + } let mut child = command.spawn()?; let websocket_url = read_listen_url_from_stdout(&mut child).await?; diff --git a/codex-rs/exec-server/tests/common/mod.rs b/codex-rs/exec-server/tests/common/mod.rs index e95aa55639..ca62ab52a8 100644 --- a/codex-rs/exec-server/tests/common/mod.rs +++ b/codex-rs/exec-server/tests/common/mod.rs @@ -222,13 +222,32 @@ fn maybe_run_exec_server_from_test_binary(guard: Option<&TestBinaryDispatchGuard std::process::exit(1); } }; - let exit_code = match runtime.block_on(codex_exec_server::run_main_with_telemetry( - &listen_url, - runtime_paths, - ExecServerTelemetry::default(), - http_client_factory, - request_dispatch_mode, - )) { + let exit_code = match runtime.block_on(async { + #[cfg(target_os = "macos")] + let runtime_paths = { + let home = codex_utils_home_dir::find_codex_home()?; + let config = codex_config::loader::load_config_layers_state( + &codex_exec_server::LocalFileSystem::unsandboxed(), + home.as_path(), + /*cwd*/ None, + &[], + codex_config::LoaderOverrides::default(), + &codex_config::NoopThreadConfigLoader, + ) + .await?; + runtime_paths.with_allowed_symlinked_codex_home( + codex_config::allowed_symlinked_codex_home(&config, &home), + ) + }; + codex_exec_server::run_main_with_telemetry( + &listen_url, + runtime_paths, + ExecServerTelemetry::default(), + http_client_factory, + request_dispatch_mode, + ) + .await + }) { Ok(()) => 0, Err(error) => { eprintln!("exec-server failed: {error}"); diff --git a/codex-rs/exec-server/tests/exec_process.rs b/codex-rs/exec-server/tests/exec_process.rs index 823f626c51..c7bb503849 100644 --- a/codex-rs/exec-server/tests/exec_process.rs +++ b/codex-rs/exec-server/tests/exec_process.rs @@ -102,6 +102,78 @@ async fn create_process_context(use_remote: bool) -> Result { } } +#[cfg(target_os = "macos")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn codex_home_symlink_opt_out_respects_host_config_and_scope() -> Result<()> { + use codex_exec_server::WriteFileOptions; + use common::exec_server::exec_server_with_env; + use std::os::unix::fs::symlink; + + let workspace = TempDir::new()?; + let home = TempDir::new()?; + let target = TempDir::new()?; + let alias = home.path().join("visualizations"); + let other_alias = workspace.path().join(".codex/visualizations"); + std::fs::create_dir(workspace.path().join(".codex"))?; + symlink(target.path(), &alias)?; + symlink(target.path(), &other_alias)?; + std::fs::write( + workspace.path().join(".codex/config.toml"), + "allow_symlinked_codex_home = true\n", + )?; + + for enabled in [None, Some(false), Some(true)] { + std::fs::write( + home.path().join("config.toml"), + enabled.map_or_else(String::new, |enabled| { + format!("allow_symlinked_codex_home = {enabled}\n") + }), + )?; + let mut server = exec_server_with_env([("CODEX_HOME", home.path())], &[]).await?; + let environment = Environment::create_for_tests(Some(server.websocket_url().to_string()))?; + for root in [alias.as_path(), other_alias.as_path(), workspace.path()] { + let mut policy = FileSystemSandboxPolicy::read_only(); + policy.entries.push(FileSystemSandboxEntry::new( + PathUri::from_host_native_path(root)?.into(), + FileSystemAccessMode::Write, + )); + let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + PermissionProfile::from_runtime_permissions( + &policy, + NetworkSandboxPolicy::Restricted, + ), + PathUri::from_host_native_path(workspace.path())?, + ); + let result = environment + .get_filesystem() + .write_file( + &PathUri::from_host_native_path(root.join("output"))?, + b"written".to_vec(), + WriteFileOptions::default(), + Some(&sandbox), + ) + .await; + assert_eq!( + result.is_ok(), + root == workspace.path() || (enabled == Some(true) && root == alias), + "root={root:?}, enabled={enabled:?}: {result:?}" + ); + if let Err(error) = result { + assert!( + error + .to_string() + .contains("symlinked writable roots are not supported"), + "{error}" + ); + } + } + server.shutdown().await?; + } + assert_eq!(std::fs::read(target.path().join("output"))?, b"written"); + assert_eq!(std::fs::read(workspace.path().join("output"))?, b"written"); + Ok(()) +} + #[cfg(unix)] #[test_case(false, false, false, false, "bash"; "local_pipe")] #[test_case(false, true, false, false, "bash"; "local_tty")] diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 50f3311431..d399ac3249 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -626,6 +626,10 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result arg0_paths.codex_self_exe.clone(), arg0_paths.codex_linux_sandbox_exe.clone(), )?; + #[cfg(target_os = "macos")] + let local_runtime_paths = local_runtime_paths.with_allowed_symlinked_codex_home( + codex_config::allowed_symlinked_codex_home(&config.config_layer_stack, &config.codex_home), + ); let state_db = codex_core::init_state_db(&config).await; let environment_manager = if run_loader_overrides.ignore_user_config { EnvironmentManager::from_env(Some(local_runtime_paths), config.http_client_factory()) diff --git a/codex-rs/exec/tests/suite/seatbelt.rs b/codex-rs/exec/tests/suite/seatbelt.rs index f8201ef6d3..26b287a625 100644 --- a/codex-rs/exec/tests/suite/seatbelt.rs +++ b/codex-rs/exec/tests/suite/seatbelt.rs @@ -15,6 +15,110 @@ use std::process::Output; use std::time::Duration; use tempfile::TempDir; +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn codex_home_symlink_opt_out_uses_loaded_user_config() -> anyhow::Result<()> { + use core_test_support::responses; + use core_test_support::test_codex_exec::test_codex_exec; + use serde_json::json; + + core_test_support::skip_if_sandbox!(Ok(())); + for (home_env, root_alias, enabled, ignore_user_config) in [ + ("user-home", Some("user-home"), true, false), + ("home-link/../.codex", Some(".codex"), false, false), + ("home-link/../.codex", None, true, false), + ("user-home", Some("user-home"), true, true), + ] { + let test = test_codex_exec(); + let home = test.home_path().join(".codex"); + let project_home = test.cwd_path().join(".codex"); + let child = test.home_path().join("child"); + for directory in [&home, &project_home, &child] { + fs::create_dir(directory)?; + } + symlink(&home, test.cwd_path().join("user-home"))?; + symlink(&child, test.cwd_path().join("home-link"))?; + fs::write( + home.join("config.toml"), + format!("allow_symlinked_codex_home = {enabled}\n"), + )?; + // The checkout must not enable the opt-out, even when `..` follows a symlink. + fs::write( + project_home.join("config.toml"), + "allow_symlinked_codex_home = true\n", + )?; + let target = TempDir::new()?; + for root in [&home, &project_home] { + symlink(target.path(), root.join("visualizations"))?; + } + let writable_home = + root_alias.map_or_else(|| home.clone(), |alias| test.cwd_path().join(alias)); + let visualizations = writable_home.join("visualizations"); + let shell_args = json!({ + "cmd": "printf shell > shell.txt", + "workdir": visualizations, + "shell": "/bin/sh", + "login": false, + }) + .to_string(); + let patch = format!( + "*** Begin Patch\n*** Add File: {}/patch.txt\n+patched\n*** End Patch", + visualizations.display() + ); + let server = responses::start_mock_server().await; + let should_run = enabled && !ignore_user_config; + let mock = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_function_call("shell", "exec_command", &shell_args), + responses::ev_apply_patch_custom_tool_call("patch", &patch), + responses::ev_completed("response-1"), + ]), + responses::sse(vec![responses::ev_completed("response-2")]), + ], + ) + .await; + let mut command = test.cmd_with_server(&server); + command + .env_remove("CODEX_API_KEY") + .env_remove("OPENAI_API_KEY") + .env_remove("CODEX_ACCESS_TOKEN") + .env("CODEX_HOME", home_env) + .args(["--skip-git-repo-check", "--sandbox", "workspace-write", "--add-dir"]) + .arg(&visualizations) + .args(["-c", "approvals_reviewer=\"user\"", "-c", "features.shell_snapshot_v2=false"]) + // Session overrides cannot authorize the host's exception either. + .args(["-c", "allow_symlinked_codex_home=true"]) + .args(["-c", "model_provider=\"test\"", "-c"]) + .arg(format!( + "model_providers.test={{name=\"test\",base_url={:?},wire_api=\"responses\",requires_openai_auth=false,supports_websockets=false}}", + format!("{}/v1", server.uri()) + )) + .arg("write the files"); + if ignore_user_config { + command.arg("--ignore-user-config"); + } + let output = command.output()?; + assert!(output.status.success(), "{output:?}"); + assert_eq!(mock.requests().len(), 2); + if should_run { + assert_eq!(fs::read(target.path().join("shell.txt"))?, b"shell"); + assert_eq!(fs::read(target.path().join("patch.txt"))?, b"patched\n"); + } else { + let shell_output = mock + .function_call_output_text("shell") + .expect("shell tool output"); + assert!( + shell_output.contains("symlinked writable roots are not supported"), + "{shell_output}" + ); + assert!(!target.path().join("shell.txt").exists()); + assert!(!target.path().join("patch.txt").exists()); + } + } + Ok(()) +} + fn workspace_fixture() -> (TempDir, AbsolutePathBuf) { let temp_dir = TempDir::new().expect("create temporary directory"); let workspace = temp_dir.path().join("workspace"); diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs index de124d54cb..530c2cb831 100644 --- a/codex-rs/sandboxing/src/manager.rs +++ b/codex-rs/sandboxing/src/manager.rs @@ -275,10 +275,12 @@ impl std::error::Error for SandboxTransformError { } } -#[derive(Default)] +#[derive(Clone, Default)] pub struct SandboxManager { #[cfg(target_os = "macos")] seatbelt_profile: MacosSeatbeltProfile, + #[cfg(target_os = "macos")] + allowed_symlinked_codex_home: Option, } impl SandboxManager { @@ -291,9 +293,22 @@ impl SandboxManager { Self { #[cfg(target_os = "macos")] seatbelt_profile: MacosSeatbeltProfile::FileSystemHelper, + #[cfg(target_os = "macos")] + allowed_symlinked_codex_home: None, } } + /// Allows otherwise-authorized writable roots beneath the opted-in user home + /// to follow symlinks, including targets outside that home. + #[cfg(target_os = "macos")] + pub fn with_allowed_symlinked_codex_home( + mut self, + allowed_symlinked_codex_home: Option, + ) -> Self { + self.allowed_symlinked_codex_home = allowed_symlinked_codex_home; + self + } + pub fn select_initial( &self, permission_profile: &PermissionProfile, @@ -395,6 +410,7 @@ impl SandboxManager { extra_allow_unix_sockets: &[], }, self.seatbelt_profile, + self.allowed_symlinked_codex_home.as_ref(), ) .map_err(|err| match err { SeatbeltPreparationError::FileSystem(message) => { diff --git a/codex-rs/sandboxing/src/seatbelt.rs b/codex-rs/sandboxing/src/seatbelt.rs index 3548264a97..4e5c9a7571 100644 --- a/codex-rs/sandboxing/src/seatbelt.rs +++ b/codex-rs/sandboxing/src/seatbelt.rs @@ -447,19 +447,34 @@ fn normalize_top_level_alias_for_sandbox( fn normalize_writable_root_for_sandbox( root: AbsolutePathBuf, + allowed_symlinked_codex_home: Option<&AbsolutePathBuf>, ) -> Result { - if let Some(symlink) = nested_symlink_component(root.as_path()) { + let allow_symlinks = allowed_symlinked_codex_home.is_some_and(|home| { + root.as_path().starts_with(home.as_path()) + || normalize_path_for_sandbox(home.as_path()) + .is_some_and(|target| root.as_path().starts_with(target.as_path())) + }); + if !allow_symlinks && let Some(symlink) = nested_symlink_component(root.as_path()) { return Err(SeatbeltPreparationError::FileSystem(format!( - "writable root {} contains symlink component {}; symlinked writable roots are not supported", + "writable root {} contains symlink component {}; symlinked writable roots are not supported.\n\ + If this writable root is at or beneath CODEX_HOME and you trust its symlink targets, \ + set `allow_symlinked_codex_home = true` at the top level of `$CODEX_HOME/config.toml` \ + (normally `~/.codex/config.toml`) on the execution host, then restart Codex or its executor. \ + This opt-out trusts targets outside CODEX_HOME and targets changed between commands. \ + It does not apply to other writable roots.", root.display(), symlink.display() ))); } - // Resolve only top-level system aliases such as `/tmp -> /private/tmp`. - // Deeper components can be mutated by an already-running sandboxed process, - // so following them here would turn a path check into a new authority grant. - let normalized = normalize_top_level_alias_for_sandbox(root)?; + let normalized = if allow_symlinks { + // This explicit opt-out trusts the current target, including targets + // outside CODEX_HOME and links changed by an earlier command. + normalize_path_for_sandbox(root.as_path()).unwrap_or(root) + } else { + // Otherwise preserve mutable components instead of granting their targets. + normalize_top_level_alias_for_sandbox(root)? + }; let metadata = match std::fs::symlink_metadata(normalized.as_path()) { Ok(metadata) => metadata, @@ -483,6 +498,7 @@ fn normalize_writable_root_for_sandbox( fn build_seatbelt_access_policy( access_kind: SeatbeltAccessKind, roots: Vec, + allowed_symlinked_codex_home: Option<&AbsolutePathBuf>, ) -> Result<(String, Vec<(String, PathBuf)>), SeatbeltPreparationError> { let mut policy_components = Vec::new(); let mut root_anchor_denies = Vec::new(); @@ -500,7 +516,10 @@ fn build_seatbelt_access_policy( (root, SeatbeltPathMatch::Subpath) } SeatbeltAccessKind::Write => { - match normalize_writable_root_for_sandbox(access_root.root)? { + match normalize_writable_root_for_sandbox( + access_root.root, + allowed_symlinked_codex_home, + )? { NormalizedWritableRoot::Subpath(root) => (root, SeatbeltPathMatch::Subpath), NormalizedWritableRoot::Literal(root) => (root, SeatbeltPathMatch::Literal), } @@ -844,13 +863,18 @@ pub struct CreateSeatbeltCommandArgsParams<'a> { pub fn create_seatbelt_command_args( args: CreateSeatbeltCommandArgsParams<'_>, ) -> Result, String> { - create_seatbelt_command_args_with_profile(args, MacosSeatbeltProfile::Process) - .map_err(|err| err.to_string()) + create_seatbelt_command_args_with_profile( + args, + MacosSeatbeltProfile::Process, + /*allowed_symlinked_codex_home*/ None, + ) + .map_err(|err| err.to_string()) } pub(crate) fn create_seatbelt_command_args_with_profile( args: CreateSeatbeltCommandArgsParams<'_>, profile: MacosSeatbeltProfile, + allowed_symlinked_codex_home: Option<&AbsolutePathBuf>, ) -> Result, SeatbeltPreparationError> { let CreateSeatbeltCommandArgsParams { command, @@ -868,6 +892,10 @@ pub(crate) fn create_seatbelt_command_args_with_profile( file_system_sandbox_policy.get_unreadable_roots_with_cwd(sandbox_policy_cwd); let writable_roots = file_system_sandbox_policy .get_writable_roots_with_cwd_preserving_mutable_paths(sandbox_policy_cwd); + let allowed_symlinked_codex_home = allowed_symlinked_codex_home + .cloned() + .map(normalize_top_level_alias_for_sandbox) + .transpose()?; // Protect ancestors of read-only paths so renaming a writable directory // cannot move its descendants outside their policy carveouts. let mut protected_ancestors = BTreeSet::new(); @@ -908,6 +936,7 @@ pub(crate) fn create_seatbelt_command_args_with_profile( excluded_subpaths: unreadable_roots.clone(), protected_metadata_names: Vec::new(), }], + /*allowed_symlinked_codex_home*/ None, )? } } else { @@ -925,6 +954,7 @@ pub(crate) fn create_seatbelt_command_args_with_profile( excluded_subpaths: root.read_only_subpaths, }) .collect(), + allowed_symlinked_codex_home.as_ref(), )? }; @@ -943,6 +973,7 @@ pub(crate) fn create_seatbelt_command_args_with_profile( excluded_subpaths: unreadable_roots, protected_metadata_names: Vec::new(), }], + /*allowed_symlinked_codex_home*/ None, )?; ( format!("; allow read-only file operations\n{policy}"), @@ -965,6 +996,7 @@ pub(crate) fn create_seatbelt_command_args_with_profile( root, }) .collect(), + /*allowed_symlinked_codex_home*/ None, )?; if policy.is_empty() { (String::new(), params) diff --git a/codex-rs/sandboxing/src/seatbelt_tests.rs b/codex-rs/sandboxing/src/seatbelt_tests.rs index de53e2d62f..4c11be4c35 100644 --- a/codex-rs/sandboxing/src/seatbelt_tests.rs +++ b/codex-rs/sandboxing/src/seatbelt_tests.rs @@ -218,6 +218,7 @@ fn filesystem_helper_platform_defaults_do_not_grant_applications_directory() { extra_allow_unix_sockets: &[], }, profile, + /*allowed_symlinked_codex_home*/ None, ) .expect("build restricted seatbelt command"); @@ -297,6 +298,7 @@ fn process_platform_defaults_allow_scratch_without_granting_it_to_filesystem_hel extra_allow_unix_sockets: &[], }, profile, + /*allowed_symlinked_codex_home*/ None, ) .expect("build restricted seatbelt command") }; @@ -1021,6 +1023,7 @@ fn preferences_access_requires_unrestricted_reads() { extra_allow_unix_sockets: &[], }, profile, + /*allowed_symlinked_codex_home*/ None, ) .expect("build seatbelt policy"); let policy = seatbelt_policy_arg(&args); diff --git a/codex-rs/tui/src/startup_orchestration.rs b/codex-rs/tui/src/startup_orchestration.rs index a7c37b45f3..253c699140 100644 --- a/codex-rs/tui/src/startup_orchestration.rs +++ b/codex-rs/tui/src/startup_orchestration.rs @@ -360,6 +360,10 @@ pub(super) async fn run_main_inner( )) .await?? }; + #[cfg(target_os = "macos")] + let local_runtime_paths = local_runtime_paths.with_allowed_symlinked_codex_home( + codex_config::allowed_symlinked_codex_home(&config.config_layer_stack, &config.codex_home), + ); let environment_manager = Arc::new( prepared_environment_manager .build(Some(local_runtime_paths), config.http_client_factory()) diff --git a/codex-rs/tui/tests/suite/focus_palette.rs b/codex-rs/tui/tests/suite/focus_palette.rs index 71a4cb57ba..b06f61fb88 100644 --- a/codex-rs/tui/tests/suite/focus_palette.rs +++ b/codex-rs/tui/tests/suite/focus_palette.rs @@ -14,6 +14,8 @@ use anyhow::Context; use anyhow::Result; use anyhow::bail; use anyhow::ensure; +#[cfg(target_os = "macos")] +use pretty_assertions::assert_eq; use tempfile::TempDir; // Full startup continues after the composer first appears and can be slower under Rosetta in CI. @@ -27,7 +29,7 @@ fn focus_gained_with_unanswered_palette_queries_preserves_immediate_input() -> R let codex_home = tempfile::tempdir()?; write_test_config(codex_home.path(), &repo_root)?; - let mut terminal = PtyCodex::start(&repo_root, codex_home)?; + let mut terminal = PtyCodex::start(&repo_root, codex_home, &[])?; terminal.wait_for_startup()?; let startup_output_len = terminal.output.len(); @@ -45,6 +47,101 @@ fn focus_gained_with_unanswered_palette_queries_preserves_immediate_input() -> R Ok(()) } +#[cfg(target_os = "macos")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn interactive_startup_honors_codex_home_symlink_opt_out() -> Result<()> { + use core_test_support::responses; + use wiremock::matchers::body_string_contains; + + core_test_support::skip_if_sandbox!(Ok(())); + let workspace = tempfile::tempdir()?; + let workspace_path = workspace.path().canonicalize()?; + let codex_home = tempfile::tempdir()?; + let target = tempfile::tempdir()?; + let visualizations = codex_home.path().join("visualizations"); + std::os::unix::fs::symlink(target.path(), &visualizations)?; + write_test_config(codex_home.path(), &workspace_path)?; + + let server = responses::start_mock_server().await; + // Automatic thread-title requests must not consume the tool responses. + let _title_mock = responses::mount_sse_once_match( + &server, + body_string_contains(r#"\"thread_source\":\"system\""#), + responses::sse(vec![ + responses::ev_assistant_message("title", r#"{"title":"Symlink probe"}"#), + responses::ev_completed("title"), + ]), + ) + .await; + let _write_mock = responses::mount_sse_once_match( + &server, + body_string_contains(r#"\"thread_source\":\"user\""#), + responses::sse(vec![ + responses::ev_exec_command_call_with_args( + "write", + &serde_json::json!({ + "cmd": "printf ready > ready.txt", + "workdir": visualizations, + "shell": "/bin/sh", + "login": false, + }), + ), + responses::ev_completed("startup"), + ]), + ) + .await; + let _completion_mock = responses::mount_sse_once_match( + &server, + body_string_contains(r#"\"thread_source\":\"user\""#), + responses::sse(vec![ + responses::ev_assistant_message("ready", "symlink-opt-out-ready"), + responses::ev_completed("complete"), + ]), + ) + .await; + let base_url = server.uri(); + let visualizations = toml::Value::String(visualizations.display().to_string()); + let config_path = codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + config_path, + format!( + "allow_symlinked_codex_home = true\n\ + sandbox_mode = \"workspace-write\"\n\ + approval_policy = \"never\"\n\ + {config}\n\ + [sandbox_workspace_write]\nwritable_roots = [{visualizations}]\n\ + [model_providers.test]\nname = \"Mock\"\n\ + base_url = \"{base_url}/v1\"\nwire_api = \"responses\"\n\ + requires_openai_auth = false\nsupports_websockets = false\n" + ), + )?; + + // A fresh home has no daemon. Writing through the link exercises the embedded server's + // local executor and its sandbox policy. + let mut terminal = PtyCodex::start( + &workspace_path, + codex_home, + &["-c", "model_provider=\"test\"", "Write ready.txt"], + )?; + terminal.wait_for_startup()?; + let deadline = Instant::now() + STARTUP_TIMEOUT; + while Instant::now() < deadline { + terminal.read_output(Duration::from_millis(/*millis*/ 50))?; + if terminal.screen_contains("symlink-opt-out-ready") { + assert_eq!(std::fs::read(target.path().join("ready.txt"))?, b"ready"); + return Ok(()); + } + if terminal.child.try_wait()?.is_some() { + break; + } + } + bail!( + "interactive startup did not honor the symlink opt-out; screen:\n{}", + terminal.screen_contents(), + ); +} + pub(super) struct PtyCodex { master: File, child: Child, @@ -57,7 +154,11 @@ pub(super) struct PtyCodex { } impl PtyCodex { - pub(super) fn start(repo_root: &Path, codex_home: TempDir) -> Result { + pub(super) fn start( + repo_root: &Path, + codex_home: TempDir, + extra_args: &[&str], + ) -> Result { let mut master_fd = -1; let mut slave_fd = -1; let mut window_size = libc::winsize { @@ -92,6 +193,7 @@ impl PtyCodex { let codex = codex_utils_cargo_bin::cargo_bin("codex-tui") .or_else(|_| codex_utils_cargo_bin::cargo_bin("codex"))?; let child = Command::new(codex) + .args(extra_args) .arg("--no-alt-screen") .arg("-C") .arg(repo_root) diff --git a/codex-rs/tui/tests/suite/reconnect.rs b/codex-rs/tui/tests/suite/reconnect.rs index 925eac79cd..fb630a83a4 100644 --- a/codex-rs/tui/tests/suite/reconnect.rs +++ b/codex-rs/tui/tests/suite/reconnect.rs @@ -130,7 +130,7 @@ async fn automatic_reconnect_restores_draft_and_routes_new_notifications() -> Re } Ok::<_, anyhow::Error>(methods) }); - let mut terminal = PtyCodex::start(&repo_root, codex_home)?; + let mut terminal = PtyCodex::start(&repo_root, codex_home, &[])?; terminal.wait_for_startup()?; let mut disconnect_tx = Some(disconnect_tx); let mut restore_tx = Some(restore_tx);