From 0cb8907e14479f7bac26afaced88e0da41a10cab Mon Sep 17 00:00:00 2001 From: bymle Date: Sun, 7 Jun 2026 13:26:08 +0800 Subject: [PATCH] fix(gateguard): gate force/path git checkout as destructive (#2158) * fix(gateguard): gate force/path git checkout as destructive The destructive-command gate's `checkout` handler only flagged `git checkout -- `. It missed `git checkout --force` / `-f ` 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`. * test(gateguard): document Test 7b force-checkout case --------- Co-authored-by: bymle <229636660+bymle@users.noreply.github.com> --- scripts/hooks/gateguard-fact-force.js | 9 ++++++++- tests/hooks/gateguard-fact-force.test.js | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/scripts/hooks/gateguard-fact-force.js b/scripts/hooks/gateguard-fact-force.js index 741d3032..33a4ac0b 100644 --- a/scripts/hooks/gateguard-fact-force.js +++ b/scripts/hooks/gateguard-fact-force.js @@ -318,7 +318,14 @@ function isDestructiveGit(tokens) { } if (command === 'checkout') { - return rest.includes('--'); + // `git checkout -- `, `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; + return t.slice(1).includes('f'); + }); } if (command === 'clean') { diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js index 10c790c5..458476a7 100644 --- a/tests/hooks/gateguard-fact-force.test.js +++ b/tests/hooks/gateguard-fact-force.test.js @@ -301,6 +301,25 @@ function runTests() { assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('rollback')); })) passed++; else failed++; + /** + * Test 7b: `git checkout -f ` (force checkout) discards uncommitted + * working-tree changes, so it must be gated 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 8: denies first routine Bash, allows second --- clearState(); if (test('denies first routine Bash, allows second', () => {