diff --git a/scripts/hooks/gateguard-fact-force.js b/scripts/hooks/gateguard-fact-force.js index 4bc7b0361..627f7fdf9 100644 --- a/scripts/hooks/gateguard-fact-force.js +++ b/scripts/hooks/gateguard-fact-force.js @@ -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(); diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js index 5a8a45b9a..8912eb994 100644 --- a/tests/hooks/gateguard-fact-force.test.js +++ b/tests/hooks/gateguard-fact-force.test.js @@ -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)) {