diff --git a/scripts/hooks/gateguard-fact-force.js b/scripts/hooks/gateguard-fact-force.js index 5c18d901..d83b557e 100644 --- a/scripts/hooks/gateguard-fact-force.js +++ b/scripts/hooks/gateguard-fact-force.js @@ -38,6 +38,8 @@ const READ_HEARTBEAT_MS = 60 * 1000; const MAX_CHECKED_ENTRIES = 500; const MAX_SESSION_KEYS = 50; const ROUTINE_BASH_SESSION_KEY = '__bash_session__'; +const EDIT_WRITE_HOOK_ID = 'pre:edit-write:gateguard-fact-force'; +const BASH_HOOK_ID = 'pre:bash:gateguard-fact-force'; const ECC_DISABLE_VALUES = new Set(['0', 'false', 'off', 'disabled', 'disable']); 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; @@ -365,11 +367,12 @@ function routineBashMsg() { ].join('\n'); } -function withRecoveryHint(message) { +function withRecoveryHint(message, hookIds = [EDIT_WRITE_HOOK_ID]) { + const disableTargets = hookIds.map(hookId => `\`${hookId}\``).join(' or '); return [ message, '', - 'Recovery: if GateGuard is blocking setup or repair work, run this session with `ECC_GATEGUARD=off` or add `pre:edit-write:gateguard-fact-force` to `ECC_DISABLED_HOOKS`.' + `Recovery: if GateGuard is blocking setup or repair work, run this session with \`ECC_GATEGUARD=off\` or add ${disableTargets} to \`ECC_DISABLED_HOOKS\`.` ].join('\n'); } @@ -377,12 +380,13 @@ function withRecoveryHint(message) { function denyResult(reason, options = {}) { const includeRecoveryHint = options.includeRecoveryHint !== false; + const hookIds = Array.isArray(options.hookIds) && options.hookIds.length > 0 ? options.hookIds : [EDIT_WRITE_HOOK_ID]; return { stdout: JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', - permissionDecisionReason: includeRecoveryHint ? withRecoveryHint(reason) : reason + permissionDecisionReason: includeRecoveryHint ? withRecoveryHint(reason, hookIds) : reason } }), exitCode: 0 @@ -471,7 +475,7 @@ function run(rawInput) { if (!markChecked(ROUTINE_BASH_SESSION_KEY)) { return allowWithStateWarning(); } - return denyResult(routineBashMsg()); + return denyResult(routineBashMsg(), { hookIds: [BASH_HOOK_ID] }); } return rawInput; // allow diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js index 7d3fb9b4..98f5ed32 100644 --- a/tests/hooks/gateguard-fact-force.test.js +++ b/tests/hooks/gateguard-fact-force.test.js @@ -471,7 +471,25 @@ function runTests() { 'denial reason should mention the existing hook-id disable control'); })) passed++; else failed++; - // --- Test 14: destructive Bash denials do not advertise the recovery escape hatch --- + // --- Test 14: routine Bash denial messages show the Bash hook escape hatch --- + clearState(); + if (test('routine Bash denials include Bash hook disable id', () => { + const input = { + tool_name: 'Bash', + tool_input: { command: 'npm test' } + }; + const result = runBashHook(input); + const output = parseOutput(result.stdout); + const reason = output.hookSpecificOutput.permissionDecisionReason; + + assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny'); + assert.ok(reason.includes('pre:bash:gateguard-fact-force'), + 'routine Bash denial should show the Bash hook ID'); + assert.ok(!reason.includes('pre:edit-write:gateguard-fact-force'), + 'routine Bash denial should not show the Edit/Write hook ID as the targeted disable'); + })) passed++; else failed++; + + // --- Test 15: destructive Bash denials do not advertise the recovery escape hatch --- clearState(); if (test('destructive Bash denials omit recovery escape hatch', () => { const input = { @@ -487,7 +505,7 @@ function runTests() { 'destructive gate should not advertise disabling GateGuard'); })) passed++; else failed++; - // --- Test 15: MultiEdit gates first unchecked file --- + // --- Test 16: MultiEdit gates first unchecked file --- clearState(); if (test('denies first MultiEdit with unchecked file', () => { const input = {