mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-17 21:15:40 +02:00
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:
co-authored by
Claude Opus 4.8
parent
8b6543929e
commit
0a35a0216b
@@ -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();
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user