feat(gateguard): add GATEGUARD_EXEMPT_GLOBS path exemptions (#2432)

The fact-forcing gate fires once per first-touched file per session. In
build-heavy sessions this costs a deny->retry round-trip on every new file,
including trees where the gate's questions ("who imports this? what schema?")
carry no signal: test files, generated artifacts, scratch dirs.

Add an opt-in, comma-separated glob allowlist read from GATEGUARD_EXEMPT_GLOBS.
A matching Edit/Write/MultiEdit target skips the first-touch gate; destructive-
Bash and routine-Bash gates are untouched. Default-off (unset => identical prior
behavior), fail-open (a malformed glob is dropped, never throws), and memoized on
the env value, matching the existing getExtraDestructiveRegex idiom.

  "env": { "GATEGUARD_EXEMPT_GLOBS": "**/tests/**,**/scratchpad/**" }

Adds 4 tests; all 144 gateguard tests pass.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alec Moldovan
2026-07-03 20:41:11 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 8b6543929e
commit 0a35a0216b
2 changed files with 123 additions and 2 deletions
+43 -2
View File
@@ -94,6 +94,47 @@ function getExtraDestructiveRegex() {
return extraDestructiveCacheRegex;
}
// Operator-supplied path exemptions. Comma-separated globs (`GATEGUARD_EXEMPT_GLOBS`)
// matched against the normalized (forward-slash, lowercased) file path. First-touch
// fact-forcing is skipped for a matching Edit/Write/MultiEdit target — intended for
// low-import-value trees (tests, generated artifacts, scratch dirs) where "who imports
// this / what schema" carries no signal. Memoized on the env value; fail-open (a
// malformed pattern is dropped, never throws). `*` matches within a path segment,
// `**` across segments, `?` a single char.
let exemptCacheKey = null;
let exemptCacheRegexes = null;
function getExemptMatchers() {
const raw = process.env.GATEGUARD_EXEMPT_GLOBS || '';
if (raw === exemptCacheKey) {
return exemptCacheRegexes;
}
exemptCacheKey = raw;
exemptCacheRegexes = raw
.split(',')
.map(s => s.trim())
.filter(Boolean)
.map(glob => {
const source = glob
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // escape regex metachars, keep * and ?
.replace(/\*\*/g, '\x00') // ** placeholder (cross-segment)
.replace(/\*/g, '[^/]*') // * -> within a segment
.replace(/\x00/g, '.*') // ** -> across segments
.replace(/\?/g, '.');
try {
return new RegExp(source);
} catch (_) {
return null;
}
})
.filter(Boolean);
return exemptCacheRegexes;
}
function isExemptPath(filePath) {
const norm = normalizeForMatch(filePath);
return getExemptMatchers().some(re => re.test(norm));
}
function isRoutineBashGateDisabled() {
return ECC_ENABLE_VALUES.has(normalizeEnvValue(process.env.GATEGUARD_BASH_ROUTINE_DISABLED));
}
@@ -1151,7 +1192,7 @@ function run(rawInput) {
if (toolName === 'Edit' || toolName === 'Write') {
const filePath = toolInput.file_path || '';
if (!filePath || isClaudeSettingsPath(filePath)) {
if (!filePath || isClaudeSettingsPath(filePath) || isExemptPath(filePath)) {
return rawInput; // allow
}
@@ -1182,7 +1223,7 @@ function run(rawInput) {
const edits = toolInput.edits || [];
for (const edit of edits) {
const filePath = edit.file_path || '';
if (filePath && !isClaudeSettingsPath(filePath) && !isChecked(filePath)) {
if (filePath && !isClaudeSettingsPath(filePath) && !isExemptPath(filePath) && !isChecked(filePath)) {
const { ok, denials } = markCheckedAndCountDenial(filePath);
if (!ok) {
return allowWithStateWarning();
+80
View File
@@ -2376,6 +2376,86 @@ function runTests() {
passed++;
else failed++;
// --- Exempt globs: GATEGUARD_EXEMPT_GLOBS skips first-touch fact-forcing ---
clearState();
if (
test('exempts an Edit whose path matches GATEGUARD_EXEMPT_GLOBS', () => {
const input = {
tool_name: 'Edit',
tool_input: { file_path: '/proj/tests/test_x.js', old_string: 'a', new_string: 'b' }
};
const result = runHook(input, { GATEGUARD_EXEMPT_GLOBS: '**/tests/**' });
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', 'exempt path must not be denied');
} else {
assert.strictEqual(output.tool_name, 'Edit', 'pass-through should preserve input');
}
})
)
passed++;
else failed++;
clearState();
if (
test('does NOT exempt a non-matching path under the same globs', () => {
const input = {
tool_name: 'Edit',
tool_input: { file_path: '/proj/src/core/x.js', old_string: 'a', new_string: 'b' }
};
const result = runHook(input, { GATEGUARD_EXEMPT_GLOBS: '**/tests/**' });
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce JSON output');
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny', 'non-matching path still gated');
})
)
passed++;
else failed++;
if (
test('supports multiple comma-separated exempt globs (Write + non-match)', () => {
const globs = '**/tests/**,**/scratchpad/**';
clearState();
const exempt = runHook(
{ tool_name: 'Write', tool_input: { file_path: '/tmp/x/scratchpad/s.js', content: 'x' } },
{ GATEGUARD_EXEMPT_GLOBS: globs }
);
const exemptOut = parseOutput(exempt.stdout);
assert.ok(exemptOut, 'should produce JSON output');
if (exemptOut.hookSpecificOutput) {
assert.notStrictEqual(exemptOut.hookSpecificOutput.permissionDecision, 'deny', 'scratchpad path exempt');
}
clearState();
const gated = runHook(
{ tool_name: 'Write', tool_input: { file_path: '/proj/src/s.js', content: 'x' } },
{ GATEGUARD_EXEMPT_GLOBS: globs }
);
const gatedOut = parseOutput(gated.stdout);
assert.ok(gatedOut, 'should produce JSON output');
assert.strictEqual(gatedOut.hookSpecificOutput.permissionDecision, 'deny', 'src path still gated');
})
)
passed++;
else failed++;
clearState();
if (
test('is default-off: unset GATEGUARD_EXEMPT_GLOBS gates tests/ as before', () => {
const input = {
tool_name: 'Edit',
tool_input: { file_path: '/proj/tests/test_x.js', old_string: 'a', new_string: 'b' }
};
const result = runHook(input, { GATEGUARD_EXEMPT_GLOBS: '' });
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce JSON output');
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny', 'no exemptions when unset');
})
)
passed++;
else failed++;
// Cleanup only the temp directory created by this test file.
try {
if (fs.existsSync(stateDir)) {