From 1188aeafc40f0dc54650cd75a6cf9abb1ccb474a Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 29 Apr 2026 22:30:57 -0400 Subject: [PATCH] fix: refine gateguard destructive git detection --- scripts/hooks/gateguard-fact-force.js | 2 +- tests/hooks/gateguard-fact-force.test.js | 56 ++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/scripts/hooks/gateguard-fact-force.js b/scripts/hooks/gateguard-fact-force.js index fcb0fd7b..67f4d700 100644 --- a/scripts/hooks/gateguard-fact-force.js +++ b/scripts/hooks/gateguard-fact-force.js @@ -39,7 +39,7 @@ const MAX_CHECKED_ENTRIES = 500; const MAX_SESSION_KEYS = 50; const ROUTINE_BASH_SESSION_KEY = '__bash_session__'; -const DESTRUCTIVE_BASH = /\b(rm\s+-rf|git\s+reset\s+--hard|git\s+checkout\s+--|git\s+clean\s+-f|drop\s+table|delete\s+from|truncate|git\s+push\s+--force|dd\s+if=)\b/i; +const DESTRUCTIVE_BASH = /\b(rm\s+-rf|git\s+reset\s+--hard|git\s+checkout\s+--|git\s+clean\s+-f|drop\s+table|delete\s+from|truncate|git\s+push\s+--force(?!-with-lease)|git\s+commit\s+--amend|dd\s+if=)\b/i; // --- State management (per-session, atomic writes, bounded) --- diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js index 98d33e57..20dd5cc6 100644 --- a/tests/hooks/gateguard-fact-force.test.js +++ b/tests/hooks/gateguard-fact-force.test.js @@ -223,6 +223,62 @@ function runTests() { // --- Test 5: denies first routine Bash, allows second --- clearState(); + if (test('allows safe git push --force-with-lease without destructive gate', () => { + writeState({ + checked: ['__bash_session__'], + last_active: Date.now() + }); + + const input = { + tool_name: 'Bash', + tool_input: { command: 'git push --force-with-lease origin feature-branch' } + }; + const result = runBashHook(input); + assert.strictEqual(result.code, 0, 'exit code should be 0'); + const output = parseOutput(result.stdout); + assert.ok(output, 'should produce valid JSON output'); + if (output.hookSpecificOutput) { + assert.notStrictEqual(output.hookSpecificOutput.permissionDecision, 'deny', + 'safe lease-protected force push should not be denied'); + } else { + assert.strictEqual(output.tool_name, 'Bash', 'pass-through should preserve input'); + } + })) passed++; else failed++; + + // --- Test 6: gates amend as destructive Bash --- + clearState(); + if (test('denies git commit --amend as destructive Bash', () => { + const input = { + tool_name: 'Bash', + tool_input: { command: 'git commit --amend --no-edit' } + }; + 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 7: still gates plain force push as destructive Bash --- + clearState(); + if (test('denies plain git push --force as destructive Bash', () => { + const input = { + tool_name: 'Bash', + tool_input: { command: 'git push --force origin feature-branch' } + }; + 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', () => { const input = { tool_name: 'Bash',