fix(gateguard): judge exempt globs against real target location

Port #3656 onto current main, preserving both the contributor fix and the unrelated current changelog entry. Includes the follow-up glob normalization from 2c11fd74.
This commit is contained in:
Gaurav Dubey authored and haelyra committed 2026-10-25 17:47:30 -04:00
1 parent e3292b0010
commit dcb947a4e1
5 files changed
+174 -26

No files matched your search

+1
View File
@@ -27,6 +27,7 @@
- GateGuard prior-search credit no longer counts a search that never saw the target. File-type filters (`rg -t`/`-T`/`--type`/`--type-not`, `fd -e`/`--extension`, the Grep tool's `type`), the Glob tool's pattern, shell glob operands, and git pathspecs now filter like include globs, so `rg -t py tokenizer src` or `Glob **/tokenizer.py` no longer credits `tokenizer.js`. A search confined to the target's own file name (`Glob **/tokenizer.js`, `find -name tokenizer.js`, Grep `glob: 'tokenizer.js'`) is a lookup and never credits it. PowerShell searches keep backslashes as path separators. - GateGuard prior-search credit no longer counts a search that never saw the target. File-type filters (`rg -t`/`-T`/`--type`/`--type-not`, `fd -e`/`--extension`, the Grep tool's `type`), the Glob tool's pattern, shell glob operands, and git pathspecs now filter like include globs, so `rg -t py tokenizer src` or `Glob **/tokenizer.py` no longer credits `tokenizer.js`. A search confined to the target's own file name (`Glob **/tokenizer.js`, `find -name tokenizer.js`, Grep `glob: 'tokenizer.js'`) is a lookup and never credits it. PowerShell searches keep backslashes as path separators.
- GateGuard no longer passes an edit as comment-only when its text contains U+2028, U+2029, or U+0085, which end a JavaScript or C# line comment. - GateGuard no longer passes an edit as comment-only when its text contains U+2028, U+2029, or U+0085, which end a JavaScript or C# line comment.
- Claude Code home installs (`ecc install --target claude` and `install.sh`) now install under `CLAUDE_CONFIG_DIR` when it is set, matching the directory Claude Code reads its user-level config from. Previously ECC wrote to `~/.claude` regardless, so when `CLAUDE_CONFIG_DIR` redirected the config root the install never loaded ("installs, but nothing loads"); the settings.json, plugin.json, hook commands, bundle and install state were all stranded in `~/.claude` while Claude Code read an empty `CLAUDE_CONFIG_DIR`. The claude-home adapter now resolves its root the same way the runtime does (`CLAUDE_CONFIG_DIR || ~/.claude`), consistent with the opencode adapter and `resolveClaudePaths`. - Claude Code home installs (`ecc install --target claude` and `install.sh`) now install under `CLAUDE_CONFIG_DIR` when it is set, matching the directory Claude Code reads its user-level config from. Previously ECC wrote to `~/.claude` regardless, so when `CLAUDE_CONFIG_DIR` redirected the config root the install never loaded ("installs, but nothing loads"); the settings.json, plugin.json, hook commands, bundle and install state were all stranded in `~/.claude` while Claude Code read an empty `CLAUDE_CONFIG_DIR`. The claude-home adapter now resolves its root the same way the runtime does (`CLAUDE_CONFIG_DIR || ~/.claude`), consistent with the opencode adapter and `resolveClaudePaths`.
- `GATEGUARD_EXEMPT_GLOBS` judges the target's real location as well as the path as written, so a symlink inside an exempt tree no longer exempts the first touch of a file it points to outside the globs (#3655).
## 2.2.3 - 2026-10-01 ## 2.2.3 - 2026-10-01
+11
View File
@@ -176,6 +176,17 @@ for existing configurations. The precedence is documented in the skill's
order of checks, and the table row warns that exemptions apply to sensitive order of checks, and the table row warns that exemptions apply to sensitive
and hard-linked targets too. and hard-linked targets too.
An exemption requires both the path as written and its symlink-resolved
location (for a new file, the real path of its nearest existing ancestor) to
match an exempt glob. The resolved location is judged
against the project root's real path for relative globs and, for absolute
globs, against the glob with its literal leading directories resolved (`/tmp`
is `/private/tmp` on macOS). Otherwise a link in an exempt scratch directory
would exempt any project file it names. A real location that cannot be
resolved grants no exemption; Windows-style paths on a POSIX host keep the
lexical match, as for the other real-path checks. Hard links are not path
aliases and are still covered by the precedence above.
## NotebookEdit ## NotebookEdit
NotebookEdit writes a `.ipynb` file named by `tool_input.notebook_path` (not NotebookEdit writes a `.ipynb` file named by `tool_input.notebook_path` (not
+63 -25
View File
@@ -179,6 +179,27 @@ function getExtraDestructiveRegex() {
// `**` across segments, `?` a single char. // `**` across segments, `?` a single char.
let exemptCacheKey = null; let exemptCacheKey = null;
let exemptCacheRegexes = null; let exemptCacheRegexes = null;
function exemptGlobRegex(glob) {
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}$`);
} catch (_) {
return null;
}
}
function getExemptMatchers() { function getExemptMatchers() {
const raw = process.env.GATEGUARD_EXEMPT_GLOBS || ''; const raw = process.env.GATEGUARD_EXEMPT_GLOBS || '';
if (raw === exemptCacheKey) { if (raw === exemptCacheKey) {
@@ -187,42 +208,59 @@ function getExemptMatchers() {
exemptCacheKey = raw; exemptCacheKey = raw;
exemptCacheRegexes = raw exemptCacheRegexes = raw
.split(',') .split(',')
.map(s => normalizeForMatch(s.trim())) .map(s => s.trim().replace(/\\/g, '/'))
.filter(Boolean) .filter(Boolean)
.map(glob => { .map(written => {
let source = ''; const glob = normalizeForMatch(written);
for (let index = 0; index < glob.length; index++) { const regex = exemptGlobRegex(glob);
const char = glob[index]; return regex ? { written, regex, absolute: path.posix.isAbsolute(glob) || path.win32.isAbsolute(glob) } : null;
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 { regex: new RegExp(`^${source}$`), absolute: path.posix.isAbsolute(glob) || path.win32.isAbsolute(glob) };
} catch (_) {
return null;
}
}) })
.filter(Boolean); .filter(Boolean);
return exemptCacheRegexes; return exemptCacheRegexes;
} }
function matchesExemptGlob(target, projectRoot, paths, absoluteRegexFor = matcher => matcher.regex) {
const relative = paths.relative(projectRoot, target);
const contained = relative !== '..' && !relative.startsWith(`..${paths.sep}`) && !paths.isAbsolute(relative);
return getExemptMatchers().some(matcher => {
if (!matcher.absolute) return contained && matcher.regex.test(normalizeForMatch(relative));
const regex = absoluteRegexFor(matcher);
return Boolean(regex) && regex.test(normalizeForMatch(target));
});
}
// An absolute glob as it reads once its literal leading directories are
// symlink-resolved (`/tmp/...` is `/private/tmp/...` on macOS). Null when that
// prefix has no real location.
function realAbsoluteGlobRegex(glob, isWin) {
const segments = glob.split('/');
const wild = segments.findIndex(segment => /[*?]/.test(segment));
const literal = wild === -1 ? segments.length - 1 : wild;
const prefix = segments.slice(0, literal).join('/') || '/';
const realPrefix = targetClass().realLocationOf(isWin ? prefix.replace(/\//g, '\\') : prefix, isWin);
if (!realPrefix) return null;
const rest = segments.slice(literal).join('/');
return exemptGlobRegex(normalizeForMatch(`${realPrefix.replace(/[\\/]$/, '')}/${rest}`));
}
// The path as written and its real location must both match: a symlink inside
// an exempt tree does not exempt its target. See docs/gateguard/design-notes.md#exempt-globs
function isExemptPath(filePath, data) { function isExemptPath(filePath, data) {
const projectRoot = process.env.CLAUDE_PROJECT_DIR || data.cwd || process.cwd(); const projectRoot = process.env.CLAUDE_PROJECT_DIR || data.cwd || process.cwd();
if (typeof projectRoot !== 'string' || typeof filePath !== 'string') return false; if (typeof projectRoot !== 'string' || typeof filePath !== 'string') return false;
const paths = targetClass().WINDOWS_PATH_PATTERN.test(projectRoot) ? path.win32 : path.posix; const isWin = targetClass().WINDOWS_PATH_PATTERN.test(projectRoot);
const paths = isWin ? path.win32 : path.posix;
if (!paths.isAbsolute(projectRoot)) return false; if (!paths.isAbsolute(projectRoot)) return false;
const target = paths.resolve(projectRoot, filePath); const target = paths.resolve(projectRoot, filePath);
const relative = paths.relative(projectRoot, target); if (!matchesExemptGlob(target, projectRoot, paths)) return false;
const contained = relative !== '..' && !relative.startsWith(`..${paths.sep}`) && !paths.isAbsolute(relative); // A Windows-style path has no real location on a POSIX host (and vice versa).
return getExemptMatchers().some(({ regex, absolute }) => if (isWin !== (process.platform === 'win32')) return true;
absolute ? regex.test(normalizeForMatch(target)) : contained && regex.test(normalizeForMatch(relative)) const realTarget = targetClass().realLocationOf(target, isWin);
if (!realTarget) return false;
if (realTarget === target) return true;
const realRoot = targetClass().realLocationOf(projectRoot, isWin) || projectRoot;
return matchesExemptGlob(realTarget, realRoot, paths, matcher =>
matcher.regex.test(normalizeForMatch(realTarget)) ? matcher.regex : realAbsoluteGlobRegex(matcher.written, isWin)
); );
} }
+18 -1
View File
@@ -398,6 +398,22 @@ function realTargetPath(resolved, paths) {
return realDir ? paths.join(realDir, paths.basename(resolved)) : null; return realDir ? paths.join(realDir, paths.basename(resolved)) : null;
} }
/**
* Real (symlink-resolved) location of an absolute host-style path; a missing
* file resolves through its nearest existing ancestor. Null when the path is
* not host-style or its real location cannot be determined.
*/
function realLocationOf(nativePath, isWin) {
try {
if (typeof nativePath !== 'string' || !isHostPathStyle(isWin)) return null;
const paths = isWin ? path.win32 : path.posix;
if (!paths.isAbsolute(nativePath)) return null;
return realTargetPath(paths.resolve(nativePath), paths);
} catch (_) {
return null;
}
}
function isMissingEntry(nativePath) { function isMissingEntry(nativePath) {
try { try {
fs.lstatSync(nativePath); fs.lstatSync(nativePath);
@@ -453,5 +469,6 @@ module.exports = {
collapseGateDir, collapseGateDir,
isSensitiveTarget, isSensitiveTarget,
isSensitiveTargetFor, isSensitiveTargetFor,
isHardLinkedTargetFor isHardLinkedTargetFor,
realLocationOf
}; };
+81
View File
@@ -3946,6 +3946,87 @@ function runTests() {
})) passed++; })) passed++;
else failed++; else failed++;
// --- Exempt globs judge the real location too: a symlink cannot carry the exemption out ---
// <base>/real/proj holds README.md, src/app.js and scratch/; <base>/alias -> <base>/real.
const exemptLinkBase = fs.realpathSync(fs.mkdtempSync(path.join(tmpRoot, 'gateguard-exempt-link-')));
const exemptLinkRoot = path.join(exemptLinkBase, 'real', 'proj');
const exemptLinksReady = (() => {
fs.mkdirSync(path.join(exemptLinkRoot, 'scratch'), { recursive: true });
fs.mkdirSync(path.join(exemptLinkRoot, 'src'), { recursive: true });
fs.writeFileSync(path.join(exemptLinkRoot, 'README.md'), 'x');
fs.writeFileSync(path.join(exemptLinkRoot, 'src', 'app.js'), 'x');
fs.writeFileSync(path.join(exemptLinkRoot, 'scratch', 'notes.md'), 'x');
try {
fs.symlinkSync(path.join(exemptLinkRoot, 'README.md'), path.join(exemptLinkRoot, 'scratch', 'link.md'));
fs.symlinkSync(path.join(exemptLinkRoot, 'scratch', 'notes.md'), path.join(exemptLinkRoot, 'scratch', 'notes-link.md'));
fs.symlinkSync(path.join(exemptLinkRoot, 'src'), path.join(exemptLinkRoot, 'scratch', 'src-alias'), 'dir');
fs.symlinkSync(path.join(exemptLinkBase, 'real'), path.join(exemptLinkBase, 'alias'), 'dir');
return true;
} catch (e) {
if (process.platform === 'win32' && e.code === 'EPERM') return false; // no symlink privilege
throw e;
}
})();
const exemptLinkRun = (tool, toolInput, glob, root = exemptLinkRoot) => {
clearState();
const result = runHook(
{ cwd: root, tool_name: tool, tool_input: toolInput },
{ GATEGUARD_EXEMPT_GLOBS: glob, CLAUDE_PROJECT_DIR: root }
);
assert.strictEqual(result.code, 0, result.stderr);
const output = parseOutput(result.stdout);
assert.ok(output && typeof output === 'object', `hook output must be JSON: ${result.stdout}`);
if (!output.hookSpecificOutput) {
assert.strictEqual(output.tool_name, tool, 'a pass-through must echo the tool input');
return undefined;
}
return output.hookSpecificOutput.permissionDecision;
};
const exemptLinkCase = (name, fn) => {
if (test(name, () => {
if (exemptLinksReady) fn();
})) passed++;
else failed++;
};
[
{ kind: 'relative', glob: 'scratch/**' },
{ kind: 'absolute', glob: `${exemptLinkRoot.replace(/\\/g, '/')}/scratch/**` }
].forEach(({ kind, glob }) => {
exemptLinkCase(`a symlink in an exempt dir does not exempt its target outside the glob (${kind} glob)`, () => {
const linked = path.join(exemptLinkRoot, 'scratch', 'link.md');
assert.strictEqual(exemptLinkRun('Edit', { file_path: linked, old_string: 'a', new_string: 'b' }, glob), 'deny');
assert.strictEqual(exemptLinkRun('Edit', { file_path: path.join(exemptLinkRoot, 'scratch', 'notes.md'), old_string: 'a', new_string: 'b' }, glob), undefined, 'control: a plain exempt file still passes');
});
});
exemptLinkCase('a symlink that stays inside the exempt dir keeps the exemption', () => {
const linked = path.join(exemptLinkRoot, 'scratch', 'notes-link.md');
assert.strictEqual(exemptLinkRun('Edit', { file_path: linked, old_string: 'a', new_string: 'b' }, 'scratch/**'), undefined);
});
exemptLinkCase('a new file through a symlinked directory in an exempt dir is not exempt', () => {
const created = path.join(exemptLinkRoot, 'scratch', 'src-alias', 'new_module.js');
assert.strictEqual(exemptLinkRun('Write', { file_path: created, content: 'x' }, 'scratch/**'), 'deny');
});
exemptLinkCase('MultiEdit gates a symlinked entry even when its path matches an exempt glob', () => {
const decision = exemptLinkRun('MultiEdit', {
edits: [{ file_path: path.join(exemptLinkRoot, 'scratch', 'notes.md') }, { file_path: path.join(exemptLinkRoot, 'scratch', 'link.md') }]
}, 'scratch/**');
assert.strictEqual(decision, 'deny');
});
exemptLinkCase('a project reached through a symlinked parent keeps relative and absolute exemptions', () => {
const aliasRoot = path.join(exemptLinkBase, 'alias', 'proj');
const plain = path.join(aliasRoot, 'scratch', 'notes.md');
for (const glob of ['scratch/**', `${aliasRoot.replace(/\\/g, '/')}/scratch/**`, `${exemptLinkBase.replace(/\\/g, '/')}/alias/*/scratch/**`, `${exemptLinkBase.replace(/\\/g, '/')}/alias/*/SCRATCH/**`]) {
assert.strictEqual(exemptLinkRun('Edit', { file_path: plain, old_string: 'a', new_string: 'b' }, glob, aliasRoot), undefined, glob);
assert.strictEqual(exemptLinkRun('Edit', { file_path: path.join(aliasRoot, 'scratch', 'link.md'), old_string: 'a', new_string: 'b' }, glob, aliasRoot), 'deny', glob);
}
});
fs.rmSync(exemptLinkBase, { recursive: true, force: true });
// --- PowerShell tool consumer contract --- // --- PowerShell tool consumer contract ---
if ( if (
test('normalizes PowerShell tool-name casing before destructive classification', () => { test('normalizes PowerShell tool-name casing before destructive classification', () => {