Validate linked worktrees before inheriting project trust (#39616)

## Why

A checkout could point its `.git` file at a trusted repository's worktree
directory without proving that the repository had registered that checkout.
This could cause project configuration from an unrelated checkout to be treated
as trusted.

## What changed

- Verify the linked worktree's `gitdir` backlink, `commondir`, registered
  checkout, and main checkout ownership before resolving the main repository's
  trust key.
- Reject missing, oversized, symlinked, mismatched, or swapped Git metadata.
- Preserve valid linked worktrees that use path aliases, separate Git
  directories, or non-UTF-8 POSIX paths.

## Testing

Add resolver and config-loading coverage for forged worktrees, metadata races,
case-sensitive paths, moved worktrees, and host MCP startup from project config.

GitOrigin-RevId: 6052a7d10ad2d613436f20175c356abdef8c758e
This commit is contained in:
Jeremy Rose authored and copyberry committed 2026-08-20 06:29:32 +00:00
1 parent 250b5ea2bf
commit bc3545b805
12 files changed
+859 -80

No files matched your search

@@ -103,7 +103,13 @@ async fn write_linked_worktree_pointer(
worktree_root.join(".git"),
format!("gitdir: {}\n", worktree_git_dir.display()),
)
.await
.await?;
tokio::fs::write(
worktree_git_dir.join("gitdir"),
format!("{}\n", worktree_root.join(".git").display()),
)
.await?;
tokio::fs::write(worktree_git_dir.join("commondir"), "../..\n").await
}
async fn write_project_hook_config(
@@ -2835,6 +2841,59 @@ async fn linked_worktree_project_layers_keep_worktree_config_but_use_root_repo_h
Ok(())
}
#[tokio::test]
async fn forged_linked_worktree_does_not_inherit_repo_trust() -> std::io::Result<()> {
let tmp = tempdir()?;
let trusted_root = tmp.path().join("trusted");
let attacker_root = tmp.path().join("attacker");
tokio::fs::create_dir_all(trusted_root.join(".git")).await?;
tokio::fs::create_dir_all(attacker_root.join(".codex")).await?;
tokio::fs::write(
attacker_root.join(".git"),
format!(
"gitdir: {}\n",
trusted_root.join(".git/worktrees/missing").display()
),
)
.await?;
tokio::fs::write(
attacker_root.join(".codex").join(CONFIG_TOML_FILE),
r#"foo = "attacker"
"#,
)
.await?;
let codex_home = tmp.path().join("home");
tokio::fs::create_dir_all(&codex_home).await?;
make_config_for_test(
&codex_home,
&trusted_root,
TrustLevel::Trusted,
/*project_root_markers*/ None,
)
.await?;
let layers = load_config_layers_state(
LOCAL_FS.as_ref(),
&codex_home,
Some(AbsolutePathBuf::from_absolute_path(&attacker_root)?),
&[] as &[(String, TomlValue)],
LoaderOverrides::default(),
&codex_config::NoopThreadConfigLoader,
)
.await?;
let project_layers = layers
.all_layers_high_to_low()
.filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. }))
.collect::<Vec<_>>();
assert_eq!(project_layers.len(), 1);
assert!(project_layers[0].disabled_reason.is_some());
assert_eq!(layers.effective_config().get("foo"), None);
Ok(())
}
#[tokio::test]
async fn malformed_untrusted_linked_worktree_does_not_read_root_hooks() -> std::io::Result<()> {
let tmp = tempdir()?;
+136 -20
View File
@@ -26,15 +26,18 @@ use std::fs;
use std::io;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::path::PathBuf;
use tempfile::TempDir;
use tokio::process::Command;
struct FailingMetadataFileSystem {
struct MetadataOverrideFileSystem {
path: PathUri,
replacement: Option<PathBuf>,
canonical_overrides: Vec<(PathUri, PathUri)>,
}
impl FailingMetadataFileSystem {
impl MetadataOverrideFileSystem {
fn unsupported<T>() -> FileSystemResult<T> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
@@ -43,21 +46,40 @@ impl FailingMetadataFileSystem {
}
}
impl ExecutorFileSystem for FailingMetadataFileSystem {
impl ExecutorFileSystem for MetadataOverrideFileSystem {
fn canonicalize<'a>(
&'a self,
_path: &'a PathUri,
_sandbox: Option<&'a FileSystemSandboxContext>,
path: &'a PathUri,
sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, PathUri> {
Box::pin(async { Self::unsupported() })
Box::pin(async move {
if let Some((_, canonical)) = self
.canonical_overrides
.iter()
.find(|(source, _)| source.to_url() == path.to_url())
{
return Ok(canonical.clone());
}
LOCAL_FS.canonicalize(path, sandbox).await
})
}
fn read_file<'a>(
&'a self,
_path: &'a PathUri,
_sandbox: Option<&'a FileSystemSandboxContext>,
path: &'a PathUri,
sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, Vec<u8>> {
Box::pin(async { Self::unsupported() })
Box::pin(async move {
#[cfg(unix)]
if path == &self.path
&& let Some(replacement) = &self.replacement
{
let local_path = path.to_abs_path()?;
fs::remove_file(local_path.as_path())?;
std::os::unix::fs::symlink(replacement, local_path.as_path())?;
}
LOCAL_FS.read_file(path, sandbox).await
})
}
fn read_file_stream<'a>(
@@ -92,7 +114,7 @@ impl ExecutorFileSystem for FailingMetadataFileSystem {
sandbox: Option<&'a FileSystemSandboxContext>,
) -> ExecutorFileSystemFuture<'a, FileMetadata> {
Box::pin(async move {
if path == &self.path {
if path == &self.path && self.replacement.is_none() {
Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"injected metadata failure",
@@ -638,8 +660,10 @@ async fn resolve_root_git_project_for_trust_ignores_metadata_errors() {
let nested = proj.join("nested");
std::fs::create_dir_all(proj.join(".git")).unwrap();
std::fs::create_dir_all(&nested).unwrap();
let fs = FailingMetadataFileSystem {
let fs = MetadataOverrideFileSystem {
path: PathUri::from_abs_path(&nested.join(".git").abs()),
replacement: None,
canonical_overrides: Vec::new(),
};
assert_eq!(
@@ -682,6 +706,27 @@ async fn resolve_root_git_project_for_trust_regular_repo_returns_repo_root() {
);
}
fn write_linked_worktree_metadata(repo_root: &Path, worktree_root: &Path) -> PathBuf {
let worktree_git_dir = repo_root.join(".git/worktrees/feature-x");
std::fs::create_dir_all(&worktree_git_dir).unwrap();
std::fs::create_dir_all(worktree_root).unwrap();
std::fs::write(
worktree_root.join(".git"),
format!("gitdir: {}\n", worktree_git_dir.display()),
)
.unwrap();
std::fs::write(
worktree_git_dir.join("gitdir"),
format!("{}\n", worktree_root.join(".git").display()),
)
.unwrap();
std::fs::write(worktree_git_dir.join("commondir"), "../..\n").unwrap();
worktree_git_dir
}
#[path = "worktree_trust_tests.rs"]
mod worktree_trust_tests;
#[tokio::test]
async fn resolve_root_git_project_for_trust_detects_worktree_and_returns_main_root() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
@@ -728,17 +773,9 @@ async fn resolve_root_git_project_for_trust_detects_worktree_and_returns_main_ro
async fn resolve_root_git_project_for_trust_detects_worktree_pointer_without_git_command() {
let tmp = TempDir::new().expect("tempdir");
let repo_root = tmp.path().join("repo");
let common_dir = repo_root.join(".git");
let worktree_git_dir = common_dir.join("worktrees").join("feature-x");
let worktree_root = tmp.path().join("wt");
std::fs::create_dir_all(&worktree_git_dir).unwrap();
std::fs::create_dir_all(&worktree_root).unwrap();
std::fs::create_dir_all(worktree_root.join("nested")).unwrap();
std::fs::write(
worktree_root.join(".git"),
format!("gitdir: {}\n", worktree_git_dir.display()),
)
.unwrap();
write_linked_worktree_metadata(&repo_root, &worktree_root);
let expected = repo_root.abs();
let worktree_root = worktree_root.abs();
@@ -753,6 +790,85 @@ async fn resolve_root_git_project_for_trust_detects_worktree_pointer_without_git
);
}
#[tokio::test]
async fn resolve_root_git_project_for_trust_rejects_missing_worktree_metadata() {
let tmp = TempDir::new().expect("tempdir");
let trusted_root = tmp.path().join("trusted");
let attacker_root = tmp.path().join("attacker");
std::fs::create_dir_all(trusted_root.join(".git")).unwrap();
std::fs::create_dir_all(&attacker_root).unwrap();
std::fs::write(
attacker_root.join(".git"),
format!(
"gitdir: {}\n",
trusted_root.join(".git/worktrees/missing").display()
),
)
.unwrap();
assert_eq!(
resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &attacker_root.abs()).await,
None
);
}
#[tokio::test]
async fn resolve_root_git_project_for_trust_rejects_mismatched_worktree_backlink() {
let tmp = TempDir::new().expect("tempdir");
let repo_root = tmp.path().join("repo");
let worktree_root = tmp.path().join("worktree");
let other_root = tmp.path().join("other");
let worktree_git_dir = write_linked_worktree_metadata(&repo_root, &worktree_root);
std::fs::create_dir_all(&other_root).unwrap();
std::fs::write(
worktree_git_dir.join("gitdir"),
format!("{}\n", other_root.join(".git").display()),
)
.unwrap();
assert_eq!(
resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &worktree_root.abs()).await,
None
);
}
#[tokio::test]
async fn resolve_root_git_project_for_trust_rejects_mismatched_common_dir() {
let tmp = TempDir::new().expect("tempdir");
let repo_root = tmp.path().join("repo");
let worktree_root = tmp.path().join("worktree");
let other_common_dir = tmp.path().join("other/.git");
let worktree_git_dir = write_linked_worktree_metadata(&repo_root, &worktree_root);
std::fs::create_dir_all(&other_common_dir).unwrap();
std::fs::write(
worktree_git_dir.join("commondir"),
format!("{}\n", other_common_dir.display()),
)
.unwrap();
assert_eq!(
resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &worktree_root.abs()).await,
None
);
}
#[cfg(unix)]
#[tokio::test]
async fn resolve_root_git_project_for_trust_rejects_symlinked_dot_git_file() {
let tmp = TempDir::new().expect("tempdir");
let repo_root = tmp.path().join("repo");
let worktree_root = tmp.path().join("worktree");
let attacker_root = tmp.path().join("attacker");
write_linked_worktree_metadata(&repo_root, &worktree_root);
std::fs::create_dir_all(&attacker_root).unwrap();
std::os::unix::fs::symlink(worktree_root.join(".git"), attacker_root.join(".git")).unwrap();
assert_eq!(
resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &attacker_root.abs()).await,
None
);
}
#[tokio::test]
async fn resolve_root_git_project_for_trust_non_worktrees_gitdir_returns_none() {
let tmp = TempDir::new().expect("tempdir");
+276
View File
@@ -0,0 +1,276 @@
use super::MetadataOverrideFileSystem;
use super::create_test_git_repo;
use super::write_linked_worktree_metadata;
use codex_exec_server::LOCAL_FS;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_utils_path::normalize_for_path_comparison;
use codex_utils_path_uri::PathUri;
use core_test_support::PathBufExt;
use pretty_assertions::assert_eq;
use std::fs;
use tempfile::TempDir;
use tokio::process::Command;
#[tokio::test]
async fn resolve_root_git_project_for_trust_validates_main_checkout_ownership() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
let checkout = tmp.path().join("checkout");
let admin = write_linked_worktree_metadata(&repo, &checkout);
fs::write(admin.join("gitdir"), "../../../../checkout/.git\n").unwrap();
assert_eq!(
resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &checkout.abs()).await,
Some(repo.abs())
);
let fake_common = repo.join("fake-common");
fs::rename(repo.join(".git"), &fake_common).unwrap();
fs::create_dir(repo.join(".git")).unwrap();
fs::write(
checkout.join(".git"),
format!(
"gitdir: {}\n",
fake_common.join("worktrees/feature-x").display()
),
)
.unwrap();
assert_eq!(
resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &checkout.abs()).await,
None
);
}
#[cfg(unix)]
#[tokio::test]
async fn resolve_root_git_project_for_trust_rejects_dot_git_swapped_after_stat() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
let checkout = tmp.path().join("checkout");
let attacker = tmp.path().join("attacker");
write_linked_worktree_metadata(&repo, &checkout);
fs::create_dir_all(&attacker).unwrap();
fs::copy(checkout.join(".git"), attacker.join(".git")).unwrap();
let fs = MetadataOverrideFileSystem {
path: PathUri::from_abs_path(&attacker.join(".git").abs()),
replacement: Some(checkout.join(".git")),
canonical_overrides: Vec::new(),
};
assert_eq!(
resolve_root_git_project_for_trust(&fs, &attacker.abs()).await,
None
);
assert!(
std::fs::symlink_metadata(attacker.join(".git"))
.unwrap()
.is_symlink()
);
}
#[tokio::test]
async fn resolve_root_git_project_for_trust_preserves_canonical_checkout_case() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
let checkout = tmp.path().join("real");
let attacker = tmp.path().join("forged");
write_linked_worktree_metadata(&repo, &checkout);
fs::create_dir(&attacker).unwrap();
fs::copy(checkout.join(".git"), attacker.join(".git")).unwrap();
let registered_uri = PathUri::from_abs_path(&checkout.abs());
let attacker_uri = PathUri::from_abs_path(&attacker.abs());
// Simulate a case-sensitive Windows executor on every test host. General
// PathUri equality treats these distinct filesystem identities as equal.
for (canonical_attacker, expected) in [
("file:///C:/real", Some(repo.abs())),
("file:///C:/REAL", None),
] {
let fs = MetadataOverrideFileSystem {
path: PathUri::from_abs_path(&tmp.path().join("unused").abs()),
replacement: None,
canonical_overrides: vec![
(
registered_uri.clone(),
PathUri::parse("file:///C:/real").unwrap(),
),
(
attacker_uri.clone(),
PathUri::parse(canonical_attacker).unwrap(),
),
],
};
assert_eq!(
resolve_root_git_project_for_trust(&fs, &attacker.abs()).await,
expected,
"{canonical_attacker}"
);
}
}
// APFS rejects invalid UTF-8 filenames; exercise actual Git metadata on Linux.
#[cfg(target_os = "linux")]
#[tokio::test]
async fn resolve_root_git_project_for_trust_preserves_non_utf8_worktree_paths() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
let tmp = TempDir::new().unwrap();
let original_repo = create_test_git_repo(&tmp).await;
let repo = tmp.path().join(OsStr::from_bytes(b"repo-\xfe"));
fs::rename(original_repo, &repo).unwrap();
let common = repo.join(OsStr::from_bytes(b"git-data-\xfd"));
let output = Command::new("git")
.arg("init")
.arg("--separate-git-dir")
.arg(&common)
.current_dir(&repo)
.output()
.await
.unwrap();
assert!(output.status.success(), "{output:?}");
let checkout = tmp.path().join("checkout");
let moved = tmp.path().join(OsStr::from_bytes(b"checkout-\xff"));
let output = Command::new("git")
.args(["worktree", "add", "--detach"])
.arg(&checkout)
.current_dir(&repo)
.output()
.await
.unwrap();
assert!(output.status.success(), "{output:?}");
let output = Command::new("git")
.args(["worktree", "move"])
.arg(&checkout)
.arg(&moved)
.current_dir(&repo)
.output()
.await
.unwrap();
assert!(output.status.success(), "{output:?}");
assert_eq!(
resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &moved.abs())
.await
.map(|path| normalize_for_path_comparison(path.as_path()).unwrap()),
Some(normalize_for_path_comparison(&repo).unwrap())
);
}
#[tokio::test]
async fn resolve_root_git_project_for_trust_rejects_incomplete_metadata() {
for filename in ["gitdir", "commondir"] {
for contents in [None, Some(String::new()), Some("x".repeat(65_537))] {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
let checkout = tmp.path().join("checkout");
let admin = write_linked_worktree_metadata(&repo, &checkout);
let path = admin.join(filename);
match contents {
Some(contents) => fs::write(path, contents).unwrap(),
None => fs::remove_file(path).unwrap(),
}
assert_eq!(
resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &checkout.abs()).await,
None,
"{filename}"
);
}
}
}
#[tokio::test]
async fn resolve_root_git_project_for_trust_preserves_separate_git_directory() {
let tmp = TempDir::new().unwrap();
let repo = create_test_git_repo(&tmp).await;
let common = repo.join(".git-data");
let output = Command::new("git")
.arg("init")
.arg("--separate-git-dir")
.arg(&common)
.current_dir(&repo)
.output()
.await
.unwrap();
assert!(output.status.success(), "{output:?}");
let checkout = tmp.path().join("checkout");
let output = Command::new("git")
.args(["worktree", "add", "--detach"])
.arg(&checkout)
.current_dir(&repo)
.output()
.await
.unwrap();
assert!(output.status.success(), "{output:?}");
assert_eq!(
resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &checkout.abs())
.await
.map(|path| normalize_for_path_comparison(path.as_path()).unwrap()),
Some(normalize_for_path_comparison(&repo).unwrap())
);
}
#[tokio::test]
async fn resolve_root_git_project_for_trust_requires_repair_after_move() {
let tmp = TempDir::new().unwrap();
let repo = create_test_git_repo(&tmp).await;
let checkout = tmp.path().join("checkout");
let moved = tmp.path().join("moved");
let output = Command::new("git")
.args(["worktree", "add", "--detach"])
.arg(&checkout)
.current_dir(&repo)
.output()
.await
.unwrap();
assert!(output.status.success(), "{output:?}");
fs::rename(&checkout, &moved).unwrap();
assert_eq!(
resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &moved.abs()).await,
None
);
let output = Command::new("git")
.args(["worktree", "repair"])
.arg(&moved)
.current_dir(&repo)
.output()
.await
.unwrap();
assert!(output.status.success(), "{output:?}");
assert_eq!(
resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &moved.abs())
.await
.map(|path| normalize_for_path_comparison(path.as_path()).unwrap()),
Some(normalize_for_path_comparison(&repo).unwrap())
);
}
#[cfg(unix)]
#[tokio::test]
async fn resolve_root_git_project_for_trust_preserves_checkout_directory_alias() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
let checkout = tmp.path().join("checkout");
let alias = tmp.path().join("alias");
write_linked_worktree_metadata(&repo, &checkout);
std::os::unix::fs::symlink(&checkout, &alias).unwrap();
assert_eq!(
resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &alias.abs()).await,
Some(repo.abs())
);
}
#[cfg(unix)]
#[tokio::test]
async fn resolve_root_git_project_for_trust_rejects_symlinked_metadata() {
for filename in ["gitdir", "commondir"] {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
let checkout = tmp.path().join("checkout");
let admin = write_linked_worktree_metadata(&repo, &checkout);
let path = admin.join(filename);
let replacement = tmp.path().join("replacement");
fs::rename(&path, &replacement).unwrap();
std::os::unix::fs::symlink(replacement, path).unwrap();
assert_eq!(
resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &checkout.abs()).await,
None
);
}
}