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', () => {