Skip to content

fix(hook): resolve PreToolUse branch guard against target file's repo, not host cwd - #283

Merged
getappz merged 3 commits into
masterfrom
fix/pre-tool-use-path-resolve
Jul 21, 2026
Merged

fix(hook): resolve PreToolUse branch guard against target file's repo, not host cwd#283
getappz merged 3 commits into
masterfrom
fix/pre-tool-use-path-resolve

Conversation

@getappz

@getappz getappz commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Bug: current_branch()/default_branch() in the PreToolUse redirect classifier used std::env::current_dir() to resolve which repo to check. Two failure modes:

  1. False positive: cwd is leanstack@master but file_path is outside any git repo (e.g. ~/.claude/memory/) → write falsely denied with "'master' is this repo's default branch"
  2. False negative: cwd is outside a repo but file_path is inside a repo on master → write allowed when it shouldn't be

Fix: extract file_path/path from tool input, resolve its git toplevel via repo_toplevel(), and check that repo's branch. If the target path is not in any git repo, skip the branch guard entirely.

Also fixes the opencode stray-diff incident class (item #234's path-not-cwd principle, same root cause).

Summary by CodeRabbit

  • Bug Fixes
    • Branch protection checks now use the Git repository containing the target file or path.
    • Operations on files outside a Git repository no longer trigger branch protection checks.
    • Improved behavior when working across multiple repositories or from a different working directory.

…, not host cwd

Agentflare-Agent: 1
Agentflare-Branch: fix/pre-tool-use-path-resolve
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@getappz, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 22 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c6ef660c-1a85-4140-a4f3-1646d31a5a32

📥 Commits

Reviewing files that changed from the base of the PR and between 264b065 and 8b7d9ec.

📒 Files selected for processing (1)
  • src/hook_redirect.rs
📝 Walkthrough

Walkthrough

Branch lookup in hook_redirect now uses the repository containing a mutating tool’s target path rather than the process working directory. Targets outside Git repositories bypass branch guarding.

Changes

Target repository branch guard

Layer / File(s) Summary
Path-aware branch resolution and redirect integration
src/hook_redirect.rs
Adds path support, makes current/default branch helpers repository-aware, and derives branch guard inputs from file_path or path; non-repository targets skip the guard.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description explains the bug and fix, but it omits the required Summary, Test plan, and Notes for reviewers sections. Add the template sections with a brief summary, test commands or results, and reviewer notes covering risks and backward compatibility.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: resolving branch guards against the target file's Git repo instead of the host cwd.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/pre-tool-use-path-resolve

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/hook_redirect.rs (1)

63-77: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Avoid unnecessary PathBuf allocations.

Both current_branch and default_branch unconditionally allocate a new PathBuf via .to_path_buf() when start_path is provided, just to borrow it on the next line. You can avoid this allocation by matching directly.

♻️ Proposed refactor
 fn current_branch(start_path: Option<&Path>) -> Option<String> {
-    let path = start_path
-        .map(|p| p.to_path_buf())
-        .or_else(|| std::env::current_dir().ok())?;
-    flare_git_core::branch::current_branch(&path)
+    if let Some(p) = start_path {
+        flare_git_core::branch::current_branch(p)
+    } else {
+        flare_git_core::branch::current_branch(&std::env::current_dir().ok()?)
+    }
 }
 
 /// Resolve the default branch of the repo containing `start_path`, or cwd if
 /// `start_path` is None.
 fn default_branch(start_path: Option<&Path>) -> Option<String> {
-    let path = start_path
-        .map(|p| p.to_path_buf())
-        .or_else(|| std::env::current_dir().ok())?;
-    Some(flare_git_core::branch::resolve_default_branch(&path))
+    if let Some(p) = start_path {
+        Some(flare_git_core::branch::resolve_default_branch(p))
+    } else {
+        Some(flare_git_core::branch::resolve_default_branch(&std::env::current_dir().ok()?))
+    }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hook_redirect.rs` around lines 63 - 77, Remove the unnecessary PathBuf
allocation in both current_branch and default_branch by matching on start_path
directly and borrowing the provided Path, while using the current working
directory only when start_path is None. Preserve the existing Option handling
and branch-resolution behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/hook_redirect.rs`:
- Around line 147-163: Update the target repository resolution in the
MUTATING_TOOLS branch around target_repo so failed path extraction falls back to
the current directory, empty parent paths are treated as ".", and non-existent
parent directories walk upward through ancestors until the first existing
directory before calling repo_toplevel. Preserve the no-guard behavior only when
no repository is found after this fallback resolution.

---

Nitpick comments:
In `@src/hook_redirect.rs`:
- Around line 63-77: Remove the unnecessary PathBuf allocation in both
current_branch and default_branch by matching on start_path directly and
borrowing the provided Path, while using the current working directory only when
start_path is None. Preserve the existing Option handling and branch-resolution
behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 17ac9e92-0c9b-48df-98b3-5189798a4290

📥 Commits

Reviewing files that changed from the base of the PR and between 0524862 and 264b065.

📒 Files selected for processing (1)
  • src/hook_redirect.rs

Comment thread src/hook_redirect.rs
Comment on lines 147 to +163
let (current, default) = if MUTATING_TOOLS.contains(&tool_name.as_str()) {
(current_branch(), default_branch())
let target_repo = tool_input.as_ref().and_then(|ti| {
ti.get("file_path")
.or_else(|| ti.get("path"))
.and_then(Value::as_str)
.map(Path::new)
.and_then(|p| p.parent())
.and_then(flare_git_core::branch::repo_toplevel)
});
match target_repo {
Some(repo) => (
current_branch(Some(&repo)),
default_branch(Some(&repo)),
),
// Target path not in any git repo → no branch guard.
None => (None, None),
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | 🏗️ Heavy lift

Fix branch guard bypasses for missing parameters, new directories, and local files.

The current implementation has three edge cases that will silently bypass the branch guard:

  1. Extraction failure bypass: Tools that don't use a top-level "file_path" or "path" (like MultiEdit or patch) will result in target_repo = None, skipping the guard entirely. You should fall back to the current directory if the path cannot be extracted.
  2. Empty parent paths: When editing a file in the current directory (e.g., "file.txt"), p.parent() returns an empty path (""). Using this as a working directory for the git subprocess fails with ENOENT, returning None and incorrectly bypassing the guard.
  3. Non-existent directories: If writing to a new file in a subdirectory that doesn't exist yet (e.g., "new_dir/file.txt"), the parent directory won't exist. The git subprocess will fail, again bypassing the guard.

To fix all three, separate the path extraction, walk up the ancestors to the first existing directory (mapping "" to "."), and properly fall back to the current directory when extraction fails.

🐛 Proposed fix
-        let (current, default) = if MUTATING_TOOLS.contains(&tool_name.as_str()) {
-            let target_repo = tool_input.as_ref().and_then(|ti| {
-                ti.get("file_path")
-                    .or_else(|| ti.get("path"))
-                    .and_then(Value::as_str)
-                    .map(Path::new)
-                    .and_then(|p| p.parent())
-                    .and_then(flare_git_core::branch::repo_toplevel)
-            });
-            match target_repo {
-                Some(repo) => (
-                    current_branch(Some(&repo)),
-                    default_branch(Some(&repo)),
-                ),
-                // Target path not in any git repo → no branch guard.
-                None => (None, None),
-            }
+        let (current, default) = if MUTATING_TOOLS.contains(&tool_name.as_str()) {
+            let target_path = tool_input.as_ref().and_then(|ti| {
+                ti.get("file_path")
+                    .or_else(|| ti.get("path"))
+                    .and_then(Value::as_str)
+                    .map(Path::new)
+            });
+            let target_repo = target_path.and_then(|p| {
+                for ancestor in p.ancestors().skip(1) {
+                    let check = if ancestor == Path::new("") { Path::new(".") } else { ancestor };
+                    if check.exists() {
+                        return flare_git_core::branch::repo_toplevel(check);
+                    }
+                }
+                None
+            });
+
+            match (target_path, target_repo) {
+                // Path was extracted but isn't in any git repo → no branch guard.
+                (Some(_), None) => (None, None),
+                // Path couldn't be extracted, or repo was found → use target_repo or fall back to cwd.
+                (_, repo) => (
+                    current_branch(repo.as_deref()),
+                    default_branch(repo.as_deref()),
+                ),
+            }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let (current, default) = if MUTATING_TOOLS.contains(&tool_name.as_str()) {
(current_branch(), default_branch())
let target_repo = tool_input.as_ref().and_then(|ti| {
ti.get("file_path")
.or_else(|| ti.get("path"))
.and_then(Value::as_str)
.map(Path::new)
.and_then(|p| p.parent())
.and_then(flare_git_core::branch::repo_toplevel)
});
match target_repo {
Some(repo) => (
current_branch(Some(&repo)),
default_branch(Some(&repo)),
),
// Target path not in any git repo → no branch guard.
None => (None, None),
}
let (current, default) = if MUTATING_TOOLS.contains(&tool_name.as_str()) {
let target_path = tool_input.as_ref().and_then(|ti| {
ti.get("file_path")
.or_else(|| ti.get("path"))
.and_then(Value::as_str)
.map(Path::new)
});
let target_repo = target_path.and_then(|p| {
for ancestor in p.ancestors().skip(1) {
let check = if ancestor == Path::new("") { Path::new(".") } else { ancestor };
if check.exists() {
return flare_git_core::branch::repo_toplevel(check);
}
}
None
});
match (target_path, target_repo) {
// Path was extracted but isn't in any git repo → no branch guard.
(Some(_), None) => (None, None),
// Path couldn't be extracted, or repo was found → use target_repo or fall back to cwd.
(_, repo) => (
current_branch(repo.as_deref()),
default_branch(repo.as_deref()),
),
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hook_redirect.rs` around lines 147 - 163, Update the target repository
resolution in the MUTATING_TOOLS branch around target_repo so failed path
extraction falls back to the current directory, empty parent paths are treated
as ".", and non-existent parent directories walk upward through ancestors until
the first existing directory before calling repo_toplevel. Preserve the no-guard
behavior only when no repository is found after this fallback resolution.

getappz added 2 commits July 21, 2026 12:15
…ocation

Agentflare-Agent: 1
Agentflare-Branch: fix/pre-tool-use-path-resolve
Agentflare-Agent: 1
Agentflare-Branch: fix/pre-tool-use-path-resolve
@getappz
getappz merged commit a2a0445 into master Jul 21, 2026
15 of 16 checks passed
@getappz
getappz deleted the fix/pre-tool-use-path-resolve branch July 21, 2026 06:56
getappz added a commit that referenced this pull request Jul 21, 2026
…missing path fields (#291)

* fix(hook): close branch-guard bypasses for bare filenames, new dirs, and missing path fields

CodeRabbit flagged this as critical on PR #283 but it was squash-merged
without a fix. target_repo resolution called repo_toplevel() directly
on p.parent(), which fails (and silently skips the branch guard) for:
- a bare filename (parent is \, ENOENT)
- a new file under a not-yet-created directory (parent doesn't exist)
- a MUTATING_TOOLS call with no top-level file_path/path (e.g. MultiEdit)

Now walks ancestors to the first directory that actually exists on disk
before resolving its repo toplevel, and falls back to cwd when the path
can't be extracted at all -- matching the original bug's intended fix.

Agentflare-Agent: claude-code_2-1-216_agent
Agentflare-Branch: fix/hook-redirect-branch-guard-bypass

* fix(hook): stop mutating process cwd in tests; call repo_toplevel once

Two issues found reviewing this PR's own CI run:

- The ancestor-walk tests used std::env::set_current_dir, which is
  global process state -- it collided with an unrelated parallel test
  (hook::tests::session_start_message_shows_pending_items_from_backend_db,
  which resolves its project from cwd) and broke CI's ubuntu build.
  Rewritten to use absolute paths and cwd-independent ground-truth
  comparisons instead, so no test here touches the real cwd.

- CodeRabbit nitpick on the ancestor walk: repo_toplevel() (a git
  subprocess) was being re-invoked for every existing ancestor instead
  of stopping at the first one, since git rev-parse --show-toplevel
  already walks upward internally. Now finds the first existing
  ancestor and calls repo_toplevel exactly once.

Agentflare-Agent: claude-code_2-1-216_agent
Agentflare-Branch: fix/hook-redirect-branch-guard-bypass
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant