From 6c6756676792179bf443341666607858635ee9fa Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Mon, 13 Apr 2026 00:58:50 -0700 Subject: [PATCH] fix: keep gateguard session state alive --- scripts/hooks/gateguard-fact-force.js | 36 +++++++++----- tests/hooks/gateguard-fact-force.test.js | 61 ++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 13 deletions(-) diff --git a/scripts/hooks/gateguard-fact-force.js b/scripts/hooks/gateguard-fact-force.js index 6b47c915..b8a0fcc8 100644 --- a/scripts/hooks/gateguard-fact-force.js +++ b/scripts/hooks/gateguard-fact-force.js @@ -37,6 +37,8 @@ const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // Maximum checked entries to prevent unbounded growth 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; @@ -57,19 +59,25 @@ function loadState() { return { checked: [], last_active: Date.now() }; } +function pruneCheckedEntries(checked) { + if (checked.length <= MAX_CHECKED_ENTRIES) { + return checked; + } + + const preserved = checked.includes(ROUTINE_BASH_SESSION_KEY) ? [ROUTINE_BASH_SESSION_KEY] : []; + const sessionKeys = checked.filter(k => k.startsWith('__') && k !== ROUTINE_BASH_SESSION_KEY); + const fileKeys = checked.filter(k => !k.startsWith('__')); + const remainingSessionSlots = Math.max(MAX_SESSION_KEYS - preserved.length, 0); + const cappedSession = sessionKeys.slice(-remainingSessionSlots); + const remainingFileSlots = Math.max(MAX_CHECKED_ENTRIES - preserved.length - cappedSession.length, 0); + const cappedFiles = fileKeys.slice(-remainingFileSlots); + return [...preserved, ...cappedSession, ...cappedFiles]; +} + function saveState(state) { try { state.last_active = Date.now(); - // Prune checked list if it exceeds the cap. - // Preserve session keys (__prefixed) so gates like __bash_session__ don't re-fire. - if (state.checked.length > MAX_CHECKED_ENTRIES) { - const sessionKeys = state.checked.filter(k => k.startsWith('__')); - const fileKeys = state.checked.filter(k => !k.startsWith('__')); - // Cap session keys at 50 to prevent unbounded growth - const cappedSession = sessionKeys.length > 50 ? sessionKeys.slice(-50) : sessionKeys; - const remaining = MAX_CHECKED_ENTRIES - cappedSession.length; - state.checked = [...cappedSession, ...fileKeys.slice(-Math.max(remaining, 0))]; - } + state.checked = pruneCheckedEntries(state.checked); fs.mkdirSync(STATE_DIR, { recursive: true }); // Atomic write: temp file + rename prevents partial reads const tmpFile = STATE_FILE + '.tmp.' + process.pid; @@ -88,7 +96,9 @@ function markChecked(key) { function isChecked(key) { const state = loadState(); - return state.checked.includes(key); + const found = state.checked.includes(key); + saveState(state); + return found; } // Prune stale session files older than 1 hour @@ -241,8 +251,8 @@ function run(rawInput) { return rawInput; // allow retry after facts presented } - if (!isChecked('__bash_session__')) { - markChecked('__bash_session__'); + if (!isChecked(ROUTINE_BASH_SESSION_KEY)) { + markChecked(ROUTINE_BASH_SESSION_KEY); return denyResult(routineBashMsg()); } diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js index b76e580d..2f0837c3 100644 --- a/tests/hooks/gateguard-fact-force.test.js +++ b/tests/hooks/gateguard-fact-force.test.js @@ -48,6 +48,11 @@ function writeExpiredState() { } catch (_) { /* ignore */ } } +function writeState(state) { + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(stateFile, JSON.stringify(state), 'utf8'); +} + function runHook(input, env = {}) { const rawInput = typeof input === 'string' ? input : JSON.stringify(input); const result = spawnSync('node', [ @@ -358,6 +363,62 @@ function runTests() { } })) passed++; else failed++; + // --- Test 12: reads refresh active session state --- + clearState(); + if (test('touches last_active on read so active sessions do not age out', () => { + const staleButActive = Date.now() - (29 * 60 * 1000); + writeState({ + checked: ['/src/keep-alive.js'], + last_active: staleButActive + }); + + const before = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + assert.strictEqual(before.last_active, staleButActive, 'seed state should use the expected timestamp'); + + const result = runHook({ + tool_name: 'Edit', + tool_input: { file_path: '/src/keep-alive.js', old_string: 'a', new_string: 'b' } + }); + const output = parseOutput(result.stdout); + assert.ok(output, 'should produce valid JSON output'); + if (output.hookSpecificOutput) { + assert.notStrictEqual(output.hookSpecificOutput.permissionDecision, 'deny', + 'already-checked file should still be allowed'); + } + + const after = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + assert.ok(after.last_active > staleButActive, 'successful reads should refresh last_active'); + })) passed++; else failed++; + + // --- Test 13: pruning preserves routine bash gate marker --- + clearState(); + if (test('preserves __bash_session__ when pruning oversized state', () => { + const checked = ['__bash_session__']; + for (let i = 0; i < 80; i++) checked.push(`__destructive__${i}`); + for (let i = 0; i < 700; i++) checked.push(`/src/file-${i}.js`); + writeState({ checked, last_active: Date.now() }); + + runHook({ + tool_name: 'Edit', + tool_input: { file_path: '/src/newly-gated.js', old_string: 'a', new_string: 'b' } + }); + + const result = runBashHook({ + tool_name: 'Bash', + tool_input: { command: 'pwd' } + }); + const output = parseOutput(result.stdout); + assert.ok(output, 'should produce valid JSON output'); + if (output.hookSpecificOutput) { + assert.notStrictEqual(output.hookSpecificOutput.permissionDecision, 'deny', + 'routine bash marker should survive pruning'); + } + + const persisted = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + assert.ok(persisted.checked.includes('__bash_session__'), 'pruned state should retain __bash_session__'); + assert.ok(persisted.checked.length <= 500, 'pruned state should still honor the checked-entry cap'); + })) passed++; else failed++; + // Cleanup only the temp directory created by this test file. if (!externalStateDir) { try {