fix: fail open on gateguard state write errors

This commit is contained in:
Affaan Mustafa
2026-04-30 08:07:02 -04:00
committed by Affaan Mustafa
parent e381c8d8a8
commit 95bef977c1
2 changed files with 47 additions and 5 deletions

View File

@@ -171,6 +171,7 @@ function saveState(state) {
} }
} }
tmpFile = null; tmpFile = null;
return true;
} catch (_) { } catch (_) {
if (tmpFile) { if (tmpFile) {
try { try {
@@ -179,6 +180,7 @@ function saveState(state) {
/* ignore */ /* ignore */
} }
} }
return false;
} }
} }
@@ -186,8 +188,9 @@ function markChecked(key) {
const state = loadState(); const state = loadState();
if (!state.checked.includes(key)) { if (!state.checked.includes(key)) {
state.checked.push(key); state.checked.push(key);
saveState(state); return saveState(state);
} }
return true;
} }
function isChecked(key) { function isChecked(key) {
@@ -364,6 +367,13 @@ function denyResult(reason) {
}; };
} }
function allowWithStateWarning() {
return {
stderr: '[Fact-Forcing Gate] GateGuard state could not be persisted; allowing this operation to avoid a permanent retry loop. Check GATEGUARD_STATE_DIR or filesystem permissions.',
exitCode: 0
};
}
// --- Core logic (exported for run-with-flags.js) --- // --- Core logic (exported for run-with-flags.js) ---
function run(rawInput) { function run(rawInput) {
@@ -389,7 +399,9 @@ function run(rawInput) {
} }
if (!isChecked(filePath)) { if (!isChecked(filePath)) {
markChecked(filePath); if (!markChecked(filePath)) {
return allowWithStateWarning();
}
return denyResult(toolName === 'Edit' ? editGateMsg(filePath) : writeGateMsg(filePath)); return denyResult(toolName === 'Edit' ? editGateMsg(filePath) : writeGateMsg(filePath));
} }
@@ -401,7 +413,9 @@ function run(rawInput) {
for (const edit of edits) { for (const edit of edits) {
const filePath = edit.file_path || ''; const filePath = edit.file_path || '';
if (filePath && !isClaudeSettingsPath(filePath) && !isChecked(filePath)) { if (filePath && !isClaudeSettingsPath(filePath) && !isChecked(filePath)) {
markChecked(filePath); if (!markChecked(filePath)) {
return allowWithStateWarning();
}
return denyResult(editGateMsg(filePath)); return denyResult(editGateMsg(filePath));
} }
} }
@@ -418,14 +432,18 @@ function run(rawInput) {
// Gate destructive commands on first attempt; allow retry after facts presented // Gate destructive commands on first attempt; allow retry after facts presented
const key = '__destructive__' + crypto.createHash('sha256').update(command).digest('hex').slice(0, 16); const key = '__destructive__' + crypto.createHash('sha256').update(command).digest('hex').slice(0, 16);
if (!isChecked(key)) { if (!isChecked(key)) {
markChecked(key); if (!markChecked(key)) {
return allowWithStateWarning();
}
return denyResult(destructiveBashMsg()); return denyResult(destructiveBashMsg());
} }
return rawInput; // allow retry after facts presented return rawInput; // allow retry after facts presented
} }
if (!isChecked(ROUTINE_BASH_SESSION_KEY)) { if (!isChecked(ROUTINE_BASH_SESSION_KEY)) {
markChecked(ROUTINE_BASH_SESSION_KEY); if (!markChecked(ROUTINE_BASH_SESSION_KEY)) {
return allowWithStateWarning();
}
return denyResult(routineBashMsg()); return denyResult(routineBashMsg());
} }

View File

@@ -191,6 +191,30 @@ function runTests() {
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('call this new file')); assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('call this new file'));
})) passed++; else failed++; })) passed++; else failed++;
// --- Test 3b: fails open when retry state cannot be persisted ---
clearState();
if (test('fails open with warning when state path cannot be persisted', () => {
const invalidStateDir = path.join(stateDir, 'not-a-directory');
fs.writeFileSync(invalidStateDir, 'not a directory', 'utf8');
const input = {
tool_name: 'Write',
tool_input: { file_path: '/src/state-failure.js', content: 'module.exports = {};' }
};
const result = runHook(input, { GATEGUARD_STATE_DIR: invalidStateDir });
assert.strictEqual(result.code, 0, 'exit code should be 0');
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce valid JSON output');
if (output.hookSpecificOutput) {
assert.notStrictEqual(output.hookSpecificOutput.permissionDecision, 'deny',
'unpersistable state must not deny a retry that can never be recorded');
} else {
assert.strictEqual(output.tool_name, 'Write', 'pass-through should preserve input');
}
assert.ok(result.stderr.includes('GateGuard state could not be persisted'),
'should warn that state persistence failed');
})) passed++; else failed++;
// --- Test 4: denies destructive Bash, allows retry --- // --- Test 4: denies destructive Bash, allows retry ---
clearState(); clearState();
if (test('denies destructive Bash commands, allows retry after facts presented', () => { if (test('denies destructive Bash commands, allows retry after facts presented', () => {