diff --git a/hooks/README.md b/hooks/README.md index 98ac8295..ae8d568a 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -99,6 +99,9 @@ export ECC_HOOK_PROFILE=standard # Disable specific hook IDs (comma-separated) export ECC_DISABLED_HOOKS="pre:bash:tmux-reminder,post:edit:typecheck" +# Disable only GateGuard during setup or recovery +export ECC_GATEGUARD=off + # Cap SessionStart additional context (default: 8000 chars) export ECC_SESSION_START_MAX_CHARS=4000 diff --git a/scripts/hooks/gateguard-fact-force.js b/scripts/hooks/gateguard-fact-force.js index 111dd2f7..a57bee9e 100644 --- a/scripts/hooks/gateguard-fact-force.js +++ b/scripts/hooks/gateguard-fact-force.js @@ -38,11 +38,25 @@ const READ_HEARTBEAT_MS = 60 * 1000; const MAX_CHECKED_ENTRIES = 500; const MAX_SESSION_KEYS = 50; const ROUTINE_BASH_SESSION_KEY = '__bash_session__'; +const LEGACY_DISABLE_VALUES = new Set(['1', 'true', 'yes', 'on']); +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; // --- State management (per-session, atomic writes, bounded) --- +function normalizeEnvValue(value) { + return String(value || '').trim().toLowerCase(); +} + +function isGateGuardDisabled() { + if (LEGACY_DISABLE_VALUES.has(normalizeEnvValue(process.env.GATEGUARD_DISABLED))) { + return true; + } + + return ECC_DISABLE_VALUES.has(normalizeEnvValue(process.env.ECC_GATEGUARD)); +} + function sanitizeSessionKey(value) { const raw = String(value || '').trim(); if (!raw) { @@ -352,6 +366,14 @@ function routineBashMsg() { ].join('\n'); } +function withRecoveryHint(message) { + 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`.' + ].join('\n'); +} + // --- Deny helper --- function denyResult(reason) { @@ -360,7 +382,7 @@ function denyResult(reason) { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', - permissionDecisionReason: reason + permissionDecisionReason: withRecoveryHint(reason) } }), exitCode: 0 @@ -383,6 +405,11 @@ function run(rawInput) { } catch (_) { return rawInput; // allow on parse error } + + if (isGateGuardDisabled()) { + return rawInput; + } + activeStateFile = null; getStateFile(data); diff --git a/skills/gateguard/SKILL.md b/skills/gateguard/SKILL.md index ab90746a..6b112599 100644 --- a/skills/gateguard/SKILL.md +++ b/skills/gateguard/SKILL.md @@ -94,6 +94,10 @@ Triggers on: `rm -rf`, `git reset --hard`, `git push --force`, `drop table`, etc The hook at `scripts/hooks/gateguard-fact-force.js` is included in this plugin. Enable it via hooks.json. +If GateGuard blocks setup or repair work, start the session with +`ECC_GATEGUARD=off`. For hook-level control, keep using +`ECC_DISABLED_HOOKS` with the GateGuard hook ID. + ### Option B: Full package with config ```bash diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js index 80f03454..5b43890e 100644 --- a/tests/hooks/gateguard-fact-force.test.js +++ b/tests/hooks/gateguard-fact-force.test.js @@ -408,7 +408,56 @@ function runTests() { } })) passed++; else failed++; - // --- Test 10: MultiEdit gates first unchecked file --- + // --- Test 10: respects direct GateGuard env disable for recovery sessions --- + clearState(); + if (test('respects ECC_GATEGUARD=off without writing gate state', () => { + const input = { + tool_name: 'Write', + tool_input: { file_path: '/src/env-disabled.js', content: 'export const ok = true;' } + }; + const result = runHook(input, { ECC_GATEGUARD: 'off' }); + const output = parseOutput(result.stdout); + + assert.ok(output, 'should produce valid JSON output'); + assert.strictEqual(output.tool_name, 'Write', 'disabled gate should pass through raw input'); + assert.ok(!output.hookSpecificOutput, 'disabled gate should not deny the operation'); + assert.ok(!fs.existsSync(stateFile), 'disabled gate should not create or mutate gate state'); + })) passed++; else failed++; + + // --- Test 11: respects legacy GATEGUARD_DISABLED env disable --- + clearState(); + if (test('respects GATEGUARD_DISABLED=1 for Bash recovery', () => { + const input = { + tool_name: 'Bash', + tool_input: { command: 'npm test' } + }; + const result = runBashHook(input, { GATEGUARD_DISABLED: '1' }); + const output = parseOutput(result.stdout); + + assert.ok(output, 'should produce valid JSON output'); + assert.strictEqual(output.tool_name, 'Bash', 'disabled gate should pass Bash through raw input'); + assert.ok(!output.hookSpecificOutput, 'disabled gate should not deny Bash'); + assert.ok(!fs.existsSync(stateFile), 'disabled gate should not create or mutate gate state'); + })) passed++; else failed++; + + // --- Test 12: denial messages show an escape hatch --- + clearState(); + if (test('denial messages include direct recovery escape hatch', () => { + const input = { + tool_name: 'Write', + tool_input: { file_path: '/src/recovery-hint.js', content: 'export const ok = true;' } + }; + const result = runHook(input); + const output = parseOutput(result.stdout); + + assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny'); + assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('ECC_GATEGUARD=off'), + 'denial reason should show the direct recovery env toggle'); + assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('ECC_DISABLED_HOOKS'), + 'denial reason should mention the existing hook-id disable control'); + })) passed++; else failed++; + + // --- Test 13: MultiEdit gates first unchecked file --- clearState(); if (test('denies first MultiEdit with unchecked file', () => { const input = {