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 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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in new issue
Block a user