diff --git a/scripts/hooks/gateguard-fact-force.js b/scripts/hooks/gateguard-fact-force.js index bfd2b11c6..2b3dbfb8f 100644 --- a/scripts/hooks/gateguard-fact-force.js +++ b/scripts/hooks/gateguard-fact-force.js @@ -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 }); } diff --git a/skills/gateguard/SKILL.md b/skills/gateguard/SKILL.md index e7ebc5cec..39f4af43c 100644 --- a/skills/gateguard/SKILL.md +++ b/skills/gateguard/SKILL.md @@ -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. | diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js index 495928691..b9d6c54b0 100644 --- a/tests/hooks/gateguard-fact-force.test.js +++ b/tests/hooks/gateguard-fact-force.test.js @@ -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)', () => {