From e0252df02f2fb7d431328df25e5f70a6b20d771e Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:26:24 -0400 Subject: [PATCH] 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. --- scripts/hooks/gateguard-fact-force.js | 47 ++++++++++++++++-------- skills/gateguard/SKILL.md | 22 ++++++----- tests/hooks/gateguard-fact-force.test.js | 47 +++++++++++++++++++++++- 3 files changed, 90 insertions(+), 26 deletions(-) diff --git a/scripts/hooks/gateguard-fact-force.js b/scripts/hooks/gateguard-fact-force.js index bf91eb78a..efd6fbf1b 100644 --- a/scripts/hooks/gateguard-fact-force.js +++ b/scripts/hooks/gateguard-fact-force.js @@ -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(); diff --git a/skills/gateguard/SKILL.md b/skills/gateguard/SKILL.md index 2c37994a7..e7ebc5cec 100644 --- a/skills/gateguard/SKILL.md +++ b/skills/gateguard/SKILL.md @@ -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 { diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js index 54a19c0e0..3b6851481 100644 --- a/tests/hooks/gateguard-fact-force.test.js +++ b/tests/hooks/gateguard-fact-force.test.js @@ -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)) {