Allow trusted symlinks beneath CODEX_HOME on macOS (#42716)

## What changed

- Add the macOS-only `allow_symlinked_codex_home` user setting. When enabled in the execution host's `$CODEX_HOME/config.toml`, writable roots at or beneath that home may traverse symlinks.
- Keep the exception disabled by default and prevent project config, command-line overrides, and ignored user config from enabling it. Other writable roots remain subject to symlink checks.
- Propagate the resolved setting through local, interactive, and exec-server sandbox paths, including `CODEX_HOME` aliases.
- Explain the opt-out in symlink rejection errors, including that it trusts targets outside `CODEX_HOME` and targets that change between commands.

## Testing

- Add macOS coverage for shell commands, patches, filesystem helpers, process execution, and interactive startup with enabled, disabled, aliased, and out-of-scope homes.

GitOrigin-RevId: 99fcdf611200c9e1b7713cf06f7fdea5bfa7f089
This commit is contained in:
jif
2026-09-04 10:12:03 +00:00
committed by copyberry
parent 8e6a44b428
commit d13aeb77ea
24 changed files with 499 additions and 36 deletions
+4
View File
@@ -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<Arc<dyn CodeModeSessionProvider>> =
match &runtime_options.code_mode_host_transport {
CodeModeHostTransport::Local => None,
+15
View File
@@ -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)
+28
View File
@@ -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<AbsolutePathBuf> {
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()))
}
+7
View File
@@ -204,6 +204,13 @@ pub struct ConfigToml {
/// Sandbox mode to use.
pub sandbox_mode: Option<SandboxMode>,
/// 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<bool>,
/// Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`.
pub sandbox_workspace_write: Option<SandboxWorkspaceWrite>,
+4
View File
@@ -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;
+4
View File
@@ -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": [
{
+16 -12
View File
@@ -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<Out> {
pub output: Out,
@@ -48,9 +46,7 @@ pub(crate) struct OrchestratorRunResult<Out> {
impl ToolOrchestrator {
pub fn new() -> Self {
Self {
sandbox: SandboxManager::new(),
}
Self
}
async fn run_attempt<Rq, Out, T>(
@@ -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,
@@ -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<String>,
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,
@@ -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(),
+10
View File
@@ -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(())
}
+4
View File
@@ -129,6 +129,10 @@ impl FileSystemSandboxRunner {
) -> Result<SandboxExecRequest, JSONRPCErrorError> {
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,
@@ -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,
+16 -1
View File
@@ -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<AbsolutePathBuf>,
/// User-config opt-out of writable-root symlink checks beneath this host's home.
#[cfg(target_os = "macos")]
pub allowed_symlinked_codex_home: Option<AbsolutePathBuf>,
}
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<AbsolutePathBuf>,
) -> Self {
self.allowed_symlinked_codex_home = allowed_symlinked_codex_home;
self
}
}
fn absolute_path(path: PathBuf) -> std::io::Result<AbsolutePathBuf> {
@@ -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?;
+26 -7
View File
@@ -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}");
@@ -102,6 +102,78 @@ async fn create_process_context(use_remote: bool) -> Result<ProcessContext> {
}
}
#[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")]
+4
View File
@@ -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())
+104
View File
@@ -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");
+17 -1
View File
@@ -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<AbsolutePathBuf>,
}
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<AbsolutePathBuf>,
) -> 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) => {
+41 -9
View File
@@ -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<NormalizedWritableRoot, SeatbeltPreparationError> {
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<SeatbeltAccessRoot>,
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<Vec<String>, 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<Vec<String>, 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)
@@ -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);
@@ -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())
+104 -2
View File
@@ -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<Self> {
pub(super) fn start(
repo_root: &Path,
codex_home: TempDir,
extra_args: &[&str],
) -> Result<Self> {
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)
+1 -1
View File
@@ -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);