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 22:41:11 -05:00 committed by GitHub
parent 8b6543929e
commit 0a35a0216b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 123 additions and 2 deletions

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();