fix: scope GateGuard exemptions to the project

Address #2921 and complete the segment-anchoring direction in #2979. Preserve explicit absolute exemptions while denying accidental matches in unrelated projects.
This commit is contained in:
haelyra
2026-09-07 16:27:01 -04:00
parent fe3d82e280
commit e0252df02f
3 changed files with 90 additions and 26 deletions
+32 -15
View File
@@ -100,11 +100,12 @@ function getExtraDestructiveRegex() {
}
// Operator-supplied path exemptions. Comma-separated globs (`GATEGUARD_EXEMPT_GLOBS`)
// matched against the normalized (forward-slash, lowercased) file path. First-touch
// matched against the normalized project-relative path (or full path for an
// explicitly absolute glob). 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,
// this / what schema" carries no signal. Memoized on the env value; malformed
// patterns are dropped without granting exemptions. `*` matches within a path segment,
// `**` across segments, `?` a single char.
let exemptCacheKey = null;
let exemptCacheRegexes = null;
@@ -116,16 +117,24 @@ function getExemptMatchers() {
exemptCacheKey = raw;
exemptCacheRegexes = raw
.split(',')
.map(s => s.trim())
.map(s => normalizeForMatch(s.trim()))
.filter(Boolean)
.map(glob => {
const source = glob
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // escape regex metachars, keep * and ?
.split('**') // ** boundaries (cross-segment)
.map(part => part.replace(/\*/g, '[^/]*').replace(/\?/g, '.'))
.join('.*'); // ** -> across segments
let source = '';
for (let index = 0; index < glob.length; index++) {
const char = glob[index];
if (char === '*' && glob[index + 1] === '*') {
index++;
if (glob[index + 1] === '/') {
source += '(?:.*/)?';
index++;
} else source += '.*';
} else if (char === '*') source += '[^/]*';
else if (char === '?') source += '[^/]';
else source += char.replace(/[.+^${}()|[\]\\]/g, '\\$&');
}
try {
return new RegExp(source);
return { regex: new RegExp(`^${source}$`), absolute: path.posix.isAbsolute(glob) || path.win32.isAbsolute(glob) };
} catch (_) {
return null;
}
@@ -134,9 +143,17 @@ function getExemptMatchers() {
return exemptCacheRegexes;
}
function isExemptPath(filePath) {
const norm = normalizeForMatch(filePath);
return getExemptMatchers().some(re => re.test(norm));
function isExemptPath(filePath, data) {
const projectRoot = process.env.CLAUDE_PROJECT_DIR || data.cwd || process.cwd();
if (typeof projectRoot !== 'string' || typeof filePath !== 'string') return false;
const paths = /^[a-z]:[\\/]|^\\\\/i.test(projectRoot) ? path.win32 : path.posix;
if (!paths.isAbsolute(projectRoot)) return false;
const target = paths.resolve(projectRoot, filePath);
const relative = paths.relative(projectRoot, target);
const contained = relative !== '..' && !relative.startsWith(`..${paths.sep}`) && !paths.isAbsolute(relative);
return getExemptMatchers().some(({ regex, absolute }) =>
absolute ? regex.test(normalizeForMatch(target)) : contained && regex.test(normalizeForMatch(relative))
);
}
function isRoutineBashGateDisabled() {
@@ -1206,7 +1223,7 @@ function run(rawInput) {
if (toolName === 'Edit' || toolName === 'Write') {
const filePath = toolInput.file_path || '';
if (!filePath || isClaudeSettingsPath(filePath) || isExemptPath(filePath)) {
if (!filePath || isClaudeSettingsPath(filePath) || isExemptPath(filePath, data)) {
return rawInput; // allow
}
@@ -1239,7 +1256,7 @@ function run(rawInput) {
const edits = toolInput.edits || [];
for (const edit of edits) {
const filePath = edit.file_path || '';
if (filePath && !isClaudeSettingsPath(filePath) && !isExemptPath(filePath) && !isChecked(filePath)) {
if (filePath && !isClaudeSettingsPath(filePath) && !isExemptPath(filePath, data) && !isChecked(filePath)) {
const { ok, denials } = markCheckedAndCountDenial(filePath);
if (!ok) {
return allowWithStateWarning();
+13 -9
View File
@@ -135,16 +135,20 @@ For hook-level control, keep using `ECC_DISABLED_HOOKS` with the GateGuard hook
#### Glob semantics for `GATEGUARD_EXEMPT_GLOBS`
Patterns are matched, unanchored, against the target path with backslashes
normalized to `/` and the whole string lowercased — the path exactly as the
hook receives it, which for Claude Code tool payloads is absolute. `*` matches
within a path segment, `**` across segments, `?` a single character. Matching
is fail-open: a malformed pattern is dropped rather than raising.
Patterns match the entire project-relative target path. The project root is
`CLAUDE_PROJECT_DIR`, falling back to the hook payload's `cwd`, then the hook
process working directory. Relative globs never exempt targets outside that
root. Explicit absolute globs match the entire absolute target path and may
deliberately exempt paths outside the project.
Note that a leading `**/` compiles to `.*/`, so it requires at least one
preceding separator: `**/tests/**` exempts `/repo/tests/foo.js` but would not
match a bare relative `tests/foo.js`. Add the separator-free form too if you
pass relative paths:
Both patterns and paths use `/` separators and lowercase matching. `*` matches
within a segment, `**` across segments, and `?` one non-separator character.
`**/` includes zero directories, so `**/tests/**` also matches `tests/foo.js`.
Malformed patterns are dropped without granting an exemption.
Since 2.2.1, `services/**` only covers the project's root services tree, and
`*.md` only covers its root Markdown files. Use `**/*.md` for all Markdown
files within the project. Existing unanchored exemptions may need adjustment:
```json
{
+45 -2
View File
@@ -2788,7 +2788,7 @@ function runTests() {
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/**' });
const result = runHook(input, { GATEGUARD_EXEMPT_GLOBS: '**/tests/**', CLAUDE_PROJECT_DIR: '/proj' });
assert.strictEqual(result.code, 0, 'exit code should be 0');
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce valid JSON output');
@@ -2824,7 +2824,7 @@ function runTests() {
clearState();
const exempt = runHook(
{ tool_name: 'Write', tool_input: { file_path: '/tmp/x/scratchpad/s.js', content: 'x' } },
{ GATEGUARD_EXEMPT_GLOBS: globs }
{ GATEGUARD_EXEMPT_GLOBS: globs, CLAUDE_PROJECT_DIR: '/tmp/x' }
);
const exemptOut = parseOutput(exempt.stdout);
assert.ok(exemptOut, 'should produce JSON output');
@@ -2860,6 +2860,49 @@ function runTests() {
passed++;
else failed++;
for (const { glob, filePath, cwd = '/proj', exempt } of [
{ glob: 'services/**', filePath: '/proj/services/api.js', exempt: true },
{ glob: 'services/**', filePath: '/other/services/api.js', exempt: false },
{ glob: 'services/**', filePath: '/proj/vendor/services/api.js', exempt: false },
{ glob: 'services/**', filePath: '/proj/my-services/api.js', exempt: false },
{ glob: '*.md', filePath: '/proj/notes.md/outline.txt', exempt: false },
{ glob: '*.md', filePath: '/proj/docs/notes.md', exempt: false },
{ glob: '*.md', filePath: '/other/notes.md', exempt: false },
{ glob: 'README.md', filePath: '/proj/readme.md', exempt: true },
{ glob: '*.md', filePath: './notes.md', exempt: true },
{ glob: '**/*.md', filePath: '/proj/docs/notes.md', exempt: true },
{ glob: '**/*.md', filePath: '/proj/notes.md', exempt: true },
{ glob: '**/*.md', filePath: '../other/notes.md', exempt: false },
{ glob: 'docs/?otes.md', filePath: '/proj/docs/notes.md', exempt: true },
{ glob: 'docs?notes.md', filePath: '/proj/docs/notes.md', exempt: false },
{ glob: 'services/**', filePath: 'C:\\proj\\services\\api.js', cwd: 'C:\\proj', exempt: true },
{ glob: 'services/**', filePath: 'C:\\other\\services\\api.js', cwd: 'C:\\proj', exempt: false },
{ glob: '/approved/docs/**', filePath: '/approved/docs/notes.md', exempt: true },
]) {
clearState();
if (test(`scopes exempt glob ${glob} for ${filePath}`, () => {
const result = runHook(
{ cwd, tool_name: 'Edit', tool_input: { file_path: filePath } },
{ GATEGUARD_EXEMPT_GLOBS: glob, CLAUDE_PROJECT_DIR: cwd }
);
const output = parseOutput(result.stdout);
assert.strictEqual(output?.hookSpecificOutput?.permissionDecision === 'deny', !exempt);
})) passed++;
else failed++;
}
clearState();
if (test('MultiEdit gates outside-project targets even when another target is exempt', () => {
const result = runHook({
cwd: '/proj', tool_name: 'MultiEdit',
tool_input: { edits: [{ file_path: '/proj/docs/a.md' }, { file_path: '/other/docs/b.md' }] }
}, { GATEGUARD_EXEMPT_GLOBS: 'docs/**', CLAUDE_PROJECT_DIR: '/proj' });
const output = parseOutput(result.stdout);
assert.strictEqual(output?.hookSpecificOutput?.permissionDecision, 'deny');
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('/other/docs/b.md'));
})) passed++;
else failed++;
// Cleanup only the temp directory created by this test file.
try {
if (fs.existsSync(stateDir)) {