Sync generated provider output

This commit is contained in:
github-actions[bot]
2026-08-03 03:08:14 +00:00
parent ae2be34fdc
commit 0c9ce16248
30 changed files with 855 additions and 180 deletions
@@ -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 });
@@ -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 = '';
@@ -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 });
@@ -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 = '';
@@ -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 });
@@ -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 = '';
@@ -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 });
@@ -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 = '';
@@ -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 });
@@ -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 = '';
@@ -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 });
@@ -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 = '';
@@ -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 });
@@ -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 = '';
@@ -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 });
@@ -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 = '';
@@ -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 });
@@ -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 = '';
@@ -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 });
@@ -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 = '';
@@ -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 });
@@ -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 = '';
@@ -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 });
@@ -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 = '';
@@ -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 });
@@ -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 = '';
@@ -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 });
@@ -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 = '';
@@ -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 });
@@ -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 = '';