From b6a290d061f5ad7e7c9b6ace0fa537b9bc73a159 Mon Sep 17 00:00:00 2001 From: seto Date: Sun, 12 Apr 2026 18:08:15 +0900 Subject: [PATCH] fix: allow destructive bash retry after facts presented Destructive bash gate previously denied every invocation with no isChecked call, creating an infinite deny loop. Now gates per-command on first attempt and allows retry after the model presents the required facts (targets, rollback plan, user instruction). Addresses greptile P1: "Destructive bash gate permanently blocks" Co-Authored-By: Claude Opus 4.6 --- scripts/hooks/gateguard-fact-force.js | 8 +++++- tests/hooks/gateguard-fact-force.test.js | 32 +++++++++++++++++------- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/scripts/hooks/gateguard-fact-force.js b/scripts/hooks/gateguard-fact-force.js index 92f53ce0..79d219c4 100644 --- a/scripts/hooks/gateguard-fact-force.js +++ b/scripts/hooks/gateguard-fact-force.js @@ -184,7 +184,13 @@ function run(rawInput) { const command = toolInput.command || ''; if (DESTRUCTIVE_BASH.test(command)) { - return denyResult(destructiveBashMsg()); + // Gate destructive commands on first attempt; allow retry after facts presented + const key = '__destructive__' + command.slice(0, 200); + if (!isChecked(key)) { + markChecked(key); + return denyResult(destructiveBashMsg()); + } + return rawInput; // allow retry after facts presented } if (!isChecked('__bash_session__')) { diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js index 6c26fafc..ef1f9905 100644 --- a/tests/hooks/gateguard-fact-force.test.js +++ b/tests/hooks/gateguard-fact-force.test.js @@ -166,20 +166,34 @@ function runTests() { assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('call this new file')); })) passed++; else failed++; - // --- Test 4: denies destructive Bash --- + // --- Test 4: denies destructive Bash, allows retry --- clearState(); - if (test('denies destructive Bash commands', () => { + if (test('denies destructive Bash commands, allows retry after facts presented', () => { const input = { tool_name: 'Bash', tool_input: { command: 'rm -rf /important/data' } }; - 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')); + + // First call: should deny + const result1 = runBashHook(input); + assert.strictEqual(result1.code, 0, 'first call exit code should be 0'); + const output1 = parseOutput(result1.stdout); + assert.ok(output1, 'first call should produce JSON output'); + assert.strictEqual(output1.hookSpecificOutput.permissionDecision, 'deny'); + assert.ok(output1.hookSpecificOutput.permissionDecisionReason.includes('Destructive')); + assert.ok(output1.hookSpecificOutput.permissionDecisionReason.includes('rollback')); + + // Second call (retry after facts presented): should allow + const result2 = runBashHook(input); + assert.strictEqual(result2.code, 0, 'second call exit code should be 0'); + const output2 = parseOutput(result2.stdout); + assert.ok(output2, 'second call should produce valid JSON output'); + if (output2.hookSpecificOutput) { + assert.notStrictEqual(output2.hookSpecificOutput.permissionDecision, 'deny', + 'should not deny destructive bash retry after facts presented'); + } else { + assert.strictEqual(output2.tool_name, 'Bash', 'pass-through should preserve input'); + } })) passed++; else failed++; // --- Test 5: denies first routine Bash, allows second ---