fix(gateguard): lowercase the whole re-rooted exempt glob

realAbsoluteGlobRegex lowercased only the resolved prefix, so an absolute
glob with uppercase text after its first wildcard stopped matching once
its leading directory resolved through a symlink. Normalize the rebuilt
pattern as a whole and cover it with a mixed-case case.

The exempt-link test helper now fails on a nonzero hook exit or invalid
JSON instead of reading it as an exemption, and the design note states the
both-must-match rule directly.

Refs #3655
This commit is contained in:
Gaurav Dubey committed 2026-10-21 03:18:17 +00:00
1 parent 119d4b246a
commit 2c11fd74c1
3 files changed
+15 -8

No files matched your search

+3 -3
View File
@@ -176,9 +176,9 @@ 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 covers the path as written and its real location, not wherever
a symlink points: the target's symlink-resolved path (for a new file, the real
path of its nearest existing ancestor) must also match an exempt glob, judged
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
+1 -1
View File
@@ -240,7 +240,7 @@ function realAbsoluteGlobRegex(glob, isWin) {
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}`);
return exemptGlobRegex(normalizeForMatch(`${realPrefix.replace(/[\\/]$/, '')}/${rest}`));
}
// The path as written and its real location must both match: a symlink inside
+11 -4
View File
@@ -3969,11 +3969,18 @@ function runTests() {
})();
const exemptLinkRun = (tool, toolInput, glob, root = exemptLinkRoot) => {
clearState();
const output = parseOutput(runHook(
const result = runHook(
{ cwd: root, tool_name: tool, tool_input: toolInput },
{ GATEGUARD_EXEMPT_GLOBS: glob, CLAUDE_PROJECT_DIR: root }
).stdout);
return output && output.hookSpecificOutput ? output.hookSpecificOutput.permissionDecision : undefined;
);
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, () => {
@@ -4013,7 +4020,7 @@ function runTests() {
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/**`]) {
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);
}