fix: skip design-hook scans for files outside the resolved project root

The per-edit and Stop deep passes gated on sensitive paths, generated
paths, extension, config ignores, and size, but never on containment.
Any file the session touched outside the project (harness scratchpad
dirs under the system temp root, sibling checkouts) was scanned and
judged against THIS project's config and DESIGN.md palette, producing
design-system findings that are wrong by construction.

Both loops now check isScanTargetInsideProject() (audit reason:
outside-project), matching the gate hook-before-edit.mjs already had.
Paths are canonicalized so a symlinked root doesn't split the
comparison. The Stop pass re-checks containment itself because caches
written by older hook versions can still list out-of-project paths.
Umbrella-dir launches (issue #305) are unaffected: their projectCwd
resolves to the edited file's own project root, so containment holds.

Written with AI assistance (Claude Code).

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-31 18:20:09 -07:00
co-authored by Claude Code
parent 6c1aff7d1f
commit ae03e9e09c
2 changed files with 131 additions and 0 deletions
+26
View File
@@ -1332,6 +1332,24 @@ function isInsideProject(filePath, projectCwd) {
}
}
function canonicalPath(p) {
try { return fs.realpathSync(p); } catch { return path.resolve(p); }
}
// Containment gate for 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; the realpath fallback for
// missing paths is only correct because both scan loops check existence
// before calling this.
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);
@@ -1690,6 +1708,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) {
@@ -2020,6 +2042,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 = '';