diff --git a/codex-rs/core/tests/suite/apply_patch_system_aliases.rs b/codex-rs/core/tests/suite/apply_patch_system_aliases.rs index f2144e3084..2ba778a4a8 100644 --- a/codex-rs/core/tests/suite/apply_patch_system_aliases.rs +++ b/codex-rs/core/tests/suite/apply_patch_system_aliases.rs @@ -17,11 +17,86 @@ use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::skip_if_remote; +use core_test_support::skip_if_sandbox; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; use test_case::test_case; +#[test_case(false; "freeform")] +#[test_case(true; "shell interception")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn case_variant_metadata_patch_cannot_write_without_approval(shell: bool) -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + skip_if_remote!(Ok(()), "exercises native macOS case aliases"); + let server = start_mock_server().await; + let test = test_codex() + .with_config(|config| { + config.workspace_roots = vec![config.cwd.clone()]; + config + .permissions + .set_workspace_roots(config.workspace_roots.clone()); + config.approvals_reviewer = ApprovalsReviewer::User; + config + .permissions + .approval_policy + .set(AskForApproval::Never) + .expect("disable approvals in the regression"); + config + .permissions + .set_permission_profile(PermissionProfile::workspace_write()) + .expect("set workspace permissions"); + }) + .build_with_auto_env(&server) + .await?; + std::fs::create_dir_all(test.cwd_path().join(".git"))?; + std::fs::write(test.cwd_path().join(".git/config"), "# original\n")?; + std::fs::write(test.cwd_path().join("notes.txt"), "# original\n")?; + if !test.cwd_path().join(".GIT/config").exists() { + // The reported alias requires a case-insensitive filesystem. + return Ok(()); + } + let mut events = Vec::new(); + for (call_id, patch) in [ + ( + "control", + "*** Begin Patch\n*** Update File: notes.txt\n@@\n-# original\n+# changed\n*** End Patch", + ), + ( + "protected", + "*** Begin Patch\n*** Update File: .GIT/config\n@@\n-# original\n+# changed\n*** End Patch", + ), + ] { + let call = if shell { + ev_exec_command_call(call_id, &format!("apply_patch <<'PATCH'\n{patch}\nPATCH")) + } else { + ev_apply_patch_custom_tool_call(call_id, patch) + }; + events.push(sse(vec![ + ev_response_created(call_id), + call, + ev_completed(call_id), + ])); + } + events.push(sse(vec![ + ev_assistant_message("done", "done"), + ev_completed("done"), + ])); + let responses = mount_sse_sequence(&server, events).await; + test.submit_text_turn("Apply the patches within workspace permissions.") + .await?; + assert_eq!( + ( + std::fs::read_to_string(test.cwd_path().join("notes.txt"))?, + std::fs::read_to_string(test.cwd_path().join(".git/config"))?, + ), + ("# changed\n".to_string(), "# original\n".to_string()), + ); + assert_eq!(responses.requests().len(), 3); + Ok(()) +} + #[test_case(false; "freeform")] #[test_case(true; "shell interception")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/codex-rs/sandboxing/src/seatbelt_tests.rs b/codex-rs/sandboxing/src/seatbelt_tests.rs index 9f80838771..b5034bc220 100644 --- a/codex-rs/sandboxing/src/seatbelt_tests.rs +++ b/codex-rs/sandboxing/src/seatbelt_tests.rs @@ -2628,6 +2628,91 @@ fn seatbelt_protects_aws_in_extra_writable_home() { } } +#[cfg(target_os = "macos")] +#[test] +fn seatbelt_protects_case_variants_of_metadata() { + let mut results = Vec::new(); + let mut expected = Vec::new(); + for name in PROTECTED_METADATA_PATH_NAMES { + let uppercase = name.to_ascii_uppercase(); + for existing_name in [None, Some(*name), Some(uppercase.as_str())] { + for root_name in ["workspace", "WORKSPACE"] { + let tmp = TempDir::new().expect("tempdir"); + let workspace = tmp.path().join("workspace"); + let variant = workspace.join(&uppercase); + let probe = workspace.join("probe"); + fs::create_dir(&workspace).expect("create workspace"); + if !tmp.path().join("WORKSPACE").is_dir() { + // The reported aliases require a case-insensitive filesystem. + return; + } + if let Some(existing_name) = existing_name { + let existing = workspace.join(existing_name); + fs::create_dir(&existing).expect("create metadata"); + fs::write(existing.join("config"), "original").expect("write config"); + } + let policy_root = tmp.path().join(root_name); + let policy = restricted_write_policy(&[policy_root.as_path()]); + let command = if existing_name.is_some() { + "printf ran > \"$2\" && printf changed > \"$1/config\"" + } else { + "printf ran > \"$2\" && mkdir -p \"$1\" && printf changed > \"$1/config\"" + }; + let args = create_seatbelt_command_args(CreateSeatbeltCommandArgsParams { + command: vec![ + "/bin/sh".to_string(), + "-c".to_string(), + command.to_string(), + "sh".to_string(), + variant.display().to_string(), + probe.display().to_string(), + ], + file_system_sandbox_policy: &policy, + network_sandbox_policy: NetworkSandboxPolicy::Restricted, + sandbox_policy_cwd: &workspace, + enforce_managed_network: false, + managed_network: None, + environment_id: None, + network: None, + extra_allow_unix_sockets: &[], + }) + .expect("build seatbelt command"); + let output = Command::new(MACOS_PATH_TO_SEATBELT_EXECUTABLE) + .args(args) + .current_dir(&workspace) + .output() + .expect("execute seatbelt command"); + assert_eq!( + fs::read_to_string(&probe).expect("read sibling probe"), + "ran" + ); + results.push(( + *name, + existing_name.map(str::to_owned), + root_name, + output.status.success(), + variant.exists(), + fs::read_to_string( + workspace + .join(existing_name.unwrap_or(&uppercase)) + .join("config"), + ) + .ok(), + )); + expected.push(( + *name, + existing_name.map(str::to_owned), + root_name, + false, + existing_name.is_some(), + existing_name.map(|_| "original".to_string()), + )); + } + } + } + assert_eq!(results, expected); +} + #[cfg(target_os = "macos")] #[test] fn seatbelt_protects_resolved_target_of_symlinked_metadata_directory() {