fix: keep gateguard session state alive

This commit is contained in:
Affaan Mustafa
2026-04-13 00:58:50 -07:00
parent 2e44beabc1
commit 6c67566767
2 changed files with 84 additions and 13 deletions

View File

@@ -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());
}

View File

@@ -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 {