mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-12 04:37:54 +02:00
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:
@@ -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();
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user