From c893af3edf77091ec5a6ec010ce7dd48c9c62697 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sun, 26 Jul 2026 12:10:58 -0500 Subject: [PATCH 1/6] fix(tools): contain hosted repair file paths --- src-rust/crates/tools/src/apply_patch.rs | 4 + src-rust/crates/tools/src/batch_edit.rs | 5 + src-rust/crates/tools/src/file_edit.rs | 4 + src-rust/crates/tools/src/file_read.rs | 4 + src-rust/crates/tools/src/file_write.rs | 4 + src-rust/crates/tools/src/glob_tool.rs | 7 ++ src-rust/crates/tools/src/grep_tool.rs | 8 ++ src-rust/crates/tools/src/lib.rs | 137 ++++++++++++++++++++- src-rust/crates/tools/src/notebook_edit.rs | 4 + 9 files changed, 176 insertions(+), 1 deletion(-) diff --git a/src-rust/crates/tools/src/apply_patch.rs b/src-rust/crates/tools/src/apply_patch.rs index 0e3f1f5c..abcf847e 100644 --- a/src-rust/crates/tools/src/apply_patch.rs +++ b/src-rust/crates/tools/src/apply_patch.rs @@ -318,6 +318,10 @@ impl Tool for ApplyPatchTool { let path = ctx.resolve_path(&fp.path); debug!(path = %path.display(), "ApplyPatch processing file"); + if let Err(e) = ctx.check_hosted_repair_path(self.name(), &path) { + return ToolResult::error(e.to_string()); + } + // Read current content (or empty string for new files). let original_content = if path.exists() { match tokio::fs::read_to_string(&path).await { diff --git a/src-rust/crates/tools/src/batch_edit.rs b/src-rust/crates/tools/src/batch_edit.rs index 005c96d8..02141716 100644 --- a/src-rust/crates/tools/src/batch_edit.rs +++ b/src-rust/crates/tools/src/batch_edit.rs @@ -109,6 +109,11 @@ impl Tool for BatchEditTool { let path = ctx.resolve_path(&edit.file_path); debug!(path = %path.display(), index = i, "BatchEdit pre-check"); + if let Err(e) = ctx.check_hosted_repair_path(self.name(), &path) { + pre_check_errors.push(format!("Edit {}: {}", i, e)); + continue; + } + let content = match tokio::fs::read_to_string(&path).await { Ok(c) => c, Err(e) => { diff --git a/src-rust/crates/tools/src/file_edit.rs b/src-rust/crates/tools/src/file_edit.rs index 6f04bc27..b171f3e3 100644 --- a/src-rust/crates/tools/src/file_edit.rs +++ b/src-rust/crates/tools/src/file_edit.rs @@ -74,6 +74,10 @@ impl Tool for FileEditTool { let path = ctx.resolve_path(¶ms.file_path); debug!(path = %path.display(), "Editing file"); + if let Err(e) = ctx.check_hosted_repair_path(self.name(), &path) { + return ToolResult::error(e.to_string()); + } + // Permission check if let Err(e) = ctx.check_permission(self.name(), &format!("Edit {}", path.display()), false) diff --git a/src-rust/crates/tools/src/file_read.rs b/src-rust/crates/tools/src/file_read.rs index 895ebdca..45c46b36 100644 --- a/src-rust/crates/tools/src/file_read.rs +++ b/src-rust/crates/tools/src/file_read.rs @@ -64,6 +64,10 @@ impl Tool for FileReadTool { let path = ctx.resolve_path(¶ms.file_path); debug!(path = %path.display(), "Reading file"); + if let Err(e) = ctx.check_hosted_repair_path(self.name(), &path) { + return ToolResult::error(e.to_string()); + } + // Permission check if let Err(e) = ctx.check_permission_for_path( self.name(), diff --git a/src-rust/crates/tools/src/file_write.rs b/src-rust/crates/tools/src/file_write.rs index 460c7422..f9ac86f2 100644 --- a/src-rust/crates/tools/src/file_write.rs +++ b/src-rust/crates/tools/src/file_write.rs @@ -56,6 +56,10 @@ impl Tool for FileWriteTool { let path = ctx.resolve_path(¶ms.file_path); debug!(path = %path.display(), "Writing file"); + if let Err(e) = ctx.check_hosted_repair_path(self.name(), &path) { + return ToolResult::error(e.to_string()); + } + // Permission check if let Err(e) = ctx.check_permission(self.name(), &format!("Write {}", path.display()), false) diff --git a/src-rust/crates/tools/src/glob_tool.rs b/src-rust/crates/tools/src/glob_tool.rs index 12989cbb..aa0e66e8 100644 --- a/src-rust/crates/tools/src/glob_tool.rs +++ b/src-rust/crates/tools/src/glob_tool.rs @@ -62,6 +62,10 @@ impl Tool for GlobTool { .map(|p| ctx.resolve_path(p)) .unwrap_or_else(|| ctx.working_dir.clone()); + if let Err(e) = ctx.check_hosted_repair_path(self.name(), &base_dir) { + return ToolResult::error(e.to_string()); + } + if let Err(e) = ctx.check_permission_for_path( self.name(), &format!("Glob {} in {}", params.pattern, base_dir.display()), @@ -88,6 +92,9 @@ impl Tool for GlobTool { Ok(paths) => { let mut out = Vec::new(); for path in paths.filter_map(|p| p.ok()) { + if let Err(e) = ctx.check_hosted_repair_path(self.name(), &path) { + return ToolResult::error(e.to_string()); + } if !ctx.path_is_within_workspace(&path) { if let Err(e) = ctx.check_permission_for_path( self.name(), diff --git a/src-rust/crates/tools/src/grep_tool.rs b/src-rust/crates/tools/src/grep_tool.rs index 06738056..b5a33057 100644 --- a/src-rust/crates/tools/src/grep_tool.rs +++ b/src-rust/crates/tools/src/grep_tool.rs @@ -144,6 +144,10 @@ impl Tool for GrepTool { .map(|p| ctx.resolve_path(p)) .unwrap_or_else(|| ctx.working_dir.clone()); + if let Err(e) = ctx.check_hosted_repair_path(self.name(), &search_path) { + return ToolResult::error(e.to_string()); + } + if let Err(e) = ctx.check_permission_for_path( self.name(), &format!("Grep {} in {}", params.pattern, search_path.display()), @@ -219,6 +223,10 @@ impl Tool for GrepTool { let path = entry.path(); + if let Err(e) = ctx.check_hosted_repair_path(self.name(), path) { + return ToolResult::error(e.to_string()); + } + if !ctx.path_is_within_workspace(path) { if let Err(e) = ctx.check_permission_for_path( self.name(), diff --git a/src-rust/crates/tools/src/lib.rs b/src-rust/crates/tools/src/lib.rs index 3edbb631..2a742b5f 100644 --- a/src-rust/crates/tools/src/lib.rs +++ b/src-rust/crates/tools/src/lib.rs @@ -10,7 +10,7 @@ use claurst_core::permissions::{PermissionDecision, PermissionHandler, Permissio use claurst_core::types::ToolDefinition; use serde_json::Value; use std::collections::{HashMap, VecDeque}; -use std::path::PathBuf; +use std::path::{Component, Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; @@ -413,6 +413,28 @@ impl ToolContext { self.request_permission_inner(request) } + /// Hosted repair runs GitHub-supplied prompts with permission bypass, so + /// file tools must enforce the repository boundary themselves. + pub fn check_hosted_repair_path( + &self, + tool_name: &str, + path: &Path, + ) -> Result<(), claurst_core::error::ClaudeError> { + if !self.config.hosted_review.allow_file_write_tools { + return Ok(()); + } + + if self.path_is_within_working_dir(path) { + return Ok(()); + } + + Err(claurst_core::error::ClaudeError::PermissionDenied(format!( + "Permission denied for tool '{}': hosted repair file tools are limited to {}", + tool_name, + self.working_dir.display() + ))) + } + /// Like `check_permission` but also passes structured `details` text /// (e.g. a risk explanation) that the TUI permission dialog can display. pub fn check_permission_with_details( @@ -450,6 +472,13 @@ impl ToolContext { roots.iter().any(|root| resolved.starts_with(root)) } + fn path_is_within_working_dir(&self, path: &Path) -> bool { + let root = std::fs::canonicalize(&self.working_dir) + .unwrap_or_else(|_| normalize_path(&self.working_dir)); + let resolved = canonicalize_existing_prefix(path); + resolved.starts_with(root) + } + pub fn check_permission_with_details_and_path( &self, tool_name: &str, @@ -494,6 +523,45 @@ impl ToolContext { } } +fn normalize_path(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + other => normalized.push(other.as_os_str()), + } + } + normalized +} + +fn canonicalize_existing_prefix(path: &Path) -> PathBuf { + let normalized = normalize_path(path); + let mut ancestor = normalized.as_path(); + let mut suffix = Vec::new(); + + loop { + if ancestor.exists() { + let mut resolved = + std::fs::canonicalize(ancestor).unwrap_or_else(|_| ancestor.to_path_buf()); + for component in suffix.iter().rev() { + resolved.push(component); + } + return normalize_path(&resolved); + } + + match (ancestor.parent(), ancestor.file_name()) { + (Some(parent), Some(file_name)) => { + suffix.push(file_name.to_os_string()); + ancestor = parent; + } + _ => return normalized, + } + } +} + /// The trait every tool must implement. #[async_trait] pub trait Tool: Send + Sync { @@ -936,6 +1004,73 @@ mod tests { assert!(error.contains("generic reason")); } + #[test] + fn hosted_repair_path_check_allows_working_dir_files() { + use claurst_core::permissions::AutoPermissionHandler; + + let temp = tempfile::tempdir().expect("tempdir"); + let mut ctx = test_tool_context(Arc::new(AutoPermissionHandler { + mode: claurst_core::config::PermissionMode::BypassPermissions, + })); + ctx.working_dir = temp.path().to_path_buf(); + ctx.config.hosted_review.enabled = true; + ctx.config.hosted_review.allow_file_write_tools = true; + + let path = ctx.resolve_path("src/new.rs"); + assert!(ctx.check_hosted_repair_path("Write", &path).is_ok()); + } + + #[test] + fn hosted_repair_path_check_rejects_parent_escape() { + use claurst_core::permissions::AutoPermissionHandler; + + let temp = tempfile::tempdir().expect("tempdir"); + let workspace = temp.path().join("workspace"); + std::fs::create_dir(&workspace).expect("workspace dir"); + let mut ctx = test_tool_context(Arc::new(AutoPermissionHandler { + mode: claurst_core::config::PermissionMode::BypassPermissions, + })); + ctx.working_dir = workspace; + ctx.config.hosted_review.enabled = true; + ctx.config.hosted_review.allow_file_write_tools = true; + + let path = ctx.resolve_path("../outside.txt"); + let error = ctx + .check_hosted_repair_path("Read", &path) + .expect_err("parent escape must be rejected") + .to_string(); + + assert!(error.contains("hosted repair file tools are limited")); + } + + #[cfg(unix)] + #[test] + fn hosted_repair_path_check_rejects_symlink_escape() { + use claurst_core::permissions::AutoPermissionHandler; + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("tempdir"); + let workspace = temp.path().join("workspace"); + let outside = temp.path().join("outside"); + std::fs::create_dir(&workspace).expect("workspace dir"); + std::fs::create_dir(&outside).expect("outside dir"); + symlink(&outside, workspace.join("link")).expect("symlink"); + let mut ctx = test_tool_context(Arc::new(AutoPermissionHandler { + mode: claurst_core::config::PermissionMode::BypassPermissions, + })); + ctx.working_dir = workspace; + ctx.config.hosted_review.enabled = true; + ctx.config.hosted_review.allow_file_write_tools = true; + + let path = ctx.resolve_path("link/new.txt"); + let error = ctx + .check_hosted_repair_path("Write", &path) + .expect_err("symlink escape must be rejected") + .to_string(); + + assert!(error.contains("hosted repair file tools are limited")); + } + // ---- PermissionLevel tests --------------------------------------------- #[test] diff --git a/src-rust/crates/tools/src/notebook_edit.rs b/src-rust/crates/tools/src/notebook_edit.rs index 07e95ede..d5a16b21 100644 --- a/src-rust/crates/tools/src/notebook_edit.rs +++ b/src-rust/crates/tools/src/notebook_edit.rs @@ -98,6 +98,10 @@ impl Tool for NotebookEditTool { return ToolResult::error("File must have .ipynb extension".to_string()); } + if let Err(e) = ctx.check_hosted_repair_path(self.name(), &path) { + return ToolResult::error(e.to_string()); + } + // Permission check if let Err(e) = ctx.check_permission( self.name(), From 8a183e6aaaa02c14ad8b214db722535db75855fb Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Mon, 27 Jul 2026 07:48:50 -0500 Subject: [PATCH 2/6] fix(tools): resolve hosted repair paths safely Signed-off-by: Val Alexander <68980965+BunsDev@users.noreply.github.com> --- src-rust/crates/tools/src/lib.rs | 174 +++++++++++++++++++++++++------ 1 file changed, 142 insertions(+), 32 deletions(-) diff --git a/src-rust/crates/tools/src/lib.rs b/src-rust/crates/tools/src/lib.rs index 2a742b5f..21c7b0d1 100644 --- a/src-rust/crates/tools/src/lib.rs +++ b/src-rust/crates/tools/src/lib.rs @@ -473,9 +473,12 @@ impl ToolContext { } fn path_is_within_working_dir(&self, path: &Path) -> bool { - let root = std::fs::canonicalize(&self.working_dir) - .unwrap_or_else(|_| normalize_path(&self.working_dir)); - let resolved = canonicalize_existing_prefix(path); + let Ok(root) = std::fs::canonicalize(&self.working_dir) else { + return false; + }; + let Ok(resolved) = canonicalize_path_allow_missing(path) else { + return false; + }; resolved.starts_with(root) } @@ -523,43 +526,41 @@ impl ToolContext { } } -fn normalize_path(path: &Path) -> PathBuf { - let mut normalized = PathBuf::new(); +/// Resolve every existing component before applying later `..` components, +/// while retaining nonexistent suffixes for file-creation tools. +fn canonicalize_path_allow_missing(path: &Path) -> std::io::Result { + if !path.is_absolute() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "hosted repair paths must be absolute", + )); + } + + let mut resolved = PathBuf::new(); for component in path.components() { match component { + Component::Prefix(prefix) => resolved.push(prefix.as_os_str()), + root @ Component::RootDir => { + resolved.push(root.as_os_str()); + resolved = std::fs::canonicalize(&resolved)?; + } Component::CurDir => {} Component::ParentDir => { - normalized.pop(); - } - other => normalized.push(other.as_os_str()), - } - } - normalized -} - -fn canonicalize_existing_prefix(path: &Path) -> PathBuf { - let normalized = normalize_path(path); - let mut ancestor = normalized.as_path(); - let mut suffix = Vec::new(); - - loop { - if ancestor.exists() { - let mut resolved = - std::fs::canonicalize(ancestor).unwrap_or_else(|_| ancestor.to_path_buf()); - for component in suffix.iter().rev() { - resolved.push(component); + resolved.pop(); } - return normalize_path(&resolved); - } - - match (ancestor.parent(), ancestor.file_name()) { - (Some(parent), Some(file_name)) => { - suffix.push(file_name.to_os_string()); - ancestor = parent; + Component::Normal(name) => { + let candidate = resolved.join(name); + match std::fs::symlink_metadata(&candidate) { + Ok(_) => resolved = std::fs::canonicalize(candidate)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + resolved.push(name); + } + Err(error) => return Err(error), + } } - _ => return normalized, } } + Ok(resolved) } /// The trait every tool must implement. @@ -1043,6 +1044,56 @@ mod tests { assert!(error.contains("hosted repair file tools are limited")); } + #[test] + fn hosted_repair_path_check_rejects_uncanonicalizable_working_dir() { + use claurst_core::permissions::AutoPermissionHandler; + + let temp = tempfile::tempdir().expect("tempdir"); + let workspace = std::fs::canonicalize(temp.path()) + .expect("canonical tempdir") + .join("missing-workspace"); + let mut ctx = test_tool_context(Arc::new(AutoPermissionHandler { + mode: claurst_core::config::PermissionMode::BypassPermissions, + })); + ctx.working_dir = workspace.clone(); + ctx.config.hosted_review.enabled = true; + ctx.config.hosted_review.allow_file_write_tools = true; + + let path = workspace.join("new.rs"); + let error = ctx + .check_hosted_repair_path("Write", &path) + .expect_err("uncanonicalizable working dir must fail closed") + .to_string(); + + assert!(error.contains("hosted repair file tools are limited")); + } + + #[test] + fn hosted_repair_path_resolution_does_not_pop_past_filesystem_root() { + let temp = tempfile::tempdir().expect("tempdir"); + let canonical_temp = std::fs::canonicalize(temp.path()).expect("canonical tempdir"); + let filesystem_root = canonical_temp + .ancestors() + .find(|ancestor| ancestor.parent().is_none()) + .expect("filesystem root") + .to_path_buf(); + let mut path = canonical_temp.clone(); + for _ in 0..canonical_temp.components().count() + 2 { + path.push(".."); + } + path.push("new.txt"); + + let resolved = canonicalize_path_allow_missing(&path).expect("absolute path"); + + assert_eq!( + resolved, + std::fs::canonicalize(filesystem_root) + .expect("canonical filesystem root") + .join("new.txt") + ); + assert!(resolved.is_absolute()); + } + #[cfg(unix)] #[test] fn hosted_repair_path_check_rejects_symlink_escape() { @@ -1071,6 +1122,65 @@ mod tests { assert!(error.contains("hosted repair file tools are limited")); } + #[cfg(unix)] + #[test] + fn hosted_repair_path_check_resolves_symlink_before_parent_component() { + use claurst_core::permissions::AutoPermissionHandler; + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("tempdir"); + let workspace = temp.path().join("workspace"); + let outside = temp.path().join("outside"); + let nested = outside.join("nested"); + std::fs::create_dir(&workspace).expect("workspace dir"); + std::fs::create_dir_all(&nested).expect("outside nested dir"); + symlink(&nested, workspace.join("link")).expect("symlink"); + let mut ctx = test_tool_context(Arc::new(AutoPermissionHandler { + mode: claurst_core::config::PermissionMode::BypassPermissions, + })); + ctx.working_dir = workspace.clone(); + ctx.config.hosted_review.enabled = true; + ctx.config.hosted_review.allow_file_write_tools = true; + + let path = workspace.join("link").join("..").join("secret.txt"); + let error = ctx + .check_hosted_repair_path("Write", &path) + .expect_err("symlink must resolve before applying parent component") + .to_string(); + + assert!(error.contains("hosted repair file tools are limited")); + } + + #[cfg(unix)] + #[test] + fn hosted_repair_path_check_rejects_dangling_symlink() { + use claurst_core::permissions::AutoPermissionHandler; + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("tempdir"); + let workspace = temp.path().join("workspace"); + std::fs::create_dir(&workspace).expect("workspace dir"); + symlink( + temp.path().join("missing-target"), + workspace.join("dangling"), + ) + .expect("symlink"); + let mut ctx = test_tool_context(Arc::new(AutoPermissionHandler { + mode: claurst_core::config::PermissionMode::BypassPermissions, + })); + ctx.working_dir = workspace.clone(); + ctx.config.hosted_review.enabled = true; + ctx.config.hosted_review.allow_file_write_tools = true; + + let path = workspace.join("dangling").join("new.txt"); + let error = ctx + .check_hosted_repair_path("Write", &path) + .expect_err("dangling symlink must fail closed") + .to_string(); + + assert!(error.contains("hosted repair file tools are limited")); + } + // ---- PermissionLevel tests --------------------------------------------- #[test] From 8d224ad7a06fe1a2bbe9eb613dc5446c21aa8de2 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Mon, 27 Jul 2026 08:06:24 -0500 Subject: [PATCH 3/6] fix(tools): suppress formatters in hosted repair Signed-off-by: Val Alexander <68980965+BunsDev@users.noreply.github.com> --- src-rust/crates/tools/src/formatter.rs | 6 ++++ src-rust/crates/tools/src/lib.rs | 47 ++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src-rust/crates/tools/src/formatter.rs b/src-rust/crates/tools/src/formatter.rs index 10f222b4..63c078b3 100644 --- a/src-rust/crates/tools/src/formatter.rs +++ b/src-rust/crates/tools/src/formatter.rs @@ -5,6 +5,12 @@ use crate::ToolContext; /// Try to format a file using any configured formatter. /// Returns silently if no formatter is configured or the formatter fails. pub async fn try_format_file(path: &str, ctx: &ToolContext) { + // Hosted repair promises a file-only surface under BypassPermissions. + // Formatters are executable commands and may come from repository config. + if ctx.config.hosted_review_enabled() && ctx.config.hosted_review.allow_file_write_tools { + return; + } + let formatters = &ctx.config.formatter; if formatters.is_empty() { return; diff --git a/src-rust/crates/tools/src/lib.rs b/src-rust/crates/tools/src/lib.rs index 21c7b0d1..5b363bed 100644 --- a/src-rust/crates/tools/src/lib.rs +++ b/src-rust/crates/tools/src/lib.rs @@ -1181,6 +1181,53 @@ mod tests { assert!(error.contains("hosted repair file tools are limited")); } + #[cfg(unix)] + #[tokio::test] + async fn hosted_repair_does_not_run_configured_formatter() { + use claurst_core::config::FormatterConfig; + use claurst_core::permissions::AutoPermissionHandler; + + let temp = tempfile::tempdir().expect("tempdir"); + let marker = temp.path().join("formatter-ran"); + let mut ctx = test_tool_context(Arc::new(AutoPermissionHandler { + mode: claurst_core::config::PermissionMode::BypassPermissions, + })); + ctx.working_dir = temp.path().to_path_buf(); + ctx.config.hosted_review.enabled = true; + ctx.config.hosted_review.allow_file_write_tools = true; + ctx.config.formatter.insert( + "repo-controlled".to_string(), + FormatterConfig { + command: vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "printf formatter-ran > \"$1\"".to_string(), + "formatter-test".to_string(), + marker.to_string_lossy().into_owned(), + "$FILE".to_string(), + ], + extensions: vec![".rs".to_string()], + disabled: false, + }, + ); + + try_format_file(&temp.path().join("source.rs").to_string_lossy(), &ctx).await; + + assert!( + !marker.exists(), + "hosted repair must not execute configured formatter commands" + ); + + ctx.config.hosted_review.enabled = false; + ctx.config.hosted_review.allow_file_write_tools = false; + try_format_file(&temp.path().join("source.rs").to_string_lossy(), &ctx).await; + + assert!( + marker.exists(), + "local mode should retain configured formatter behavior" + ); + } + // ---- PermissionLevel tests --------------------------------------------- #[test] From 96383f478ce127874ee43304968126d1580a71fb Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Mon, 27 Jul 2026 08:18:45 -0500 Subject: [PATCH 4/6] fix(tools): protect Git control metadata Signed-off-by: Val Alexander <68980965+BunsDev@users.noreply.github.com> --- src-rust/crates/tools/src/lib.rs | 106 ++++++++++++++++++++++++++++++- 1 file changed, 105 insertions(+), 1 deletion(-) diff --git a/src-rust/crates/tools/src/lib.rs b/src-rust/crates/tools/src/lib.rs index 5b363bed..8349f362 100644 --- a/src-rust/crates/tools/src/lib.rs +++ b/src-rust/crates/tools/src/lib.rs @@ -473,13 +473,25 @@ impl ToolContext { } fn path_is_within_working_dir(&self, path: &Path) -> bool { + if let Ok(relative) = path.strip_prefix(&self.working_dir) { + if contains_git_metadata_component(relative) { + return false; + } + } + let Ok(root) = std::fs::canonicalize(&self.working_dir) else { return false; }; let Ok(resolved) = canonicalize_path_allow_missing(path) else { return false; }; - resolved.starts_with(root) + let Ok(relative) = resolved.strip_prefix(&root) else { + return false; + }; + + // Git metadata controls later host-side `git` calls (for example via + // core.fsmonitor), so hosted repair must treat it as control-plane data. + !contains_git_metadata_component(relative) } pub fn check_permission_with_details_and_path( @@ -526,6 +538,16 @@ impl ToolContext { } } +fn contains_git_metadata_component(path: &Path) -> bool { + path.components().any(|component| { + matches!( + component, + Component::Normal(name) + if name.to_str().is_some_and(|name| name.eq_ignore_ascii_case(".git")) + ) + }) +} + /// Resolve every existing component before applying later `..` components, /// while retaining nonexistent suffixes for file-creation tools. fn canonicalize_path_allow_missing(path: &Path) -> std::io::Result { @@ -1068,6 +1090,62 @@ mod tests { assert!(error.contains("hosted repair file tools are limited")); } + #[test] + fn hosted_repair_path_check_rejects_git_metadata() { + use claurst_core::permissions::AutoPermissionHandler; + + let temp = tempfile::tempdir().expect("tempdir"); + let workspace = temp.path().join("workspace"); + std::fs::create_dir_all(workspace.join(".git")).expect("git metadata dir"); + let mut ctx = test_tool_context(Arc::new(AutoPermissionHandler { + mode: claurst_core::config::PermissionMode::BypassPermissions, + })); + ctx.working_dir = workspace.clone(); + ctx.config.hosted_review.enabled = true; + ctx.config.hosted_review.allow_file_write_tools = true; + + for path in [ + workspace.join(".git"), + workspace.join(".git/config"), + workspace.join("nested/.GiT/config"), + workspace.join(".git/missing/../../source.rs"), + ] { + let error = ctx + .check_hosted_repair_path("Write", &path) + .expect_err("Git control metadata must be immutable in hosted repair") + .to_string(); + + assert!(error.contains("hosted repair file tools are limited")); + } + } + + #[test] + fn hosted_repair_path_check_rejects_linked_worktree_gitdir_file() { + use claurst_core::permissions::AutoPermissionHandler; + + let temp = tempfile::tempdir().expect("tempdir"); + let workspace = temp.path().join("workspace"); + std::fs::create_dir(&workspace).expect("workspace dir"); + std::fs::write( + workspace.join(".git"), + "gitdir: ../main.git/worktrees/workspace\n", + ) + .expect("gitdir file"); + let mut ctx = test_tool_context(Arc::new(AutoPermissionHandler { + mode: claurst_core::config::PermissionMode::BypassPermissions, + })); + ctx.working_dir = workspace.clone(); + ctx.config.hosted_review.enabled = true; + ctx.config.hosted_review.allow_file_write_tools = true; + + let error = ctx + .check_hosted_repair_path("Edit", &workspace.join(".git")) + .expect_err("linked-worktree gitdir file must be immutable") + .to_string(); + + assert!(error.contains("hosted repair file tools are limited")); + } + #[test] fn hosted_repair_path_resolution_does_not_pop_past_filesystem_root() { let temp = tempfile::tempdir().expect("tempdir"); @@ -1151,6 +1229,32 @@ mod tests { assert!(error.contains("hosted repair file tools are limited")); } + #[cfg(unix)] + #[test] + fn hosted_repair_path_check_rejects_symlink_alias_to_git_metadata() { + use claurst_core::permissions::AutoPermissionHandler; + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("tempdir"); + let workspace = temp.path().join("workspace"); + std::fs::create_dir_all(workspace.join(".git")).expect("git metadata dir"); + symlink(".git", workspace.join("metadata")).expect("symlink"); + let mut ctx = test_tool_context(Arc::new(AutoPermissionHandler { + mode: claurst_core::config::PermissionMode::BypassPermissions, + })); + ctx.working_dir = workspace.clone(); + ctx.config.hosted_review.enabled = true; + ctx.config.hosted_review.allow_file_write_tools = true; + + let path = workspace.join("metadata/config"); + let error = ctx + .check_hosted_repair_path("Write", &path) + .expect_err("symlink alias to Git metadata must be rejected") + .to_string(); + + assert!(error.contains("hosted repair file tools are limited")); + } + #[cfg(unix)] #[test] fn hosted_repair_path_check_rejects_dangling_symlink() { From 4af785c37d646e392f9adcceb0793c06f74050b9 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Mon, 27 Jul 2026 08:32:35 -0500 Subject: [PATCH 5/6] fix(tools): reject Windows Git metadata aliases Signed-off-by: Val Alexander <68980965+BunsDev@users.noreply.github.com> --- src-rust/crates/tools/src/lib.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src-rust/crates/tools/src/lib.rs b/src-rust/crates/tools/src/lib.rs index 8349f362..39ffd81a 100644 --- a/src-rust/crates/tools/src/lib.rs +++ b/src-rust/crates/tools/src/lib.rs @@ -543,7 +543,11 @@ fn contains_git_metadata_component(path: &Path) -> bool { matches!( component, Component::Normal(name) - if name.to_str().is_some_and(|name| name.eq_ignore_ascii_case(".git")) + if name.to_str().is_some_and(|name| { + // Win32 strips trailing dots and spaces when opening a path. + name.trim_end_matches(['.', ' ']) + .eq_ignore_ascii_case(".git") + }) ) }) } @@ -1108,6 +1112,9 @@ mod tests { workspace.join(".git"), workspace.join(".git/config"), workspace.join("nested/.GiT/config"), + workspace.join(".git./config"), + workspace.join(".git /config"), + workspace.join(".GIT... /config"), workspace.join(".git/missing/../../source.rs"), ] { let error = ctx From 5b92c9159cc7ddacb037b2ed318c6f3198788c18 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Mon, 27 Jul 2026 08:41:37 -0500 Subject: [PATCH 6/6] fix(tools): reject NTFS Git metadata aliases Signed-off-by: Val Alexander <68980965+BunsDev@users.noreply.github.com> --- src-rust/crates/tools/src/lib.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src-rust/crates/tools/src/lib.rs b/src-rust/crates/tools/src/lib.rs index 39ffd81a..88bc30e3 100644 --- a/src-rust/crates/tools/src/lib.rs +++ b/src-rust/crates/tools/src/lib.rs @@ -544,9 +544,11 @@ fn contains_git_metadata_component(path: &Path) -> bool { component, Component::Normal(name) if name.to_str().is_some_and(|name| { - // Win32 strips trailing dots and spaces when opening a path. - name.trim_end_matches(['.', ' ']) - .eq_ignore_ascii_case(".git") + // Win32 strips trailing dots/spaces and exposes directories + // through alternate streams and 8.3 short names. + let name = name.split_once(':').map_or(name, |(base, _)| base); + let name = name.trim_end_matches(['.', ' ']); + name.eq_ignore_ascii_case(".git") || name.eq_ignore_ascii_case("git~1") }) ) }) @@ -1115,6 +1117,10 @@ mod tests { workspace.join(".git./config"), workspace.join(".git /config"), workspace.join(".GIT... /config"), + workspace.join(".git::$INDEX_ALLOCATION/config"), + workspace.join(".git.::$INDEX_ALLOCATION/config"), + workspace.join("git~1/config"), + workspace.join("GIT~1. /config"), workspace.join(".git/missing/../../source.rs"), ] { let error = ctx