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 authored and copyberry committed 2026-09-04 10:12:03 +00:00
1 parent 8e6a44b428
commit d13aeb77ea
24 files changed
+499 -36

No files matched your search

+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);