fix(gateguard): validate the denial cap as a whole integer

Review findings on the rebased branch.

Number.parseInt accepted partial values, so GATEGUARD_FACT_FORCE_MAX_DENIALS
set to '3.5', '3oops', or '0x3' silently became a finite cap of 3 and weakened
the gate on a typo. The value is now matched whole and anything that is not a
complete non-negative decimal integer leaves the gate uncapped, which is the
stricter outcome.

Also names the setting in the condensed denial's recovery text, so a capped
session can discover the control that is dampening it, and documents that
malformed values fall back to uncapped.

Tests: adds a Write pass-through case, an exact-boundary case where the denial
that reaches the cap still denies and the next path passes, one case per
malformed value, and an assertion that the condensed denial names the cap.
Pass-through assertions now require a parsed output object rather than
accepting a missing one. 207 hook tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bvrwu6iCpKP3C2rh8tBsdJ
This commit is contained in:
SulimanAbdulrazzaq
2026-09-16 13:00:16 +00:00
co-authored by Claude Opus 5
parent 241b4b535b
commit ef0a487edd
4 changed files with 1590 additions and 2201 deletions
+21 -8
View File
@@ -953,15 +953,28 @@ 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.
const MAX_DENIALS_PATTERN = /^\d+$/;
/**
* Session-wide ceiling on Edit/Write fact-force denials, from
* GATEGUARD_FACT_FORCE_MAX_DENIALS. Opt-in: unset keeps the existing behavior
* of denying every new path, and the destructive-Bash gate is unaffected
* either way.
*
* The value is validated whole rather than with Number.parseInt, because a
* prefix parse turns '3.5', '3oops', and '0x3' into finite caps and would
* quietly weaken the gate on a typo. Anything that is not a complete
* non-negative decimal integer leaves the gate uncapped.
*
* @returns {number} the denial ceiling, or Number.POSITIVE_INFINITY when uncapped
*/
function getMaxDenialBudget() {
const raw = Number.parseInt(process.env.GATEGUARD_FACT_FORCE_MAX_DENIALS || '', 10);
if (Number.isInteger(raw) && raw >= 0) {
return raw;
const raw = (process.env.GATEGUARD_FACT_FORCE_MAX_DENIALS || '').trim();
if (!MAX_DENIALS_PATTERN.test(raw)) {
return Number.POSITIVE_INFINITY;
}
return Number.POSITIVE_INFINITY;
const parsed = Number(raw);
return Number.isSafeInteger(parsed) ? parsed : Number.POSITIVE_INFINITY;
}
function getDenialCount(state) {
@@ -1154,7 +1167,7 @@ function condensedGateMsg(action, filePath, ordinal) {
return (
`[Fact-Forcing Gate] (denial #${ordinal} this session) First ${action} of ${safe}: ` +
"briefly state importers/callers, affected API, data schemas if any, and the user's verbatim instruction, then retry. " +
'(Use GATEGUARD_EXEMPT_GLOBS for path-scoped exemptions; ECC_GATEGUARD=off disables this gate.)'
'(Use GATEGUARD_EXEMPT_GLOBS for path-scoped exemptions; GATEGUARD_FACT_FORCE_MAX_DENIALS caps denials per session; ECC_GATEGUARD=off disables this gate.)'
);
}
+1 -1
View File
@@ -117,7 +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_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, or any value that is not a whole non-negative integer, 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. |
+97 -2
View File
@@ -2665,7 +2665,9 @@ function runTests() {
);
assert.strictEqual(result.code, 0);
const output = parseOutput(result.stdout);
assert.ok(!output || !output.hookSpecificOutput, 'paths after the budget should pass through');
assert.ok(output, 'should produce valid JSON output');
assert.ok(!output.hookSpecificOutput, 'paths after the budget should pass through');
assert.strictEqual(output.tool_name, 'Edit', 'pass-through should preserve input');
})
)
passed++;
@@ -2681,7 +2683,100 @@ function runTests() {
);
assert.strictEqual(result.code, 0);
const output = parseOutput(result.stdout);
assert.ok(!output || !output.hookSpecificOutput, 'MultiEdit paths after the budget should pass through');
assert.ok(output, 'should produce valid JSON output');
assert.ok(!output.hookSpecificOutput, 'MultiEdit paths after the budget should pass through');
assert.strictEqual(output.tool_name, 'MultiEdit', 'pass-through should preserve input');
})
)
passed++;
else failed++;
clearState();
if (
test('Write allows new paths after the session-wide denial budget', () => {
writeState({ checked: [], last_active: Date.now(), fact_force_denials: 3 });
const result = runHook(
{ tool_name: 'Write', tool_input: { file_path: '/src/after-write-budget.js', content: 'x' } },
{ GATEGUARD_FACT_FORCE_MAX_DENIALS: '3' }
);
assert.strictEqual(result.code, 0);
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce valid JSON output');
assert.ok(!output.hookSpecificOutput, 'Write paths after the budget should pass through');
assert.strictEqual(output.tool_name, 'Write', 'pass-through should preserve input');
})
)
passed++;
else failed++;
clearState();
if (
test('denies at the exact budget and passes through on the next path', () => {
// Budget 3: the 3rd denial is still a denial; the 4th path is allowed.
writeState({ checked: [], last_active: Date.now(), fact_force_denials: 2 });
const atLimit = runHook(
{ tool_name: 'Edit', tool_input: { file_path: '/src/at-budget.js' } },
{ GATEGUARD_FACT_FORCE_MAX_DENIALS: '3' }
);
const atLimitOutput = parseOutput(atLimit.stdout);
assert.ok(atLimitOutput, 'should produce valid JSON output at the limit');
assert.strictEqual(
atLimitOutput.hookSpecificOutput.permissionDecision,
'deny',
'the denial that reaches the budget should still deny'
);
const pastLimit = runHook(
{ tool_name: 'Edit', tool_input: { file_path: '/src/past-budget.js' } },
{ GATEGUARD_FACT_FORCE_MAX_DENIALS: '3' }
);
const pastLimitOutput = parseOutput(pastLimit.stdout);
assert.ok(pastLimitOutput, 'should produce valid JSON output past the limit');
assert.ok(!pastLimitOutput.hookSpecificOutput, 'the next path should pass through');
})
)
passed++;
else failed++;
for (const malformed of ['3.5', '3oops', '0x3', ' 3 oops', '-1', '']) {
clearState();
if (
test(`leaves the gate uncapped for malformed budget ${JSON.stringify(malformed)}`, () => {
writeState({ checked: [], last_active: Date.now(), fact_force_denials: 9 });
const result = runHook(
{ tool_name: 'Edit', tool_input: { file_path: '/src/malformed-budget.js' } },
{ GATEGUARD_FACT_FORCE_MAX_DENIALS: malformed }
);
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce valid JSON output');
assert.strictEqual(
output.hookSpecificOutput.permissionDecision,
'deny',
'a malformed budget must not silently become a finite cap'
);
})
)
passed++;
else failed++;
}
clearState();
if (
test('condensed denial names the session-wide denial cap', () => {
// Budget above the full-block budget so a condensed denial is still reached.
writeState({ checked: [], last_active: Date.now(), fact_force_denials: 5 });
const result = runHook(
{ tool_name: 'Edit', tool_input: { file_path: '/src/condensed-budget.js' } },
{ GATEGUARD_FACT_FORCE_MAX_DENIALS: '10' }
);
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce valid JSON output');
const reason = output.hookSpecificOutput.permissionDecisionReason;
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
assert.ok(
reason.includes('GATEGUARD_FACT_FORCE_MAX_DENIALS'),
'condensed denial should name the session-wide denial cap'
);
})
)
passed++;
+1471 -2190
View File
File diff suppressed because it is too large Load Diff