fix(gateguard): gate force/path git checkout as destructive#2158
Conversation
The destructive-command gate's `checkout` handler only flagged `git checkout -- <path>`. It missed `git checkout --force` / `-f <branch>` and `git checkout .`, all of which discard uncommitted working-tree changes, so they bypassed the gate (once the once-per-session routine-Bash gate is satisfied, they ran with no challenge). The sibling `switch` handler already covers these force forms; mirror it for `checkout`.
📝 WalkthroughWalkthroughThe PR enhances the ChangesDestructive Checkout Detection
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint install failed. For unrecoverable errors, disable the tool in CodeRabbit configuration. 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. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
tests/hooks/gateguard-fact-force.test.js (1)
304-318: ⚡ Quick winConsider adding test coverage for other destructive checkout forms.
Test 7b validates
-f, but the implementation also gatesgit checkout .,git checkout --, andgit checkout --force. Adding explicit tests for these patterns would prevent regressions if the logic changes.🧪 Suggested additional test cases
// --- Test 7b: gates force/path git checkout as destructive Bash --- clearState(); if (test('denies git checkout -f as destructive Bash', () => { const input = { tool_name: 'Bash', tool_input: { command: 'git checkout -f main' } }; const result = runBashHook(input); assert.strictEqual(result.code, 0, 'exit code should be 0'); const output = parseOutput(result.stdout); assert.ok(output, 'should produce JSON output'); assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny'); assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('Destructive')); assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('rollback')); })) passed++; else failed++; + // --- Test 7c: gates git checkout . as destructive Bash --- + clearState(); + if (test('denies git checkout . as destructive Bash', () => { + expectDestructiveDeny('git checkout .', 'git checkout .'); + })) passed++; else failed++; + + // --- Test 7d: gates git checkout -- <path> as destructive Bash --- + clearState(); + if (test('denies git checkout -- path as destructive Bash', () => { + expectDestructiveDeny('git checkout -- src/file.js', 'git checkout -- path'); + })) passed++; else failed++; + + // --- Test 7e: gates git checkout --force as destructive Bash --- + clearState(); + if (test('denies git checkout --force as destructive Bash', () => { + expectDestructiveDeny('git checkout --force main', 'git checkout --force'); + })) passed++; else failed++; + + // --- Test 7f: allows plain git checkout (branch switch) --- + clearState(); + if (test('allows plain git checkout branch switch', () => { + expectAllow('git checkout feature-branch', 'git checkout feature-branch'); + })) passed++; else failed++;Note: The
expectDestructiveDenyandexpectAllowhelpers are defined later in the file (lines 1179-1204), so these tests would need to be placed after line 1175 or the helper definitions moved earlier.🤖 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 `@tests/hooks/gateguard-fact-force.test.js` around lines 304 - 318, Add explicit tests in tests/hooks/gateguard-fact-force.test.js that mirror "Test 7b" (which uses runBashHook) to cover the other destructive checkout forms: "git checkout .", "git checkout --", and "git checkout --force" and assert they produce a deny with a Destructive/rollback reason; use the existing helpers expectDestructiveDeny (or expectAllow for non-destructive cases) to keep assertions concise and place these new test cases after the helper definitions (or move expectDestructiveDeny/expectAllow above these tests) so they can be referenced, and follow the same test pattern (clearState(), build input with tool_name 'Bash' and tool_input.command, call runBashHook, parseOutput and assert permissionDecision === 'deny' and reason includes 'Destructive' and 'rollback').
🤖 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.
Nitpick comments:
In `@tests/hooks/gateguard-fact-force.test.js`:
- Around line 304-318: Add explicit tests in
tests/hooks/gateguard-fact-force.test.js that mirror "Test 7b" (which uses
runBashHook) to cover the other destructive checkout forms: "git checkout .",
"git checkout --", and "git checkout --force" and assert they produce a deny
with a Destructive/rollback reason; use the existing helpers
expectDestructiveDeny (or expectAllow for non-destructive cases) to keep
assertions concise and place these new test cases after the helper definitions
(or move expectDestructiveDeny/expectAllow above these tests) so they can be
referenced, and follow the same test pattern (clearState(), build input with
tool_name 'Bash' and tool_input.command, call runBashHook, parseOutput and
assert permissionDecision === 'deny' and reason includes 'Destructive' and
'rollback').
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 6a963e37-4d18-48e6-800f-61b59e20cd46
📒 Files selected for processing (2)
scripts/hooks/gateguard-fact-force.jstests/hooks/gateguard-fact-force.test.js
There was a problem hiding this comment.
1 issue found across 2 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="scripts/hooks/gateguard-fact-force.js">
<violation number="1" location="scripts/hooks/gateguard-fact-force.js:275">
P3: `checkout` short-option parsing may overmatch safe commands: git's stuck-form option syntax (`-bfeature`) causes false-positive destructive gating when the argument contains 'f'</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| return rest.some(t => { | ||
| if (t === '--' || t === '.' || t === '--force') return true; | ||
| if (!t.startsWith('-') || t.startsWith('--')) return false; | ||
| return t.slice(1).includes('f'); | ||
| }); |
There was a problem hiding this comment.
P3: checkout short-option parsing may overmatch safe commands: git's stuck-form option syntax (-bfeature) causes false-positive destructive gating when the argument contains 'f'
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/hooks/gateguard-fact-force.js, line 275:
<comment>`checkout` short-option parsing may overmatch safe commands: git's stuck-form option syntax (`-bfeature`) causes false-positive destructive gating when the argument contains 'f'</comment>
<file context>
@@ -269,7 +269,14 @@ function isDestructiveGit(tokens) {
+ // `git checkout -- <path>`, `git checkout .`, and the force forms
+ // (`--force` / `-f`) all discard uncommitted working-tree changes,
+ // mirroring the `switch` handler below.
+ return rest.some(t => {
+ if (t === '--' || t === '.' || t === '--force') return true;
+ if (!t.startsWith('-') || t.startsWith('--')) return false;
</file context>
| return rest.some(t => { | |
| if (t === '--' || t === '.' || t === '--force') return true; | |
| if (!t.startsWith('-') || t.startsWith('--')) return false; | |
| return t.slice(1).includes('f'); | |
| }); | |
| return rest.some(t => { | |
| if (t === '--' || t === '.' || t === '--force') return true; | |
| if (!t.startsWith('-') || t.startsWith('--')) return false; | |
| const body = t.slice(1); | |
| // `-bfeature` is a stuck-form argument, not a flag cluster. | |
| // Option-taking short opts like -b/-B/-p/-c/-C consume the rest of the token. | |
| // Only treat `f` as destructive when it appears before the first argument-taking option character, or is a standalone flag. | |
| const argTakers = new Set(['b', 'B', 'c', 'C']); | |
| for (let i = 0; i < body.length; i++) { | |
| if (body[i] === 'f') return true; | |
| if (argTakers.has(body[i])) return false; | |
| } | |
| return false; | |
| }); |
Summary
GateGuard challenges destructive commands before they run, but its
git checkouthandler inscripts/hooks/gateguard-fact-force.jsonly flaggedgit checkout -- <path>:It missed:
git checkout --force/git checkout -f <branch>— force checkout, discards local uncommitted changesgit checkout .— discards all working-tree changesSo those forms bypassed the gate. Worse, once the once-per-session routine-Bash gate is satisfied by any prior command,
git checkout -f mainruns with no challenge at all — exactly the destructive scenario GateGuard exists to catch. The semantically-identicalgit switch -f mainis gated, so this is an inconsistent gap, not policy.Fix
Mirror the existing
switchhandler (which already covers--force/-f): treat--,.,--force, and short-f-style flags oncheckoutas destructive.This preserves the existing
--behavior and leaves branch-switching (git checkout main,git checkout -b new) un-gated.Verification
Added a test to
tests/hooks/gateguard-fact-force.test.jsassertinggit checkout -f mainis denied with aDestructivefact-forcing message (alongside the existinggit push --force/git commit --amendcases). Verified it fails before the fix (only the routine gate fires) and passes after. The gateguard suite is green (92/92); fullnode tests/run-all.jsis 2618 passed with 1 pre-existing unrelated failure (scripts/trae-install.test.js, identical onmain).Summary by cubic
Fixes a gap in GateGuard so destructive
git checkoutforms (--force,-f,.) are challenged, preventing unprompted discards of uncommitted changes. Non-destructive branch switching remains unaffected.scripts/hooks/gateguard-fact-force.jsto flaggit checkoutwith--,.,--force, or short-fflags.git checkout <branch>andgit checkout -b <branch>ungated.git checkout -f mainis denied as destructive.Written for commit ddb90e4. Summary will update on new commits.
Summary by CodeRabbit
Bug Fixes
Tests