diff --git a/docs/gateguard/design-notes.md b/docs/gateguard/design-notes.md index 5fa94d024..8fe1e1c76 100644 --- a/docs/gateguard/design-notes.md +++ b/docs/gateguard/design-notes.md @@ -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 diff --git a/scripts/hooks/gateguard-fact-force.js b/scripts/hooks/gateguard-fact-force.js index eb5737450..1804b68e0 100644 --- a/scripts/hooks/gateguard-fact-force.js +++ b/scripts/hooks/gateguard-fact-force.js @@ -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 diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js index 3c18387fe..f4a2246f0 100644 --- a/tests/hooks/gateguard-fact-force.test.js +++ b/tests/hooks/gateguard-fact-force.test.js @@ -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); }