diff --git a/codex-rs/core/tests/suite/workspace_roots.rs b/codex-rs/core/tests/suite/workspace_roots.rs index e066897b4c..8170a8674c 100644 --- a/codex-rs/core/tests/suite/workspace_roots.rs +++ b/codex-rs/core/tests/suite/workspace_roots.rs @@ -1,5 +1,6 @@ use anyhow::Context; use anyhow::Result; +use codex_exec_server::CreateDirectoryOptions; use codex_exec_server::RemoveOptions; use codex_features::Feature; use codex_protocol::models::PermissionProfile; @@ -187,6 +188,96 @@ async fn workspace_roots_allow_file_and_command_writes() -> Result<()> { remove_files(&test, &[&patch_path, &command_path]).await } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn workspace_roots_allow_patches_but_protect_metadata_directories() -> Result<()> { + const PATCH_CONTENTS: &str = "workspace root patch access"; + const PROTECTED_METADATA_DIRECTORIES: [&str; 3] = [".git", ".agents", ".codex"]; + + skip_if_wine_exec!( + Ok(()), + "Wine does not emulate Windows restricted-token and ACL sandbox semantics" + ); + + let server = start_mock_server().await; + let test = workspace_roots_test(&server).await?; + let cwd = PathUri::from_abs_path(&test.config.cwd); + let allowed_path = cwd.join("workspace-root-allowed-patch.txt")?; + for directory in PROTECTED_METADATA_DIRECTORIES { + test.fs() + .create_directory( + &cwd.join(directory)?, + CreateDirectoryOptions { recursive: true }, + /*sandbox*/ None, + ) + .await?; + } + + let allowed_patch = format!( + "*** Begin Patch\n*** Add File: workspace-root-allowed-patch.txt\n+{PATCH_CONTENTS}\n*** End Patch\n" + ); + let mut patch_calls = vec![ + ev_response_created("resp-1"), + ev_apply_patch_custom_tool_call(PATCH_CALL_ID, &allowed_patch), + ]; + for directory in PROTECTED_METADATA_DIRECTORIES { + let call_id = format!("workspace-root-protected-{directory}"); + let patch = format!( + "*** Begin Patch\n*** Add File: {directory}/protected.txt\n+metadata write\n*** End Patch\n" + ); + patch_calls.push(ev_apply_patch_custom_tool_call(&call_id, &patch)); + } + patch_calls.push(ev_completed("resp-1")); + let response_mock = mount_sse_sequence( + &server, + vec![ + sse(patch_calls), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + submit_workspace_turn(&test, "patch the workspace and protected metadata").await?; + + let request = response_mock + .last_request() + .context("model should receive every workspace-root patch result")?; + let (_, patch_success) = request + .custom_tool_call_output_content_and_success(PATCH_CALL_ID) + .context("workspace patch result should be present")?; + assert_ne!(patch_success, Some(false)); + assert_eq!( + read_file(&test, &allowed_path).await?, + format!("{PATCH_CONTENTS}\n") + ); + + for directory in PROTECTED_METADATA_DIRECTORIES { + let call_id = format!("workspace-root-protected-{directory}"); + let (output, success) = request + .custom_tool_call_output_content_and_success(&call_id) + .with_context(|| format!("{directory} patch result should be present"))?; + assert_ne!(success, Some(true)); + assert!( + output + .as_deref() + .is_some_and(|output| output.contains("outside of the project")), + "{directory} patch should be denied, got {output:?}" + ); + let protected_path = cwd.join(directory)?.join("protected.txt")?; + let error = test + .fs() + .get_metadata(&protected_path, /*sandbox*/ None) + .await + .expect_err("protected metadata file should not be created"); + assert_eq!(error.kind(), std::io::ErrorKind::NotFound); + } + + remove_files(&test, &[&allowed_path]).await +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn workspace_roots_deny_file_and_command_writes_outside_roots() -> Result<()> { const PATCH_CONTENTS: &str = "outside workspace root patch";