fix(gateguard): cap session fact-force denials

This commit is contained in:
Suliman Abdulrazzaq
2026-09-16 12:35:32 +00:00
committed by SulimanAbdulrazzaq
parent 8321021c54
commit 241b4b535b
3 changed files with 50 additions and 0 deletions
+17
View File
@@ -953,6 +953,17 @@ function getFullDenialBudget() {
return DEFAULT_FULL_DENIALS;
}
// Edit/Write fact forcing can be capped per session instead of denying every
// new path forever. Keep the existing behavior when unset; hosts can opt in
// to a session-wide ceiling without weakening the destructive-Bash gate.
function getMaxDenialBudget() {
const raw = Number.parseInt(process.env.GATEGUARD_FACT_FORCE_MAX_DENIALS || '', 10);
if (Number.isInteger(raw) && raw >= 0) {
return raw;
}
return Number.POSITIVE_INFINITY;
}
function getDenialCount(state) {
const n = Number(state && state.fact_force_denials);
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
@@ -1262,6 +1273,9 @@ function run(rawInput) {
if (!ok) {
return allowWithStateWarning();
}
if (denials > getMaxDenialBudget()) {
return rawInput;
}
if (denials > getFullDenialBudget()) {
const action = toolName === 'Edit' ? 'edit' : 'creation';
return denyResult(condensedGateMsg(action, filePath, denials), { includeRecoveryHint: false });
@@ -1287,6 +1301,9 @@ function run(rawInput) {
if (!ok) {
return allowWithStateWarning();
}
if (denials > getMaxDenialBudget()) {
return rawInput;
}
if (denials > getFullDenialBudget()) {
return denyResult(condensedGateMsg('edit', filePath, denials), { includeRecoveryHint: false });
}
+1
View File
@@ -117,6 +117,7 @@ load-bearing destructive-Bash checks keep running:
| `GATEGUARD_BASH_ROUTINE_DISABLED` | unset (gate on) | Disables the **routine-Bash** gate only. The destructive-Bash gate (`rm -rf`, `git reset --hard`, `drop table`, `dd if=`, …) is unaffected. |
| `GATEGUARD_EXEMPT_GLOBS` | unset (no exemptions) | Comma-separated globs; a matching Edit/Write/MultiEdit target skips first-touch fact-forcing. Intended for low-import-value trees (tests, generated artifacts, scratch dirs) where "who imports this / what schema" carries no signal. |
| `GATEGUARD_FACT_FORCE_FULL_DENIALS` | `3` | How many denials emit the full four-fact block before later ones condense to a single line. `0` condenses from the very first denial. |
| `GATEGUARD_FACT_FORCE_MAX_DENIALS` | unset (no cap) | Caps the total number of new-path Edit/Write denials in a session. Once the cap is reached, new paths are allowed instead of denied; destructive Bash stays gated independently. Opt-in — unset preserves the existing deny-every-new-path behaviour. Unlike `GATEGUARD_FACT_FORCE_FULL_DENIALS`, which only controls message detail, this changes whether the operation is blocked. |
| `GATEGUARD_BASH_EXTRA_DESTRUCTIVE` | unset | Extra destructive-command patterns, as regex source, added to the built-in set. A malformed regex is treated as unset (built-ins still apply) and logged once to stderr. |
| `GATEGUARD_STATE_DIR` | `~/.gateguard` | Where per-session gate state is kept. If state cannot be persisted the gate allows the operation rather than looping, and names this variable in the warning. |
+32
View File
@@ -2655,6 +2655,38 @@ function runTests() {
passed++;
else failed++;
clearState();
if (
test('allows new paths after the session-wide denial budget', () => {
writeState({ checked: [], last_active: Date.now(), fact_force_denials: 3 });
const result = runHook(
{ tool_name: 'Edit', tool_input: { file_path: '/src/after-budget.js' } },
{ GATEGUARD_FACT_FORCE_MAX_DENIALS: '3' }
);
assert.strictEqual(result.code, 0);
const output = parseOutput(result.stdout);
assert.ok(!output || !output.hookSpecificOutput, 'paths after the budget should pass through');
})
)
passed++;
else failed++;
clearState();
if (
test('MultiEdit allows new paths after the session-wide denial budget', () => {
writeState({ checked: [], last_active: Date.now(), fact_force_denials: 3 });
const result = runHook(
{ tool_name: 'MultiEdit', tool_input: { edits: [{ file_path: '/src/after-multi-budget.js', old_string: 'a', new_string: 'b' }] } },
{ GATEGUARD_FACT_FORCE_MAX_DENIALS: '3' }
);
assert.strictEqual(result.code, 0);
const output = parseOutput(result.stdout);
assert.ok(!output || !output.hookSpecificOutput, 'MultiEdit paths after the budget should pass through');
})
)
passed++;
else failed++;
clearState();
if (
test('malformed denial counter in state is treated as zero (full block, no crash)', () => {