diff --git a/skill/scripts/hook-before-edit.mjs b/skill/scripts/hook-before-edit.mjs index 54e789e8b..1dcde6ef3 100644 --- a/skill/scripts/hook-before-edit.mjs +++ b/skill/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/skill/scripts/hook-lib.mjs b/skill/scripts/hook-lib.mjs index c56018cc5..5967b4052 100644 --- a/skill/scripts/hook-lib.mjs +++ b/skill/scripts/hook-lib.mjs @@ -1332,19 +1332,33 @@ 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. function canonicalPath(p) { - try { return fs.realpathSync(p); } catch { return path.resolve(p); } + const resolved = path.resolve(p); + let dir = resolved; + const tail = []; + while (true) { + try { + return tail.length ? path.join(fs.realpathSync(dir), ...tail) : fs.realpathSync(dir); + } catch { /* keep climbing */ } + const parent = path.dirname(dir); + if (parent === dir) return resolved; + tail.unshift(path.basename(dir)); + dir = parent; + } } -// 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. +// 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)); diff --git a/tests/hook.test.mjs b/tests/hook.test.mjs index 9d4c330c3..b773db2b3 100644 --- a/tests/hook.test.mjs +++ b/tests/hook.test.mjs @@ -195,6 +195,19 @@ describe('isScanTargetInsideProject()', () => { assert.equal(isScanTargetInsideProject(file, link), true); assert.equal(isScanTargetInsideProject(path.join(link, 'src', 'Card.tsx'), real), true); }); + + it('classifies not-yet-written files by their nearest existing ancestor', () => { + // The before-edit hook gates proposed Writes, so the target often does + // not exist. Canonicalization must climb to an existing ancestor rather + // than bail, or a new file under a symlinked root would read as outside. + const real = path.join(root, 'real'); + const link = path.join(root, 'link'); + fs.mkdirSync(real, { recursive: true }); + fs.symlinkSync(real, link); + assert.equal(isScanTargetInsideProject(path.join(link, 'src', 'New.tsx'), real), true); + assert.equal(isScanTargetInsideProject(path.join(real, 'deep', 'New.tsx'), link), true); + assert.equal(isScanTargetInsideProject(path.join(root, 'elsewhere', 'New.tsx'), real), false); + }); }); describe('readConfig()', () => {