mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-26 18:35:05 +00:00
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:
1 parent
e3292b0010
commit
dcb947a4e1
5 files changed
+174
-26
No files matched your search
@@ -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 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`.
|
||||
- `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
|
||||
|
||||
|
||||
@@ -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
|
||||
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 writes a `.ipynb` file named by `tool_input.notebook_path` (not
|
||||
|
||||
@@ -179,6 +179,27 @@ function getExtraDestructiveRegex() {
|
||||
// `**` across segments, `?` a single char.
|
||||
let exemptCacheKey = 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() {
|
||||
const raw = process.env.GATEGUARD_EXEMPT_GLOBS || '';
|
||||
if (raw === exemptCacheKey) {
|
||||
@@ -187,42 +208,59 @@ function getExemptMatchers() {
|
||||
exemptCacheKey = raw;
|
||||
exemptCacheRegexes = raw
|
||||
.split(',')
|
||||
.map(s => normalizeForMatch(s.trim()))
|
||||
.map(s => s.trim().replace(/\\/g, '/'))
|
||||
.filter(Boolean)
|
||||
.map(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 { regex: new RegExp(`^${source}$`), absolute: path.posix.isAbsolute(glob) || path.win32.isAbsolute(glob) };
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
.map(written => {
|
||||
const glob = normalizeForMatch(written);
|
||||
const regex = exemptGlobRegex(glob);
|
||||
return regex ? { written, regex, absolute: path.posix.isAbsolute(glob) || path.win32.isAbsolute(glob) } : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
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) {
|
||||
const projectRoot = process.env.CLAUDE_PROJECT_DIR || data.cwd || process.cwd();
|
||||
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;
|
||||
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))
|
||||
if (!matchesExemptGlob(target, projectRoot, paths)) return false;
|
||||
// A Windows-style path has no real location on a POSIX host (and vice versa).
|
||||
if (isWin !== (process.platform === 'win32')) return true;
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -398,6 +398,22 @@ function realTargetPath(resolved, paths) {
|
||||
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) {
|
||||
try {
|
||||
fs.lstatSync(nativePath);
|
||||
@@ -453,5 +469,6 @@ module.exports = {
|
||||
collapseGateDir,
|
||||
isSensitiveTarget,
|
||||
isSensitiveTargetFor,
|
||||
isHardLinkedTargetFor
|
||||
isHardLinkedTargetFor,
|
||||
realLocationOf
|
||||
};
|
||||
@@ -3946,6 +3946,87 @@ function runTests() {
|
||||
})) passed++;
|
||||
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 ---
|
||||
if (
|
||||
test('normalizes PowerShell tool-name casing before destructive classification', () => {
|
||||
|
||||
Reference in new issue
Block a user