diff --git a/CHANGELOG.md b/CHANGELOG.md index 764a58e63..9f1faf53b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/gateguard/design-notes.md b/docs/gateguard/design-notes.md index 840807dcf..8fe1e1c76 100644 --- a/docs/gateguard/design-notes.md +++ b/docs/gateguard/design-notes.md @@ -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 diff --git a/scripts/hooks/gateguard-fact-force.js b/scripts/hooks/gateguard-fact-force.js index 15b2a5da5..1804b68e0 100644 --- a/scripts/hooks/gateguard-fact-force.js +++ b/scripts/hooks/gateguard-fact-force.js @@ -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) ); } diff --git a/scripts/lib/gateguard-target-class.js b/scripts/lib/gateguard-target-class.js index d140bed1c..35a9d1dcf 100644 --- a/scripts/lib/gateguard-target-class.js +++ b/scripts/lib/gateguard-target-class.js @@ -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 }; diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js index f4ec8c0f3..f4a2246f0 100644 --- a/tests/hooks/gateguard-fact-force.test.js +++ b/tests/hooks/gateguard-fact-force.test.js @@ -3946,6 +3946,87 @@ function runTests() { })) passed++; else failed++; + // --- Exempt globs judge the real location too: a symlink cannot carry the exemption out --- + // /real/proj holds README.md, src/app.js and scratch/; /alias -> /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', () => {