diff --git a/.agents/skills/impeccable/scripts/hook-before-edit.mjs b/.agents/skills/impeccable/scripts/hook-before-edit.mjs index 54e789e8b..1dcde6ef3 100644 --- a/.agents/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.agents/skills/impeccable/scripts/hook-before-edit.mjs @@ -23,6 +23,7 @@ import { designSystemOptions, filterFindings, isNativePlatform, + isScanTargetInsideProject, loadDetector, matchConfiguredExtension, matchesAnyGlob, @@ -161,7 +162,7 @@ function replaceOnce(original, oldString, newString) { } function readExistingProjectFile(filePath, cwd) { - if (!isInsideProject(filePath, cwd)) return null; + if (!isScanTargetInsideProject(filePath, cwd)) return null; if (SENSITIVE_PATH.test(filePath) || GENERATED_PATH.test(filePath)) return null; try { const stat = fs.statSync(filePath); @@ -232,7 +233,7 @@ function shellCopiedFileContent(command, cwd) { const source = shellCopyPaths(command)?.source; if (!source) return ''; const sourcePath = path.isAbsolute(source) ? source : path.resolve(cwd, source); - if (!isInsideProject(sourcePath, cwd)) return ''; + if (!isScanTargetInsideProject(sourcePath, cwd)) return ''; if (SENSITIVE_PATH.test(sourcePath) || GENERATED_PATH.test(sourcePath)) return ''; try { const stat = fs.statSync(sourcePath); @@ -328,15 +329,6 @@ function relativePath(filePath, cwd) { } } -function isInsideProject(filePath, cwd) { - try { - const rel = path.relative(cwd, filePath); - return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); - } catch { - return false; - } -} - // The static HTML engine reads its input from disk, but preToolUse only has // the proposed content. Stage it in a temp file so html-engine targets get the // same DOM-structural rules pre-write that runHook applies post-edit. @@ -414,7 +406,7 @@ async function main() { }; if (!filePath) return allow({ ...audit, skipped: 'no-file-path', durationMs: Date.now() - started }); - if (!isInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started }); + if (!isScanTargetInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started }); if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started }); if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started }); diff --git a/.agents/skills/impeccable/scripts/hook-lib.mjs b/.agents/skills/impeccable/scripts/hook-lib.mjs index 65931f9e6..b874985a6 100644 --- a/.agents/skills/impeccable/scripts/hook-lib.mjs +++ b/.agents/skills/impeccable/scripts/hook-lib.mjs @@ -1335,6 +1335,51 @@ function isInsideProject(filePath, projectCwd) { } } +// Resolve a path to its canonical (symlink-free) form. When the path does +// not exist yet — the before-edit hook gates proposed Writes — canonicalize +// the nearest existing ancestor and re-append the remainder, so a new file +// under a symlinked root still compares equal to its canonical project. +// Memoized: the hook runs as a fresh process per tool event, so the cache +// amounts to once-per-event work — the scan loops re-check the same project +// root for every target file. The cap only matters to long-lived importers +// like the test runner. +const canonicalPathCache = new Map(); +const CANONICAL_PATH_CACHE_MAX = 1024; + +function canonicalPath(p) { + const resolved = path.resolve(p); + if (canonicalPathCache.has(resolved)) return canonicalPathCache.get(resolved); + let canonical = resolved; + let dir = resolved; + const tail = []; + while (true) { + try { + canonical = tail.length ? path.join(fs.realpathSync(dir), ...tail) : fs.realpathSync(dir); + break; + } catch { /* keep climbing */ } + const parent = path.dirname(dir); + if (parent === dir) break; + tail.unshift(path.basename(dir)); + dir = parent; + } + if (canonicalPathCache.size >= CANONICAL_PATH_CACHE_MAX) canonicalPathCache.clear(); + canonicalPathCache.set(resolved, canonical); + return canonical; +} + +// Containment gate shared by the before-edit hook and both scan passes. A +// session routinely touches files that belong to no project or to a +// different one — harness scratchpad dirs under the system temp root, +// sibling checkouts, one-off throwaway HTML — and findings against those are +// judged with THIS project's config and DESIGN.md palette, which is never +// right. Skip them (audit reason: outside-project). Paths are canonicalized +// first so a symlinked root (macOS /tmp -> /private/tmp) doesn't split the +// comparison. +export function isScanTargetInsideProject(filePath, projectCwd) { + if (!filePath || !projectCwd) return false; + return isInsideProject(canonicalPath(filePath), canonicalPath(projectCwd)); +} + export function parseStaticStyleImports(content, fromFile, projectCwd) { if (!content || typeof content !== 'string') return []; const dir = path.dirname(fromFile); @@ -1693,6 +1738,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = lastSkip = 'file-missing'; continue; } + if (!isScanTargetInsideProject(filePath, projectCwd)) { + lastSkip = 'outside-project'; + continue; + } const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes; if (maxFileBytes > 0) { @@ -2023,6 +2072,10 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no const relForMatch = relativize(filePath, projectCwd); if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue; if (!fs.existsSync(filePath)) continue; + // Caches written before this gate existed can still hold out-of-project + // paths, so the Stop pass re-checks containment rather than trusting + // the per-edit pass to have filtered them. + if (!isScanTargetInsideProject(filePath, projectCwd)) continue; scanned += 1; let content = ''; diff --git a/.claude/skills/impeccable/scripts/hook-before-edit.mjs b/.claude/skills/impeccable/scripts/hook-before-edit.mjs index 54e789e8b..1dcde6ef3 100644 --- a/.claude/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.claude/skills/impeccable/scripts/hook-before-edit.mjs @@ -23,6 +23,7 @@ import { designSystemOptions, filterFindings, isNativePlatform, + isScanTargetInsideProject, loadDetector, matchConfiguredExtension, matchesAnyGlob, @@ -161,7 +162,7 @@ function replaceOnce(original, oldString, newString) { } function readExistingProjectFile(filePath, cwd) { - if (!isInsideProject(filePath, cwd)) return null; + if (!isScanTargetInsideProject(filePath, cwd)) return null; if (SENSITIVE_PATH.test(filePath) || GENERATED_PATH.test(filePath)) return null; try { const stat = fs.statSync(filePath); @@ -232,7 +233,7 @@ function shellCopiedFileContent(command, cwd) { const source = shellCopyPaths(command)?.source; if (!source) return ''; const sourcePath = path.isAbsolute(source) ? source : path.resolve(cwd, source); - if (!isInsideProject(sourcePath, cwd)) return ''; + if (!isScanTargetInsideProject(sourcePath, cwd)) return ''; if (SENSITIVE_PATH.test(sourcePath) || GENERATED_PATH.test(sourcePath)) return ''; try { const stat = fs.statSync(sourcePath); @@ -328,15 +329,6 @@ function relativePath(filePath, cwd) { } } -function isInsideProject(filePath, cwd) { - try { - const rel = path.relative(cwd, filePath); - return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); - } catch { - return false; - } -} - // The static HTML engine reads its input from disk, but preToolUse only has // the proposed content. Stage it in a temp file so html-engine targets get the // same DOM-structural rules pre-write that runHook applies post-edit. @@ -414,7 +406,7 @@ async function main() { }; if (!filePath) return allow({ ...audit, skipped: 'no-file-path', durationMs: Date.now() - started }); - if (!isInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started }); + if (!isScanTargetInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started }); if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started }); if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started }); diff --git a/.claude/skills/impeccable/scripts/hook-lib.mjs b/.claude/skills/impeccable/scripts/hook-lib.mjs index 65931f9e6..b874985a6 100644 --- a/.claude/skills/impeccable/scripts/hook-lib.mjs +++ b/.claude/skills/impeccable/scripts/hook-lib.mjs @@ -1335,6 +1335,51 @@ function isInsideProject(filePath, projectCwd) { } } +// Resolve a path to its canonical (symlink-free) form. When the path does +// not exist yet — the before-edit hook gates proposed Writes — canonicalize +// the nearest existing ancestor and re-append the remainder, so a new file +// under a symlinked root still compares equal to its canonical project. +// Memoized: the hook runs as a fresh process per tool event, so the cache +// amounts to once-per-event work — the scan loops re-check the same project +// root for every target file. The cap only matters to long-lived importers +// like the test runner. +const canonicalPathCache = new Map(); +const CANONICAL_PATH_CACHE_MAX = 1024; + +function canonicalPath(p) { + const resolved = path.resolve(p); + if (canonicalPathCache.has(resolved)) return canonicalPathCache.get(resolved); + let canonical = resolved; + let dir = resolved; + const tail = []; + while (true) { + try { + canonical = tail.length ? path.join(fs.realpathSync(dir), ...tail) : fs.realpathSync(dir); + break; + } catch { /* keep climbing */ } + const parent = path.dirname(dir); + if (parent === dir) break; + tail.unshift(path.basename(dir)); + dir = parent; + } + if (canonicalPathCache.size >= CANONICAL_PATH_CACHE_MAX) canonicalPathCache.clear(); + canonicalPathCache.set(resolved, canonical); + return canonical; +} + +// Containment gate shared by the before-edit hook and both scan passes. A +// session routinely touches files that belong to no project or to a +// different one — harness scratchpad dirs under the system temp root, +// sibling checkouts, one-off throwaway HTML — and findings against those are +// judged with THIS project's config and DESIGN.md palette, which is never +// right. Skip them (audit reason: outside-project). Paths are canonicalized +// first so a symlinked root (macOS /tmp -> /private/tmp) doesn't split the +// comparison. +export function isScanTargetInsideProject(filePath, projectCwd) { + if (!filePath || !projectCwd) return false; + return isInsideProject(canonicalPath(filePath), canonicalPath(projectCwd)); +} + export function parseStaticStyleImports(content, fromFile, projectCwd) { if (!content || typeof content !== 'string') return []; const dir = path.dirname(fromFile); @@ -1693,6 +1738,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = lastSkip = 'file-missing'; continue; } + if (!isScanTargetInsideProject(filePath, projectCwd)) { + lastSkip = 'outside-project'; + continue; + } const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes; if (maxFileBytes > 0) { @@ -2023,6 +2072,10 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no const relForMatch = relativize(filePath, projectCwd); if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue; if (!fs.existsSync(filePath)) continue; + // Caches written before this gate existed can still hold out-of-project + // paths, so the Stop pass re-checks containment rather than trusting + // the per-edit pass to have filtered them. + if (!isScanTargetInsideProject(filePath, projectCwd)) continue; scanned += 1; let content = ''; diff --git a/.cursor/skills/impeccable/scripts/hook-before-edit.mjs b/.cursor/skills/impeccable/scripts/hook-before-edit.mjs index 54e789e8b..1dcde6ef3 100644 --- a/.cursor/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.cursor/skills/impeccable/scripts/hook-before-edit.mjs @@ -23,6 +23,7 @@ import { designSystemOptions, filterFindings, isNativePlatform, + isScanTargetInsideProject, loadDetector, matchConfiguredExtension, matchesAnyGlob, @@ -161,7 +162,7 @@ function replaceOnce(original, oldString, newString) { } function readExistingProjectFile(filePath, cwd) { - if (!isInsideProject(filePath, cwd)) return null; + if (!isScanTargetInsideProject(filePath, cwd)) return null; if (SENSITIVE_PATH.test(filePath) || GENERATED_PATH.test(filePath)) return null; try { const stat = fs.statSync(filePath); @@ -232,7 +233,7 @@ function shellCopiedFileContent(command, cwd) { const source = shellCopyPaths(command)?.source; if (!source) return ''; const sourcePath = path.isAbsolute(source) ? source : path.resolve(cwd, source); - if (!isInsideProject(sourcePath, cwd)) return ''; + if (!isScanTargetInsideProject(sourcePath, cwd)) return ''; if (SENSITIVE_PATH.test(sourcePath) || GENERATED_PATH.test(sourcePath)) return ''; try { const stat = fs.statSync(sourcePath); @@ -328,15 +329,6 @@ function relativePath(filePath, cwd) { } } -function isInsideProject(filePath, cwd) { - try { - const rel = path.relative(cwd, filePath); - return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); - } catch { - return false; - } -} - // The static HTML engine reads its input from disk, but preToolUse only has // the proposed content. Stage it in a temp file so html-engine targets get the // same DOM-structural rules pre-write that runHook applies post-edit. @@ -414,7 +406,7 @@ async function main() { }; if (!filePath) return allow({ ...audit, skipped: 'no-file-path', durationMs: Date.now() - started }); - if (!isInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started }); + if (!isScanTargetInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started }); if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started }); if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started }); diff --git a/.cursor/skills/impeccable/scripts/hook-lib.mjs b/.cursor/skills/impeccable/scripts/hook-lib.mjs index 65931f9e6..b874985a6 100644 --- a/.cursor/skills/impeccable/scripts/hook-lib.mjs +++ b/.cursor/skills/impeccable/scripts/hook-lib.mjs @@ -1335,6 +1335,51 @@ function isInsideProject(filePath, projectCwd) { } } +// Resolve a path to its canonical (symlink-free) form. When the path does +// not exist yet — the before-edit hook gates proposed Writes — canonicalize +// the nearest existing ancestor and re-append the remainder, so a new file +// under a symlinked root still compares equal to its canonical project. +// Memoized: the hook runs as a fresh process per tool event, so the cache +// amounts to once-per-event work — the scan loops re-check the same project +// root for every target file. The cap only matters to long-lived importers +// like the test runner. +const canonicalPathCache = new Map(); +const CANONICAL_PATH_CACHE_MAX = 1024; + +function canonicalPath(p) { + const resolved = path.resolve(p); + if (canonicalPathCache.has(resolved)) return canonicalPathCache.get(resolved); + let canonical = resolved; + let dir = resolved; + const tail = []; + while (true) { + try { + canonical = tail.length ? path.join(fs.realpathSync(dir), ...tail) : fs.realpathSync(dir); + break; + } catch { /* keep climbing */ } + const parent = path.dirname(dir); + if (parent === dir) break; + tail.unshift(path.basename(dir)); + dir = parent; + } + if (canonicalPathCache.size >= CANONICAL_PATH_CACHE_MAX) canonicalPathCache.clear(); + canonicalPathCache.set(resolved, canonical); + return canonical; +} + +// Containment gate shared by the before-edit hook and both scan passes. A +// session routinely touches files that belong to no project or to a +// different one — harness scratchpad dirs under the system temp root, +// sibling checkouts, one-off throwaway HTML — and findings against those are +// judged with THIS project's config and DESIGN.md palette, which is never +// right. Skip them (audit reason: outside-project). Paths are canonicalized +// first so a symlinked root (macOS /tmp -> /private/tmp) doesn't split the +// comparison. +export function isScanTargetInsideProject(filePath, projectCwd) { + if (!filePath || !projectCwd) return false; + return isInsideProject(canonicalPath(filePath), canonicalPath(projectCwd)); +} + export function parseStaticStyleImports(content, fromFile, projectCwd) { if (!content || typeof content !== 'string') return []; const dir = path.dirname(fromFile); @@ -1693,6 +1738,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = lastSkip = 'file-missing'; continue; } + if (!isScanTargetInsideProject(filePath, projectCwd)) { + lastSkip = 'outside-project'; + continue; + } const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes; if (maxFileBytes > 0) { @@ -2023,6 +2072,10 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no const relForMatch = relativize(filePath, projectCwd); if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue; if (!fs.existsSync(filePath)) continue; + // Caches written before this gate existed can still hold out-of-project + // paths, so the Stop pass re-checks containment rather than trusting + // the per-edit pass to have filtered them. + if (!isScanTargetInsideProject(filePath, projectCwd)) continue; scanned += 1; let content = ''; diff --git a/.gemini/skills/impeccable/scripts/hook-before-edit.mjs b/.gemini/skills/impeccable/scripts/hook-before-edit.mjs index 54e789e8b..1dcde6ef3 100644 --- a/.gemini/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.gemini/skills/impeccable/scripts/hook-before-edit.mjs @@ -23,6 +23,7 @@ import { designSystemOptions, filterFindings, isNativePlatform, + isScanTargetInsideProject, loadDetector, matchConfiguredExtension, matchesAnyGlob, @@ -161,7 +162,7 @@ function replaceOnce(original, oldString, newString) { } function readExistingProjectFile(filePath, cwd) { - if (!isInsideProject(filePath, cwd)) return null; + if (!isScanTargetInsideProject(filePath, cwd)) return null; if (SENSITIVE_PATH.test(filePath) || GENERATED_PATH.test(filePath)) return null; try { const stat = fs.statSync(filePath); @@ -232,7 +233,7 @@ function shellCopiedFileContent(command, cwd) { const source = shellCopyPaths(command)?.source; if (!source) return ''; const sourcePath = path.isAbsolute(source) ? source : path.resolve(cwd, source); - if (!isInsideProject(sourcePath, cwd)) return ''; + if (!isScanTargetInsideProject(sourcePath, cwd)) return ''; if (SENSITIVE_PATH.test(sourcePath) || GENERATED_PATH.test(sourcePath)) return ''; try { const stat = fs.statSync(sourcePath); @@ -328,15 +329,6 @@ function relativePath(filePath, cwd) { } } -function isInsideProject(filePath, cwd) { - try { - const rel = path.relative(cwd, filePath); - return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); - } catch { - return false; - } -} - // The static HTML engine reads its input from disk, but preToolUse only has // the proposed content. Stage it in a temp file so html-engine targets get the // same DOM-structural rules pre-write that runHook applies post-edit. @@ -414,7 +406,7 @@ async function main() { }; if (!filePath) return allow({ ...audit, skipped: 'no-file-path', durationMs: Date.now() - started }); - if (!isInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started }); + if (!isScanTargetInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started }); if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started }); if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started }); diff --git a/.gemini/skills/impeccable/scripts/hook-lib.mjs b/.gemini/skills/impeccable/scripts/hook-lib.mjs index 65931f9e6..b874985a6 100644 --- a/.gemini/skills/impeccable/scripts/hook-lib.mjs +++ b/.gemini/skills/impeccable/scripts/hook-lib.mjs @@ -1335,6 +1335,51 @@ function isInsideProject(filePath, projectCwd) { } } +// Resolve a path to its canonical (symlink-free) form. When the path does +// not exist yet — the before-edit hook gates proposed Writes — canonicalize +// the nearest existing ancestor and re-append the remainder, so a new file +// under a symlinked root still compares equal to its canonical project. +// Memoized: the hook runs as a fresh process per tool event, so the cache +// amounts to once-per-event work — the scan loops re-check the same project +// root for every target file. The cap only matters to long-lived importers +// like the test runner. +const canonicalPathCache = new Map(); +const CANONICAL_PATH_CACHE_MAX = 1024; + +function canonicalPath(p) { + const resolved = path.resolve(p); + if (canonicalPathCache.has(resolved)) return canonicalPathCache.get(resolved); + let canonical = resolved; + let dir = resolved; + const tail = []; + while (true) { + try { + canonical = tail.length ? path.join(fs.realpathSync(dir), ...tail) : fs.realpathSync(dir); + break; + } catch { /* keep climbing */ } + const parent = path.dirname(dir); + if (parent === dir) break; + tail.unshift(path.basename(dir)); + dir = parent; + } + if (canonicalPathCache.size >= CANONICAL_PATH_CACHE_MAX) canonicalPathCache.clear(); + canonicalPathCache.set(resolved, canonical); + return canonical; +} + +// Containment gate shared by the before-edit hook and both scan passes. A +// session routinely touches files that belong to no project or to a +// different one — harness scratchpad dirs under the system temp root, +// sibling checkouts, one-off throwaway HTML — and findings against those are +// judged with THIS project's config and DESIGN.md palette, which is never +// right. Skip them (audit reason: outside-project). Paths are canonicalized +// first so a symlinked root (macOS /tmp -> /private/tmp) doesn't split the +// comparison. +export function isScanTargetInsideProject(filePath, projectCwd) { + if (!filePath || !projectCwd) return false; + return isInsideProject(canonicalPath(filePath), canonicalPath(projectCwd)); +} + export function parseStaticStyleImports(content, fromFile, projectCwd) { if (!content || typeof content !== 'string') return []; const dir = path.dirname(fromFile); @@ -1693,6 +1738,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = lastSkip = 'file-missing'; continue; } + if (!isScanTargetInsideProject(filePath, projectCwd)) { + lastSkip = 'outside-project'; + continue; + } const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes; if (maxFileBytes > 0) { @@ -2023,6 +2072,10 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no const relForMatch = relativize(filePath, projectCwd); if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue; if (!fs.existsSync(filePath)) continue; + // Caches written before this gate existed can still hold out-of-project + // paths, so the Stop pass re-checks containment rather than trusting + // the per-edit pass to have filtered them. + if (!isScanTargetInsideProject(filePath, projectCwd)) continue; scanned += 1; let content = ''; diff --git a/.github/skills/impeccable/scripts/hook-before-edit.mjs b/.github/skills/impeccable/scripts/hook-before-edit.mjs index 54e789e8b..1dcde6ef3 100644 --- a/.github/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.github/skills/impeccable/scripts/hook-before-edit.mjs @@ -23,6 +23,7 @@ import { designSystemOptions, filterFindings, isNativePlatform, + isScanTargetInsideProject, loadDetector, matchConfiguredExtension, matchesAnyGlob, @@ -161,7 +162,7 @@ function replaceOnce(original, oldString, newString) { } function readExistingProjectFile(filePath, cwd) { - if (!isInsideProject(filePath, cwd)) return null; + if (!isScanTargetInsideProject(filePath, cwd)) return null; if (SENSITIVE_PATH.test(filePath) || GENERATED_PATH.test(filePath)) return null; try { const stat = fs.statSync(filePath); @@ -232,7 +233,7 @@ function shellCopiedFileContent(command, cwd) { const source = shellCopyPaths(command)?.source; if (!source) return ''; const sourcePath = path.isAbsolute(source) ? source : path.resolve(cwd, source); - if (!isInsideProject(sourcePath, cwd)) return ''; + if (!isScanTargetInsideProject(sourcePath, cwd)) return ''; if (SENSITIVE_PATH.test(sourcePath) || GENERATED_PATH.test(sourcePath)) return ''; try { const stat = fs.statSync(sourcePath); @@ -328,15 +329,6 @@ function relativePath(filePath, cwd) { } } -function isInsideProject(filePath, cwd) { - try { - const rel = path.relative(cwd, filePath); - return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); - } catch { - return false; - } -} - // The static HTML engine reads its input from disk, but preToolUse only has // the proposed content. Stage it in a temp file so html-engine targets get the // same DOM-structural rules pre-write that runHook applies post-edit. @@ -414,7 +406,7 @@ async function main() { }; if (!filePath) return allow({ ...audit, skipped: 'no-file-path', durationMs: Date.now() - started }); - if (!isInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started }); + if (!isScanTargetInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started }); if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started }); if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started }); diff --git a/.github/skills/impeccable/scripts/hook-lib.mjs b/.github/skills/impeccable/scripts/hook-lib.mjs index 65931f9e6..b874985a6 100644 --- a/.github/skills/impeccable/scripts/hook-lib.mjs +++ b/.github/skills/impeccable/scripts/hook-lib.mjs @@ -1335,6 +1335,51 @@ function isInsideProject(filePath, projectCwd) { } } +// Resolve a path to its canonical (symlink-free) form. When the path does +// not exist yet — the before-edit hook gates proposed Writes — canonicalize +// the nearest existing ancestor and re-append the remainder, so a new file +// under a symlinked root still compares equal to its canonical project. +// Memoized: the hook runs as a fresh process per tool event, so the cache +// amounts to once-per-event work — the scan loops re-check the same project +// root for every target file. The cap only matters to long-lived importers +// like the test runner. +const canonicalPathCache = new Map(); +const CANONICAL_PATH_CACHE_MAX = 1024; + +function canonicalPath(p) { + const resolved = path.resolve(p); + if (canonicalPathCache.has(resolved)) return canonicalPathCache.get(resolved); + let canonical = resolved; + let dir = resolved; + const tail = []; + while (true) { + try { + canonical = tail.length ? path.join(fs.realpathSync(dir), ...tail) : fs.realpathSync(dir); + break; + } catch { /* keep climbing */ } + const parent = path.dirname(dir); + if (parent === dir) break; + tail.unshift(path.basename(dir)); + dir = parent; + } + if (canonicalPathCache.size >= CANONICAL_PATH_CACHE_MAX) canonicalPathCache.clear(); + canonicalPathCache.set(resolved, canonical); + return canonical; +} + +// Containment gate shared by the before-edit hook and both scan passes. A +// session routinely touches files that belong to no project or to a +// different one — harness scratchpad dirs under the system temp root, +// sibling checkouts, one-off throwaway HTML — and findings against those are +// judged with THIS project's config and DESIGN.md palette, which is never +// right. Skip them (audit reason: outside-project). Paths are canonicalized +// first so a symlinked root (macOS /tmp -> /private/tmp) doesn't split the +// comparison. +export function isScanTargetInsideProject(filePath, projectCwd) { + if (!filePath || !projectCwd) return false; + return isInsideProject(canonicalPath(filePath), canonicalPath(projectCwd)); +} + export function parseStaticStyleImports(content, fromFile, projectCwd) { if (!content || typeof content !== 'string') return []; const dir = path.dirname(fromFile); @@ -1693,6 +1738,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = lastSkip = 'file-missing'; continue; } + if (!isScanTargetInsideProject(filePath, projectCwd)) { + lastSkip = 'outside-project'; + continue; + } const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes; if (maxFileBytes > 0) { @@ -2023,6 +2072,10 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no const relForMatch = relativize(filePath, projectCwd); if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue; if (!fs.existsSync(filePath)) continue; + // Caches written before this gate existed can still hold out-of-project + // paths, so the Stop pass re-checks containment rather than trusting + // the per-edit pass to have filtered them. + if (!isScanTargetInsideProject(filePath, projectCwd)) continue; scanned += 1; let content = ''; diff --git a/.grok/skills/impeccable/scripts/hook-before-edit.mjs b/.grok/skills/impeccable/scripts/hook-before-edit.mjs index 54e789e8b..1dcde6ef3 100644 --- a/.grok/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.grok/skills/impeccable/scripts/hook-before-edit.mjs @@ -23,6 +23,7 @@ import { designSystemOptions, filterFindings, isNativePlatform, + isScanTargetInsideProject, loadDetector, matchConfiguredExtension, matchesAnyGlob, @@ -161,7 +162,7 @@ function replaceOnce(original, oldString, newString) { } function readExistingProjectFile(filePath, cwd) { - if (!isInsideProject(filePath, cwd)) return null; + if (!isScanTargetInsideProject(filePath, cwd)) return null; if (SENSITIVE_PATH.test(filePath) || GENERATED_PATH.test(filePath)) return null; try { const stat = fs.statSync(filePath); @@ -232,7 +233,7 @@ function shellCopiedFileContent(command, cwd) { const source = shellCopyPaths(command)?.source; if (!source) return ''; const sourcePath = path.isAbsolute(source) ? source : path.resolve(cwd, source); - if (!isInsideProject(sourcePath, cwd)) return ''; + if (!isScanTargetInsideProject(sourcePath, cwd)) return ''; if (SENSITIVE_PATH.test(sourcePath) || GENERATED_PATH.test(sourcePath)) return ''; try { const stat = fs.statSync(sourcePath); @@ -328,15 +329,6 @@ function relativePath(filePath, cwd) { } } -function isInsideProject(filePath, cwd) { - try { - const rel = path.relative(cwd, filePath); - return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); - } catch { - return false; - } -} - // The static HTML engine reads its input from disk, but preToolUse only has // the proposed content. Stage it in a temp file so html-engine targets get the // same DOM-structural rules pre-write that runHook applies post-edit. @@ -414,7 +406,7 @@ async function main() { }; if (!filePath) return allow({ ...audit, skipped: 'no-file-path', durationMs: Date.now() - started }); - if (!isInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started }); + if (!isScanTargetInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started }); if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started }); if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started }); diff --git a/.grok/skills/impeccable/scripts/hook-lib.mjs b/.grok/skills/impeccable/scripts/hook-lib.mjs index 65931f9e6..b874985a6 100644 --- a/.grok/skills/impeccable/scripts/hook-lib.mjs +++ b/.grok/skills/impeccable/scripts/hook-lib.mjs @@ -1335,6 +1335,51 @@ function isInsideProject(filePath, projectCwd) { } } +// Resolve a path to its canonical (symlink-free) form. When the path does +// not exist yet — the before-edit hook gates proposed Writes — canonicalize +// the nearest existing ancestor and re-append the remainder, so a new file +// under a symlinked root still compares equal to its canonical project. +// Memoized: the hook runs as a fresh process per tool event, so the cache +// amounts to once-per-event work — the scan loops re-check the same project +// root for every target file. The cap only matters to long-lived importers +// like the test runner. +const canonicalPathCache = new Map(); +const CANONICAL_PATH_CACHE_MAX = 1024; + +function canonicalPath(p) { + const resolved = path.resolve(p); + if (canonicalPathCache.has(resolved)) return canonicalPathCache.get(resolved); + let canonical = resolved; + let dir = resolved; + const tail = []; + while (true) { + try { + canonical = tail.length ? path.join(fs.realpathSync(dir), ...tail) : fs.realpathSync(dir); + break; + } catch { /* keep climbing */ } + const parent = path.dirname(dir); + if (parent === dir) break; + tail.unshift(path.basename(dir)); + dir = parent; + } + if (canonicalPathCache.size >= CANONICAL_PATH_CACHE_MAX) canonicalPathCache.clear(); + canonicalPathCache.set(resolved, canonical); + return canonical; +} + +// Containment gate shared by the before-edit hook and both scan passes. A +// session routinely touches files that belong to no project or to a +// different one — harness scratchpad dirs under the system temp root, +// sibling checkouts, one-off throwaway HTML — and findings against those are +// judged with THIS project's config and DESIGN.md palette, which is never +// right. Skip them (audit reason: outside-project). Paths are canonicalized +// first so a symlinked root (macOS /tmp -> /private/tmp) doesn't split the +// comparison. +export function isScanTargetInsideProject(filePath, projectCwd) { + if (!filePath || !projectCwd) return false; + return isInsideProject(canonicalPath(filePath), canonicalPath(projectCwd)); +} + export function parseStaticStyleImports(content, fromFile, projectCwd) { if (!content || typeof content !== 'string') return []; const dir = path.dirname(fromFile); @@ -1693,6 +1738,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = lastSkip = 'file-missing'; continue; } + if (!isScanTargetInsideProject(filePath, projectCwd)) { + lastSkip = 'outside-project'; + continue; + } const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes; if (maxFileBytes > 0) { @@ -2023,6 +2072,10 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no const relForMatch = relativize(filePath, projectCwd); if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue; if (!fs.existsSync(filePath)) continue; + // Caches written before this gate existed can still hold out-of-project + // paths, so the Stop pass re-checks containment rather than trusting + // the per-edit pass to have filtered them. + if (!isScanTargetInsideProject(filePath, projectCwd)) continue; scanned += 1; let content = ''; diff --git a/.kiro/skills/impeccable/scripts/hook-before-edit.mjs b/.kiro/skills/impeccable/scripts/hook-before-edit.mjs index 54e789e8b..1dcde6ef3 100644 --- a/.kiro/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.kiro/skills/impeccable/scripts/hook-before-edit.mjs @@ -23,6 +23,7 @@ import { designSystemOptions, filterFindings, isNativePlatform, + isScanTargetInsideProject, loadDetector, matchConfiguredExtension, matchesAnyGlob, @@ -161,7 +162,7 @@ function replaceOnce(original, oldString, newString) { } function readExistingProjectFile(filePath, cwd) { - if (!isInsideProject(filePath, cwd)) return null; + if (!isScanTargetInsideProject(filePath, cwd)) return null; if (SENSITIVE_PATH.test(filePath) || GENERATED_PATH.test(filePath)) return null; try { const stat = fs.statSync(filePath); @@ -232,7 +233,7 @@ function shellCopiedFileContent(command, cwd) { const source = shellCopyPaths(command)?.source; if (!source) return ''; const sourcePath = path.isAbsolute(source) ? source : path.resolve(cwd, source); - if (!isInsideProject(sourcePath, cwd)) return ''; + if (!isScanTargetInsideProject(sourcePath, cwd)) return ''; if (SENSITIVE_PATH.test(sourcePath) || GENERATED_PATH.test(sourcePath)) return ''; try { const stat = fs.statSync(sourcePath); @@ -328,15 +329,6 @@ function relativePath(filePath, cwd) { } } -function isInsideProject(filePath, cwd) { - try { - const rel = path.relative(cwd, filePath); - return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); - } catch { - return false; - } -} - // The static HTML engine reads its input from disk, but preToolUse only has // the proposed content. Stage it in a temp file so html-engine targets get the // same DOM-structural rules pre-write that runHook applies post-edit. @@ -414,7 +406,7 @@ async function main() { }; if (!filePath) return allow({ ...audit, skipped: 'no-file-path', durationMs: Date.now() - started }); - if (!isInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started }); + if (!isScanTargetInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started }); if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started }); if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started }); diff --git a/.kiro/skills/impeccable/scripts/hook-lib.mjs b/.kiro/skills/impeccable/scripts/hook-lib.mjs index 65931f9e6..b874985a6 100644 --- a/.kiro/skills/impeccable/scripts/hook-lib.mjs +++ b/.kiro/skills/impeccable/scripts/hook-lib.mjs @@ -1335,6 +1335,51 @@ function isInsideProject(filePath, projectCwd) { } } +// Resolve a path to its canonical (symlink-free) form. When the path does +// not exist yet — the before-edit hook gates proposed Writes — canonicalize +// the nearest existing ancestor and re-append the remainder, so a new file +// under a symlinked root still compares equal to its canonical project. +// Memoized: the hook runs as a fresh process per tool event, so the cache +// amounts to once-per-event work — the scan loops re-check the same project +// root for every target file. The cap only matters to long-lived importers +// like the test runner. +const canonicalPathCache = new Map(); +const CANONICAL_PATH_CACHE_MAX = 1024; + +function canonicalPath(p) { + const resolved = path.resolve(p); + if (canonicalPathCache.has(resolved)) return canonicalPathCache.get(resolved); + let canonical = resolved; + let dir = resolved; + const tail = []; + while (true) { + try { + canonical = tail.length ? path.join(fs.realpathSync(dir), ...tail) : fs.realpathSync(dir); + break; + } catch { /* keep climbing */ } + const parent = path.dirname(dir); + if (parent === dir) break; + tail.unshift(path.basename(dir)); + dir = parent; + } + if (canonicalPathCache.size >= CANONICAL_PATH_CACHE_MAX) canonicalPathCache.clear(); + canonicalPathCache.set(resolved, canonical); + return canonical; +} + +// Containment gate shared by the before-edit hook and both scan passes. A +// session routinely touches files that belong to no project or to a +// different one — harness scratchpad dirs under the system temp root, +// sibling checkouts, one-off throwaway HTML — and findings against those are +// judged with THIS project's config and DESIGN.md palette, which is never +// right. Skip them (audit reason: outside-project). Paths are canonicalized +// first so a symlinked root (macOS /tmp -> /private/tmp) doesn't split the +// comparison. +export function isScanTargetInsideProject(filePath, projectCwd) { + if (!filePath || !projectCwd) return false; + return isInsideProject(canonicalPath(filePath), canonicalPath(projectCwd)); +} + export function parseStaticStyleImports(content, fromFile, projectCwd) { if (!content || typeof content !== 'string') return []; const dir = path.dirname(fromFile); @@ -1693,6 +1738,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = lastSkip = 'file-missing'; continue; } + if (!isScanTargetInsideProject(filePath, projectCwd)) { + lastSkip = 'outside-project'; + continue; + } const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes; if (maxFileBytes > 0) { @@ -2023,6 +2072,10 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no const relForMatch = relativize(filePath, projectCwd); if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue; if (!fs.existsSync(filePath)) continue; + // Caches written before this gate existed can still hold out-of-project + // paths, so the Stop pass re-checks containment rather than trusting + // the per-edit pass to have filtered them. + if (!isScanTargetInsideProject(filePath, projectCwd)) continue; scanned += 1; let content = ''; diff --git a/.opencode/skills/impeccable/scripts/hook-before-edit.mjs b/.opencode/skills/impeccable/scripts/hook-before-edit.mjs index 54e789e8b..1dcde6ef3 100644 --- a/.opencode/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.opencode/skills/impeccable/scripts/hook-before-edit.mjs @@ -23,6 +23,7 @@ import { designSystemOptions, filterFindings, isNativePlatform, + isScanTargetInsideProject, loadDetector, matchConfiguredExtension, matchesAnyGlob, @@ -161,7 +162,7 @@ function replaceOnce(original, oldString, newString) { } function readExistingProjectFile(filePath, cwd) { - if (!isInsideProject(filePath, cwd)) return null; + if (!isScanTargetInsideProject(filePath, cwd)) return null; if (SENSITIVE_PATH.test(filePath) || GENERATED_PATH.test(filePath)) return null; try { const stat = fs.statSync(filePath); @@ -232,7 +233,7 @@ function shellCopiedFileContent(command, cwd) { const source = shellCopyPaths(command)?.source; if (!source) return ''; const sourcePath = path.isAbsolute(source) ? source : path.resolve(cwd, source); - if (!isInsideProject(sourcePath, cwd)) return ''; + if (!isScanTargetInsideProject(sourcePath, cwd)) return ''; if (SENSITIVE_PATH.test(sourcePath) || GENERATED_PATH.test(sourcePath)) return ''; try { const stat = fs.statSync(sourcePath); @@ -328,15 +329,6 @@ function relativePath(filePath, cwd) { } } -function isInsideProject(filePath, cwd) { - try { - const rel = path.relative(cwd, filePath); - return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); - } catch { - return false; - } -} - // The static HTML engine reads its input from disk, but preToolUse only has // the proposed content. Stage it in a temp file so html-engine targets get the // same DOM-structural rules pre-write that runHook applies post-edit. @@ -414,7 +406,7 @@ async function main() { }; if (!filePath) return allow({ ...audit, skipped: 'no-file-path', durationMs: Date.now() - started }); - if (!isInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started }); + if (!isScanTargetInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started }); if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started }); if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started }); diff --git a/.opencode/skills/impeccable/scripts/hook-lib.mjs b/.opencode/skills/impeccable/scripts/hook-lib.mjs index 65931f9e6..b874985a6 100644 --- a/.opencode/skills/impeccable/scripts/hook-lib.mjs +++ b/.opencode/skills/impeccable/scripts/hook-lib.mjs @@ -1335,6 +1335,51 @@ function isInsideProject(filePath, projectCwd) { } } +// Resolve a path to its canonical (symlink-free) form. When the path does +// not exist yet — the before-edit hook gates proposed Writes — canonicalize +// the nearest existing ancestor and re-append the remainder, so a new file +// under a symlinked root still compares equal to its canonical project. +// Memoized: the hook runs as a fresh process per tool event, so the cache +// amounts to once-per-event work — the scan loops re-check the same project +// root for every target file. The cap only matters to long-lived importers +// like the test runner. +const canonicalPathCache = new Map(); +const CANONICAL_PATH_CACHE_MAX = 1024; + +function canonicalPath(p) { + const resolved = path.resolve(p); + if (canonicalPathCache.has(resolved)) return canonicalPathCache.get(resolved); + let canonical = resolved; + let dir = resolved; + const tail = []; + while (true) { + try { + canonical = tail.length ? path.join(fs.realpathSync(dir), ...tail) : fs.realpathSync(dir); + break; + } catch { /* keep climbing */ } + const parent = path.dirname(dir); + if (parent === dir) break; + tail.unshift(path.basename(dir)); + dir = parent; + } + if (canonicalPathCache.size >= CANONICAL_PATH_CACHE_MAX) canonicalPathCache.clear(); + canonicalPathCache.set(resolved, canonical); + return canonical; +} + +// Containment gate shared by the before-edit hook and both scan passes. A +// session routinely touches files that belong to no project or to a +// different one — harness scratchpad dirs under the system temp root, +// sibling checkouts, one-off throwaway HTML — and findings against those are +// judged with THIS project's config and DESIGN.md palette, which is never +// right. Skip them (audit reason: outside-project). Paths are canonicalized +// first so a symlinked root (macOS /tmp -> /private/tmp) doesn't split the +// comparison. +export function isScanTargetInsideProject(filePath, projectCwd) { + if (!filePath || !projectCwd) return false; + return isInsideProject(canonicalPath(filePath), canonicalPath(projectCwd)); +} + export function parseStaticStyleImports(content, fromFile, projectCwd) { if (!content || typeof content !== 'string') return []; const dir = path.dirname(fromFile); @@ -1693,6 +1738,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = lastSkip = 'file-missing'; continue; } + if (!isScanTargetInsideProject(filePath, projectCwd)) { + lastSkip = 'outside-project'; + continue; + } const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes; if (maxFileBytes > 0) { @@ -2023,6 +2072,10 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no const relForMatch = relativize(filePath, projectCwd); if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue; if (!fs.existsSync(filePath)) continue; + // Caches written before this gate existed can still hold out-of-project + // paths, so the Stop pass re-checks containment rather than trusting + // the per-edit pass to have filtered them. + if (!isScanTargetInsideProject(filePath, projectCwd)) continue; scanned += 1; let content = ''; diff --git a/.pi/skills/impeccable/scripts/hook-before-edit.mjs b/.pi/skills/impeccable/scripts/hook-before-edit.mjs index 54e789e8b..1dcde6ef3 100644 --- a/.pi/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.pi/skills/impeccable/scripts/hook-before-edit.mjs @@ -23,6 +23,7 @@ import { designSystemOptions, filterFindings, isNativePlatform, + isScanTargetInsideProject, loadDetector, matchConfiguredExtension, matchesAnyGlob, @@ -161,7 +162,7 @@ function replaceOnce(original, oldString, newString) { } function readExistingProjectFile(filePath, cwd) { - if (!isInsideProject(filePath, cwd)) return null; + if (!isScanTargetInsideProject(filePath, cwd)) return null; if (SENSITIVE_PATH.test(filePath) || GENERATED_PATH.test(filePath)) return null; try { const stat = fs.statSync(filePath); @@ -232,7 +233,7 @@ function shellCopiedFileContent(command, cwd) { const source = shellCopyPaths(command)?.source; if (!source) return ''; const sourcePath = path.isAbsolute(source) ? source : path.resolve(cwd, source); - if (!isInsideProject(sourcePath, cwd)) return ''; + if (!isScanTargetInsideProject(sourcePath, cwd)) return ''; if (SENSITIVE_PATH.test(sourcePath) || GENERATED_PATH.test(sourcePath)) return ''; try { const stat = fs.statSync(sourcePath); @@ -328,15 +329,6 @@ function relativePath(filePath, cwd) { } } -function isInsideProject(filePath, cwd) { - try { - const rel = path.relative(cwd, filePath); - return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); - } catch { - return false; - } -} - // The static HTML engine reads its input from disk, but preToolUse only has // the proposed content. Stage it in a temp file so html-engine targets get the // same DOM-structural rules pre-write that runHook applies post-edit. @@ -414,7 +406,7 @@ async function main() { }; if (!filePath) return allow({ ...audit, skipped: 'no-file-path', durationMs: Date.now() - started }); - if (!isInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started }); + if (!isScanTargetInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started }); if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started }); if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started }); diff --git a/.pi/skills/impeccable/scripts/hook-lib.mjs b/.pi/skills/impeccable/scripts/hook-lib.mjs index 65931f9e6..b874985a6 100644 --- a/.pi/skills/impeccable/scripts/hook-lib.mjs +++ b/.pi/skills/impeccable/scripts/hook-lib.mjs @@ -1335,6 +1335,51 @@ function isInsideProject(filePath, projectCwd) { } } +// Resolve a path to its canonical (symlink-free) form. When the path does +// not exist yet — the before-edit hook gates proposed Writes — canonicalize +// the nearest existing ancestor and re-append the remainder, so a new file +// under a symlinked root still compares equal to its canonical project. +// Memoized: the hook runs as a fresh process per tool event, so the cache +// amounts to once-per-event work — the scan loops re-check the same project +// root for every target file. The cap only matters to long-lived importers +// like the test runner. +const canonicalPathCache = new Map(); +const CANONICAL_PATH_CACHE_MAX = 1024; + +function canonicalPath(p) { + const resolved = path.resolve(p); + if (canonicalPathCache.has(resolved)) return canonicalPathCache.get(resolved); + let canonical = resolved; + let dir = resolved; + const tail = []; + while (true) { + try { + canonical = tail.length ? path.join(fs.realpathSync(dir), ...tail) : fs.realpathSync(dir); + break; + } catch { /* keep climbing */ } + const parent = path.dirname(dir); + if (parent === dir) break; + tail.unshift(path.basename(dir)); + dir = parent; + } + if (canonicalPathCache.size >= CANONICAL_PATH_CACHE_MAX) canonicalPathCache.clear(); + canonicalPathCache.set(resolved, canonical); + return canonical; +} + +// Containment gate shared by the before-edit hook and both scan passes. A +// session routinely touches files that belong to no project or to a +// different one — harness scratchpad dirs under the system temp root, +// sibling checkouts, one-off throwaway HTML — and findings against those are +// judged with THIS project's config and DESIGN.md palette, which is never +// right. Skip them (audit reason: outside-project). Paths are canonicalized +// first so a symlinked root (macOS /tmp -> /private/tmp) doesn't split the +// comparison. +export function isScanTargetInsideProject(filePath, projectCwd) { + if (!filePath || !projectCwd) return false; + return isInsideProject(canonicalPath(filePath), canonicalPath(projectCwd)); +} + export function parseStaticStyleImports(content, fromFile, projectCwd) { if (!content || typeof content !== 'string') return []; const dir = path.dirname(fromFile); @@ -1693,6 +1738,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = lastSkip = 'file-missing'; continue; } + if (!isScanTargetInsideProject(filePath, projectCwd)) { + lastSkip = 'outside-project'; + continue; + } const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes; if (maxFileBytes > 0) { @@ -2023,6 +2072,10 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no const relForMatch = relativize(filePath, projectCwd); if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue; if (!fs.existsSync(filePath)) continue; + // Caches written before this gate existed can still hold out-of-project + // paths, so the Stop pass re-checks containment rather than trusting + // the per-edit pass to have filtered them. + if (!isScanTargetInsideProject(filePath, projectCwd)) continue; scanned += 1; let content = ''; diff --git a/.qoder/skills/impeccable/scripts/hook-before-edit.mjs b/.qoder/skills/impeccable/scripts/hook-before-edit.mjs index 54e789e8b..1dcde6ef3 100644 --- a/.qoder/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.qoder/skills/impeccable/scripts/hook-before-edit.mjs @@ -23,6 +23,7 @@ import { designSystemOptions, filterFindings, isNativePlatform, + isScanTargetInsideProject, loadDetector, matchConfiguredExtension, matchesAnyGlob, @@ -161,7 +162,7 @@ function replaceOnce(original, oldString, newString) { } function readExistingProjectFile(filePath, cwd) { - if (!isInsideProject(filePath, cwd)) return null; + if (!isScanTargetInsideProject(filePath, cwd)) return null; if (SENSITIVE_PATH.test(filePath) || GENERATED_PATH.test(filePath)) return null; try { const stat = fs.statSync(filePath); @@ -232,7 +233,7 @@ function shellCopiedFileContent(command, cwd) { const source = shellCopyPaths(command)?.source; if (!source) return ''; const sourcePath = path.isAbsolute(source) ? source : path.resolve(cwd, source); - if (!isInsideProject(sourcePath, cwd)) return ''; + if (!isScanTargetInsideProject(sourcePath, cwd)) return ''; if (SENSITIVE_PATH.test(sourcePath) || GENERATED_PATH.test(sourcePath)) return ''; try { const stat = fs.statSync(sourcePath); @@ -328,15 +329,6 @@ function relativePath(filePath, cwd) { } } -function isInsideProject(filePath, cwd) { - try { - const rel = path.relative(cwd, filePath); - return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); - } catch { - return false; - } -} - // The static HTML engine reads its input from disk, but preToolUse only has // the proposed content. Stage it in a temp file so html-engine targets get the // same DOM-structural rules pre-write that runHook applies post-edit. @@ -414,7 +406,7 @@ async function main() { }; if (!filePath) return allow({ ...audit, skipped: 'no-file-path', durationMs: Date.now() - started }); - if (!isInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started }); + if (!isScanTargetInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started }); if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started }); if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started }); diff --git a/.qoder/skills/impeccable/scripts/hook-lib.mjs b/.qoder/skills/impeccable/scripts/hook-lib.mjs index 65931f9e6..b874985a6 100644 --- a/.qoder/skills/impeccable/scripts/hook-lib.mjs +++ b/.qoder/skills/impeccable/scripts/hook-lib.mjs @@ -1335,6 +1335,51 @@ function isInsideProject(filePath, projectCwd) { } } +// Resolve a path to its canonical (symlink-free) form. When the path does +// not exist yet — the before-edit hook gates proposed Writes — canonicalize +// the nearest existing ancestor and re-append the remainder, so a new file +// under a symlinked root still compares equal to its canonical project. +// Memoized: the hook runs as a fresh process per tool event, so the cache +// amounts to once-per-event work — the scan loops re-check the same project +// root for every target file. The cap only matters to long-lived importers +// like the test runner. +const canonicalPathCache = new Map(); +const CANONICAL_PATH_CACHE_MAX = 1024; + +function canonicalPath(p) { + const resolved = path.resolve(p); + if (canonicalPathCache.has(resolved)) return canonicalPathCache.get(resolved); + let canonical = resolved; + let dir = resolved; + const tail = []; + while (true) { + try { + canonical = tail.length ? path.join(fs.realpathSync(dir), ...tail) : fs.realpathSync(dir); + break; + } catch { /* keep climbing */ } + const parent = path.dirname(dir); + if (parent === dir) break; + tail.unshift(path.basename(dir)); + dir = parent; + } + if (canonicalPathCache.size >= CANONICAL_PATH_CACHE_MAX) canonicalPathCache.clear(); + canonicalPathCache.set(resolved, canonical); + return canonical; +} + +// Containment gate shared by the before-edit hook and both scan passes. A +// session routinely touches files that belong to no project or to a +// different one — harness scratchpad dirs under the system temp root, +// sibling checkouts, one-off throwaway HTML — and findings against those are +// judged with THIS project's config and DESIGN.md palette, which is never +// right. Skip them (audit reason: outside-project). Paths are canonicalized +// first so a symlinked root (macOS /tmp -> /private/tmp) doesn't split the +// comparison. +export function isScanTargetInsideProject(filePath, projectCwd) { + if (!filePath || !projectCwd) return false; + return isInsideProject(canonicalPath(filePath), canonicalPath(projectCwd)); +} + export function parseStaticStyleImports(content, fromFile, projectCwd) { if (!content || typeof content !== 'string') return []; const dir = path.dirname(fromFile); @@ -1693,6 +1738,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = lastSkip = 'file-missing'; continue; } + if (!isScanTargetInsideProject(filePath, projectCwd)) { + lastSkip = 'outside-project'; + continue; + } const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes; if (maxFileBytes > 0) { @@ -2023,6 +2072,10 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no const relForMatch = relativize(filePath, projectCwd); if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue; if (!fs.existsSync(filePath)) continue; + // Caches written before this gate existed can still hold out-of-project + // paths, so the Stop pass re-checks containment rather than trusting + // the per-edit pass to have filtered them. + if (!isScanTargetInsideProject(filePath, projectCwd)) continue; scanned += 1; let content = ''; diff --git a/.rovodev/skills/impeccable/scripts/hook-before-edit.mjs b/.rovodev/skills/impeccable/scripts/hook-before-edit.mjs index 54e789e8b..1dcde6ef3 100644 --- a/.rovodev/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.rovodev/skills/impeccable/scripts/hook-before-edit.mjs @@ -23,6 +23,7 @@ import { designSystemOptions, filterFindings, isNativePlatform, + isScanTargetInsideProject, loadDetector, matchConfiguredExtension, matchesAnyGlob, @@ -161,7 +162,7 @@ function replaceOnce(original, oldString, newString) { } function readExistingProjectFile(filePath, cwd) { - if (!isInsideProject(filePath, cwd)) return null; + if (!isScanTargetInsideProject(filePath, cwd)) return null; if (SENSITIVE_PATH.test(filePath) || GENERATED_PATH.test(filePath)) return null; try { const stat = fs.statSync(filePath); @@ -232,7 +233,7 @@ function shellCopiedFileContent(command, cwd) { const source = shellCopyPaths(command)?.source; if (!source) return ''; const sourcePath = path.isAbsolute(source) ? source : path.resolve(cwd, source); - if (!isInsideProject(sourcePath, cwd)) return ''; + if (!isScanTargetInsideProject(sourcePath, cwd)) return ''; if (SENSITIVE_PATH.test(sourcePath) || GENERATED_PATH.test(sourcePath)) return ''; try { const stat = fs.statSync(sourcePath); @@ -328,15 +329,6 @@ function relativePath(filePath, cwd) { } } -function isInsideProject(filePath, cwd) { - try { - const rel = path.relative(cwd, filePath); - return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); - } catch { - return false; - } -} - // The static HTML engine reads its input from disk, but preToolUse only has // the proposed content. Stage it in a temp file so html-engine targets get the // same DOM-structural rules pre-write that runHook applies post-edit. @@ -414,7 +406,7 @@ async function main() { }; if (!filePath) return allow({ ...audit, skipped: 'no-file-path', durationMs: Date.now() - started }); - if (!isInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started }); + if (!isScanTargetInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started }); if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started }); if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started }); diff --git a/.rovodev/skills/impeccable/scripts/hook-lib.mjs b/.rovodev/skills/impeccable/scripts/hook-lib.mjs index 65931f9e6..b874985a6 100644 --- a/.rovodev/skills/impeccable/scripts/hook-lib.mjs +++ b/.rovodev/skills/impeccable/scripts/hook-lib.mjs @@ -1335,6 +1335,51 @@ function isInsideProject(filePath, projectCwd) { } } +// Resolve a path to its canonical (symlink-free) form. When the path does +// not exist yet — the before-edit hook gates proposed Writes — canonicalize +// the nearest existing ancestor and re-append the remainder, so a new file +// under a symlinked root still compares equal to its canonical project. +// Memoized: the hook runs as a fresh process per tool event, so the cache +// amounts to once-per-event work — the scan loops re-check the same project +// root for every target file. The cap only matters to long-lived importers +// like the test runner. +const canonicalPathCache = new Map(); +const CANONICAL_PATH_CACHE_MAX = 1024; + +function canonicalPath(p) { + const resolved = path.resolve(p); + if (canonicalPathCache.has(resolved)) return canonicalPathCache.get(resolved); + let canonical = resolved; + let dir = resolved; + const tail = []; + while (true) { + try { + canonical = tail.length ? path.join(fs.realpathSync(dir), ...tail) : fs.realpathSync(dir); + break; + } catch { /* keep climbing */ } + const parent = path.dirname(dir); + if (parent === dir) break; + tail.unshift(path.basename(dir)); + dir = parent; + } + if (canonicalPathCache.size >= CANONICAL_PATH_CACHE_MAX) canonicalPathCache.clear(); + canonicalPathCache.set(resolved, canonical); + return canonical; +} + +// Containment gate shared by the before-edit hook and both scan passes. A +// session routinely touches files that belong to no project or to a +// different one — harness scratchpad dirs under the system temp root, +// sibling checkouts, one-off throwaway HTML — and findings against those are +// judged with THIS project's config and DESIGN.md palette, which is never +// right. Skip them (audit reason: outside-project). Paths are canonicalized +// first so a symlinked root (macOS /tmp -> /private/tmp) doesn't split the +// comparison. +export function isScanTargetInsideProject(filePath, projectCwd) { + if (!filePath || !projectCwd) return false; + return isInsideProject(canonicalPath(filePath), canonicalPath(projectCwd)); +} + export function parseStaticStyleImports(content, fromFile, projectCwd) { if (!content || typeof content !== 'string') return []; const dir = path.dirname(fromFile); @@ -1693,6 +1738,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = lastSkip = 'file-missing'; continue; } + if (!isScanTargetInsideProject(filePath, projectCwd)) { + lastSkip = 'outside-project'; + continue; + } const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes; if (maxFileBytes > 0) { @@ -2023,6 +2072,10 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no const relForMatch = relativize(filePath, projectCwd); if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue; if (!fs.existsSync(filePath)) continue; + // Caches written before this gate existed can still hold out-of-project + // paths, so the Stop pass re-checks containment rather than trusting + // the per-edit pass to have filtered them. + if (!isScanTargetInsideProject(filePath, projectCwd)) continue; scanned += 1; let content = ''; diff --git a/.trae-cn/skills/impeccable/scripts/hook-before-edit.mjs b/.trae-cn/skills/impeccable/scripts/hook-before-edit.mjs index 54e789e8b..1dcde6ef3 100644 --- a/.trae-cn/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.trae-cn/skills/impeccable/scripts/hook-before-edit.mjs @@ -23,6 +23,7 @@ import { designSystemOptions, filterFindings, isNativePlatform, + isScanTargetInsideProject, loadDetector, matchConfiguredExtension, matchesAnyGlob, @@ -161,7 +162,7 @@ function replaceOnce(original, oldString, newString) { } function readExistingProjectFile(filePath, cwd) { - if (!isInsideProject(filePath, cwd)) return null; + if (!isScanTargetInsideProject(filePath, cwd)) return null; if (SENSITIVE_PATH.test(filePath) || GENERATED_PATH.test(filePath)) return null; try { const stat = fs.statSync(filePath); @@ -232,7 +233,7 @@ function shellCopiedFileContent(command, cwd) { const source = shellCopyPaths(command)?.source; if (!source) return ''; const sourcePath = path.isAbsolute(source) ? source : path.resolve(cwd, source); - if (!isInsideProject(sourcePath, cwd)) return ''; + if (!isScanTargetInsideProject(sourcePath, cwd)) return ''; if (SENSITIVE_PATH.test(sourcePath) || GENERATED_PATH.test(sourcePath)) return ''; try { const stat = fs.statSync(sourcePath); @@ -328,15 +329,6 @@ function relativePath(filePath, cwd) { } } -function isInsideProject(filePath, cwd) { - try { - const rel = path.relative(cwd, filePath); - return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); - } catch { - return false; - } -} - // The static HTML engine reads its input from disk, but preToolUse only has // the proposed content. Stage it in a temp file so html-engine targets get the // same DOM-structural rules pre-write that runHook applies post-edit. @@ -414,7 +406,7 @@ async function main() { }; if (!filePath) return allow({ ...audit, skipped: 'no-file-path', durationMs: Date.now() - started }); - if (!isInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started }); + if (!isScanTargetInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started }); if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started }); if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started }); diff --git a/.trae-cn/skills/impeccable/scripts/hook-lib.mjs b/.trae-cn/skills/impeccable/scripts/hook-lib.mjs index 65931f9e6..b874985a6 100644 --- a/.trae-cn/skills/impeccable/scripts/hook-lib.mjs +++ b/.trae-cn/skills/impeccable/scripts/hook-lib.mjs @@ -1335,6 +1335,51 @@ function isInsideProject(filePath, projectCwd) { } } +// Resolve a path to its canonical (symlink-free) form. When the path does +// not exist yet — the before-edit hook gates proposed Writes — canonicalize +// the nearest existing ancestor and re-append the remainder, so a new file +// under a symlinked root still compares equal to its canonical project. +// Memoized: the hook runs as a fresh process per tool event, so the cache +// amounts to once-per-event work — the scan loops re-check the same project +// root for every target file. The cap only matters to long-lived importers +// like the test runner. +const canonicalPathCache = new Map(); +const CANONICAL_PATH_CACHE_MAX = 1024; + +function canonicalPath(p) { + const resolved = path.resolve(p); + if (canonicalPathCache.has(resolved)) return canonicalPathCache.get(resolved); + let canonical = resolved; + let dir = resolved; + const tail = []; + while (true) { + try { + canonical = tail.length ? path.join(fs.realpathSync(dir), ...tail) : fs.realpathSync(dir); + break; + } catch { /* keep climbing */ } + const parent = path.dirname(dir); + if (parent === dir) break; + tail.unshift(path.basename(dir)); + dir = parent; + } + if (canonicalPathCache.size >= CANONICAL_PATH_CACHE_MAX) canonicalPathCache.clear(); + canonicalPathCache.set(resolved, canonical); + return canonical; +} + +// Containment gate shared by the before-edit hook and both scan passes. A +// session routinely touches files that belong to no project or to a +// different one — harness scratchpad dirs under the system temp root, +// sibling checkouts, one-off throwaway HTML — and findings against those are +// judged with THIS project's config and DESIGN.md palette, which is never +// right. Skip them (audit reason: outside-project). Paths are canonicalized +// first so a symlinked root (macOS /tmp -> /private/tmp) doesn't split the +// comparison. +export function isScanTargetInsideProject(filePath, projectCwd) { + if (!filePath || !projectCwd) return false; + return isInsideProject(canonicalPath(filePath), canonicalPath(projectCwd)); +} + export function parseStaticStyleImports(content, fromFile, projectCwd) { if (!content || typeof content !== 'string') return []; const dir = path.dirname(fromFile); @@ -1693,6 +1738,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = lastSkip = 'file-missing'; continue; } + if (!isScanTargetInsideProject(filePath, projectCwd)) { + lastSkip = 'outside-project'; + continue; + } const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes; if (maxFileBytes > 0) { @@ -2023,6 +2072,10 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no const relForMatch = relativize(filePath, projectCwd); if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue; if (!fs.existsSync(filePath)) continue; + // Caches written before this gate existed can still hold out-of-project + // paths, so the Stop pass re-checks containment rather than trusting + // the per-edit pass to have filtered them. + if (!isScanTargetInsideProject(filePath, projectCwd)) continue; scanned += 1; let content = ''; diff --git a/.trae/skills/impeccable/scripts/hook-before-edit.mjs b/.trae/skills/impeccable/scripts/hook-before-edit.mjs index 54e789e8b..1dcde6ef3 100644 --- a/.trae/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.trae/skills/impeccable/scripts/hook-before-edit.mjs @@ -23,6 +23,7 @@ import { designSystemOptions, filterFindings, isNativePlatform, + isScanTargetInsideProject, loadDetector, matchConfiguredExtension, matchesAnyGlob, @@ -161,7 +162,7 @@ function replaceOnce(original, oldString, newString) { } function readExistingProjectFile(filePath, cwd) { - if (!isInsideProject(filePath, cwd)) return null; + if (!isScanTargetInsideProject(filePath, cwd)) return null; if (SENSITIVE_PATH.test(filePath) || GENERATED_PATH.test(filePath)) return null; try { const stat = fs.statSync(filePath); @@ -232,7 +233,7 @@ function shellCopiedFileContent(command, cwd) { const source = shellCopyPaths(command)?.source; if (!source) return ''; const sourcePath = path.isAbsolute(source) ? source : path.resolve(cwd, source); - if (!isInsideProject(sourcePath, cwd)) return ''; + if (!isScanTargetInsideProject(sourcePath, cwd)) return ''; if (SENSITIVE_PATH.test(sourcePath) || GENERATED_PATH.test(sourcePath)) return ''; try { const stat = fs.statSync(sourcePath); @@ -328,15 +329,6 @@ function relativePath(filePath, cwd) { } } -function isInsideProject(filePath, cwd) { - try { - const rel = path.relative(cwd, filePath); - return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); - } catch { - return false; - } -} - // The static HTML engine reads its input from disk, but preToolUse only has // the proposed content. Stage it in a temp file so html-engine targets get the // same DOM-structural rules pre-write that runHook applies post-edit. @@ -414,7 +406,7 @@ async function main() { }; if (!filePath) return allow({ ...audit, skipped: 'no-file-path', durationMs: Date.now() - started }); - if (!isInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started }); + if (!isScanTargetInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started }); if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started }); if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started }); diff --git a/.trae/skills/impeccable/scripts/hook-lib.mjs b/.trae/skills/impeccable/scripts/hook-lib.mjs index 65931f9e6..b874985a6 100644 --- a/.trae/skills/impeccable/scripts/hook-lib.mjs +++ b/.trae/skills/impeccable/scripts/hook-lib.mjs @@ -1335,6 +1335,51 @@ function isInsideProject(filePath, projectCwd) { } } +// Resolve a path to its canonical (symlink-free) form. When the path does +// not exist yet — the before-edit hook gates proposed Writes — canonicalize +// the nearest existing ancestor and re-append the remainder, so a new file +// under a symlinked root still compares equal to its canonical project. +// Memoized: the hook runs as a fresh process per tool event, so the cache +// amounts to once-per-event work — the scan loops re-check the same project +// root for every target file. The cap only matters to long-lived importers +// like the test runner. +const canonicalPathCache = new Map(); +const CANONICAL_PATH_CACHE_MAX = 1024; + +function canonicalPath(p) { + const resolved = path.resolve(p); + if (canonicalPathCache.has(resolved)) return canonicalPathCache.get(resolved); + let canonical = resolved; + let dir = resolved; + const tail = []; + while (true) { + try { + canonical = tail.length ? path.join(fs.realpathSync(dir), ...tail) : fs.realpathSync(dir); + break; + } catch { /* keep climbing */ } + const parent = path.dirname(dir); + if (parent === dir) break; + tail.unshift(path.basename(dir)); + dir = parent; + } + if (canonicalPathCache.size >= CANONICAL_PATH_CACHE_MAX) canonicalPathCache.clear(); + canonicalPathCache.set(resolved, canonical); + return canonical; +} + +// Containment gate shared by the before-edit hook and both scan passes. A +// session routinely touches files that belong to no project or to a +// different one — harness scratchpad dirs under the system temp root, +// sibling checkouts, one-off throwaway HTML — and findings against those are +// judged with THIS project's config and DESIGN.md palette, which is never +// right. Skip them (audit reason: outside-project). Paths are canonicalized +// first so a symlinked root (macOS /tmp -> /private/tmp) doesn't split the +// comparison. +export function isScanTargetInsideProject(filePath, projectCwd) { + if (!filePath || !projectCwd) return false; + return isInsideProject(canonicalPath(filePath), canonicalPath(projectCwd)); +} + export function parseStaticStyleImports(content, fromFile, projectCwd) { if (!content || typeof content !== 'string') return []; const dir = path.dirname(fromFile); @@ -1693,6 +1738,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = lastSkip = 'file-missing'; continue; } + if (!isScanTargetInsideProject(filePath, projectCwd)) { + lastSkip = 'outside-project'; + continue; + } const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes; if (maxFileBytes > 0) { @@ -2023,6 +2072,10 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no const relForMatch = relativize(filePath, projectCwd); if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue; if (!fs.existsSync(filePath)) continue; + // Caches written before this gate existed can still hold out-of-project + // paths, so the Stop pass re-checks containment rather than trusting + // the per-edit pass to have filtered them. + if (!isScanTargetInsideProject(filePath, projectCwd)) continue; scanned += 1; let content = ''; diff --git a/.vibe/skills/impeccable/scripts/hook-before-edit.mjs b/.vibe/skills/impeccable/scripts/hook-before-edit.mjs index 54e789e8b..1dcde6ef3 100644 --- a/.vibe/skills/impeccable/scripts/hook-before-edit.mjs +++ b/.vibe/skills/impeccable/scripts/hook-before-edit.mjs @@ -23,6 +23,7 @@ import { designSystemOptions, filterFindings, isNativePlatform, + isScanTargetInsideProject, loadDetector, matchConfiguredExtension, matchesAnyGlob, @@ -161,7 +162,7 @@ function replaceOnce(original, oldString, newString) { } function readExistingProjectFile(filePath, cwd) { - if (!isInsideProject(filePath, cwd)) return null; + if (!isScanTargetInsideProject(filePath, cwd)) return null; if (SENSITIVE_PATH.test(filePath) || GENERATED_PATH.test(filePath)) return null; try { const stat = fs.statSync(filePath); @@ -232,7 +233,7 @@ function shellCopiedFileContent(command, cwd) { const source = shellCopyPaths(command)?.source; if (!source) return ''; const sourcePath = path.isAbsolute(source) ? source : path.resolve(cwd, source); - if (!isInsideProject(sourcePath, cwd)) return ''; + if (!isScanTargetInsideProject(sourcePath, cwd)) return ''; if (SENSITIVE_PATH.test(sourcePath) || GENERATED_PATH.test(sourcePath)) return ''; try { const stat = fs.statSync(sourcePath); @@ -328,15 +329,6 @@ function relativePath(filePath, cwd) { } } -function isInsideProject(filePath, cwd) { - try { - const rel = path.relative(cwd, filePath); - return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); - } catch { - return false; - } -} - // The static HTML engine reads its input from disk, but preToolUse only has // the proposed content. Stage it in a temp file so html-engine targets get the // same DOM-structural rules pre-write that runHook applies post-edit. @@ -414,7 +406,7 @@ async function main() { }; if (!filePath) return allow({ ...audit, skipped: 'no-file-path', durationMs: Date.now() - started }); - if (!isInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started }); + if (!isScanTargetInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started }); if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started }); if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started }); diff --git a/.vibe/skills/impeccable/scripts/hook-lib.mjs b/.vibe/skills/impeccable/scripts/hook-lib.mjs index 65931f9e6..b874985a6 100644 --- a/.vibe/skills/impeccable/scripts/hook-lib.mjs +++ b/.vibe/skills/impeccable/scripts/hook-lib.mjs @@ -1335,6 +1335,51 @@ function isInsideProject(filePath, projectCwd) { } } +// Resolve a path to its canonical (symlink-free) form. When the path does +// not exist yet — the before-edit hook gates proposed Writes — canonicalize +// the nearest existing ancestor and re-append the remainder, so a new file +// under a symlinked root still compares equal to its canonical project. +// Memoized: the hook runs as a fresh process per tool event, so the cache +// amounts to once-per-event work — the scan loops re-check the same project +// root for every target file. The cap only matters to long-lived importers +// like the test runner. +const canonicalPathCache = new Map(); +const CANONICAL_PATH_CACHE_MAX = 1024; + +function canonicalPath(p) { + const resolved = path.resolve(p); + if (canonicalPathCache.has(resolved)) return canonicalPathCache.get(resolved); + let canonical = resolved; + let dir = resolved; + const tail = []; + while (true) { + try { + canonical = tail.length ? path.join(fs.realpathSync(dir), ...tail) : fs.realpathSync(dir); + break; + } catch { /* keep climbing */ } + const parent = path.dirname(dir); + if (parent === dir) break; + tail.unshift(path.basename(dir)); + dir = parent; + } + if (canonicalPathCache.size >= CANONICAL_PATH_CACHE_MAX) canonicalPathCache.clear(); + canonicalPathCache.set(resolved, canonical); + return canonical; +} + +// Containment gate shared by the before-edit hook and both scan passes. A +// session routinely touches files that belong to no project or to a +// different one — harness scratchpad dirs under the system temp root, +// sibling checkouts, one-off throwaway HTML — and findings against those are +// judged with THIS project's config and DESIGN.md palette, which is never +// right. Skip them (audit reason: outside-project). Paths are canonicalized +// first so a symlinked root (macOS /tmp -> /private/tmp) doesn't split the +// comparison. +export function isScanTargetInsideProject(filePath, projectCwd) { + if (!filePath || !projectCwd) return false; + return isInsideProject(canonicalPath(filePath), canonicalPath(projectCwd)); +} + export function parseStaticStyleImports(content, fromFile, projectCwd) { if (!content || typeof content !== 'string') return []; const dir = path.dirname(fromFile); @@ -1693,6 +1738,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = lastSkip = 'file-missing'; continue; } + if (!isScanTargetInsideProject(filePath, projectCwd)) { + lastSkip = 'outside-project'; + continue; + } const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes; if (maxFileBytes > 0) { @@ -2023,6 +2072,10 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no const relForMatch = relativize(filePath, projectCwd); if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue; if (!fs.existsSync(filePath)) continue; + // Caches written before this gate existed can still hold out-of-project + // paths, so the Stop pass re-checks containment rather than trusting + // the per-edit pass to have filtered them. + if (!isScanTargetInsideProject(filePath, projectCwd)) continue; scanned += 1; let content = ''; diff --git a/plugin/skills/impeccable/scripts/hook-before-edit.mjs b/plugin/skills/impeccable/scripts/hook-before-edit.mjs index 54e789e8b..1dcde6ef3 100644 --- a/plugin/skills/impeccable/scripts/hook-before-edit.mjs +++ b/plugin/skills/impeccable/scripts/hook-before-edit.mjs @@ -23,6 +23,7 @@ import { designSystemOptions, filterFindings, isNativePlatform, + isScanTargetInsideProject, loadDetector, matchConfiguredExtension, matchesAnyGlob, @@ -161,7 +162,7 @@ function replaceOnce(original, oldString, newString) { } function readExistingProjectFile(filePath, cwd) { - if (!isInsideProject(filePath, cwd)) return null; + if (!isScanTargetInsideProject(filePath, cwd)) return null; if (SENSITIVE_PATH.test(filePath) || GENERATED_PATH.test(filePath)) return null; try { const stat = fs.statSync(filePath); @@ -232,7 +233,7 @@ function shellCopiedFileContent(command, cwd) { const source = shellCopyPaths(command)?.source; if (!source) return ''; const sourcePath = path.isAbsolute(source) ? source : path.resolve(cwd, source); - if (!isInsideProject(sourcePath, cwd)) return ''; + if (!isScanTargetInsideProject(sourcePath, cwd)) return ''; if (SENSITIVE_PATH.test(sourcePath) || GENERATED_PATH.test(sourcePath)) return ''; try { const stat = fs.statSync(sourcePath); @@ -328,15 +329,6 @@ function relativePath(filePath, cwd) { } } -function isInsideProject(filePath, cwd) { - try { - const rel = path.relative(cwd, filePath); - return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); - } catch { - return false; - } -} - // The static HTML engine reads its input from disk, but preToolUse only has // the proposed content. Stage it in a temp file so html-engine targets get the // same DOM-structural rules pre-write that runHook applies post-edit. @@ -414,7 +406,7 @@ async function main() { }; if (!filePath) return allow({ ...audit, skipped: 'no-file-path', durationMs: Date.now() - started }); - if (!isInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started }); + if (!isScanTargetInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started }); if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started }); if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started }); diff --git a/plugin/skills/impeccable/scripts/hook-lib.mjs b/plugin/skills/impeccable/scripts/hook-lib.mjs index 65931f9e6..b874985a6 100644 --- a/plugin/skills/impeccable/scripts/hook-lib.mjs +++ b/plugin/skills/impeccable/scripts/hook-lib.mjs @@ -1335,6 +1335,51 @@ function isInsideProject(filePath, projectCwd) { } } +// Resolve a path to its canonical (symlink-free) form. When the path does +// not exist yet — the before-edit hook gates proposed Writes — canonicalize +// the nearest existing ancestor and re-append the remainder, so a new file +// under a symlinked root still compares equal to its canonical project. +// Memoized: the hook runs as a fresh process per tool event, so the cache +// amounts to once-per-event work — the scan loops re-check the same project +// root for every target file. The cap only matters to long-lived importers +// like the test runner. +const canonicalPathCache = new Map(); +const CANONICAL_PATH_CACHE_MAX = 1024; + +function canonicalPath(p) { + const resolved = path.resolve(p); + if (canonicalPathCache.has(resolved)) return canonicalPathCache.get(resolved); + let canonical = resolved; + let dir = resolved; + const tail = []; + while (true) { + try { + canonical = tail.length ? path.join(fs.realpathSync(dir), ...tail) : fs.realpathSync(dir); + break; + } catch { /* keep climbing */ } + const parent = path.dirname(dir); + if (parent === dir) break; + tail.unshift(path.basename(dir)); + dir = parent; + } + if (canonicalPathCache.size >= CANONICAL_PATH_CACHE_MAX) canonicalPathCache.clear(); + canonicalPathCache.set(resolved, canonical); + return canonical; +} + +// Containment gate shared by the before-edit hook and both scan passes. A +// session routinely touches files that belong to no project or to a +// different one — harness scratchpad dirs under the system temp root, +// sibling checkouts, one-off throwaway HTML — and findings against those are +// judged with THIS project's config and DESIGN.md palette, which is never +// right. Skip them (audit reason: outside-project). Paths are canonicalized +// first so a symlinked root (macOS /tmp -> /private/tmp) doesn't split the +// comparison. +export function isScanTargetInsideProject(filePath, projectCwd) { + if (!filePath || !projectCwd) return false; + return isInsideProject(canonicalPath(filePath), canonicalPath(projectCwd)); +} + export function parseStaticStyleImports(content, fromFile, projectCwd) { if (!content || typeof content !== 'string') return []; const dir = path.dirname(fromFile); @@ -1693,6 +1738,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now = lastSkip = 'file-missing'; continue; } + if (!isScanTargetInsideProject(filePath, projectCwd)) { + lastSkip = 'outside-project'; + continue; + } const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes; if (maxFileBytes > 0) { @@ -2023,6 +2072,10 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no const relForMatch = relativize(filePath, projectCwd); if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue; if (!fs.existsSync(filePath)) continue; + // Caches written before this gate existed can still hold out-of-project + // paths, so the Stop pass re-checks containment rather than trusting + // the per-edit pass to have filtered them. + if (!isScanTargetInsideProject(filePath, projectCwd)) continue; scanned += 1; let content = '';