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', () => {