mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Fix Next.js 16 CSP and parent hook discovery (#710)
* Fix CSP and hook ancestor discovery Recognize Next.js 16 proxy files when detecting runtime CSP and mirror harness ancestor lookup when locating active hook manifests for nested projects. AI assistance disclosure: Implemented and verified with Codex under maintainer direction. * Tighten hook and proxy discovery AI assistance disclosure: Codex implemented and verified these fixes under maintainer direction. * Honor ancestor hook disable config AI assistance disclosure: Codex implemented and verified this fix under maintainer direction. * Keep hook discovery within target repository Stop manifest discovery at the target repository boundary instead of re-adding an outer workspace root, with regression coverage for nested Git targets. AI assistance disclosure: This commit was prepared with Codex under maintainer direction. * Detect proxy CSP in nested Next apps Recognize proxy files at root or src placement relative to nested Next project markers while continuing to ignore unrelated proxy helpers. AI assistance disclosure: This commit was prepared with Codex under maintainer direction. * Resolve external targets from their own repository Scope explicit sibling targets to their own Git root so caller context and hook manifests cannot suppress required detector guidance. AI assistance disclosure: This commit was prepared with Codex under maintainer direction. * Isolate explicit targets at Git boundaries Keep nested repositories and external targets out of caller and home-level context or hook discovery. AI assistance disclosure: Codex helped implement and test this fix under maintainer direction.
This commit is contained in:
@@ -200,7 +200,20 @@ export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
|
||||
function resolveProject(cwd = process.cwd(), options = {}) {
|
||||
const absCwd = path.resolve(cwd);
|
||||
const targetDir = resolveTargetDir(absCwd, options);
|
||||
const hasExplicitTarget = hasTargetOption(options) && targetDir !== absCwd;
|
||||
const targetGitRoot = hasExplicitTarget ? findGitBoundaryRoot(targetDir) : null;
|
||||
let repoRoot = findMonorepoRoot(targetDir);
|
||||
if (!repoRoot && targetGitRoot) {
|
||||
const cwdGitRoot = findGitBoundaryRoot(absCwd);
|
||||
if (targetGitRoot !== cwdGitRoot) {
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: nearestTargetContextRoot(targetGitRoot, targetDir) || targetGitRoot,
|
||||
repoRoot: targetGitRoot,
|
||||
isMonorepo: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!repoRoot && targetDir !== absCwd) {
|
||||
const cwdRepoRoot = findMonorepoRoot(absCwd);
|
||||
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
|
||||
@@ -208,6 +221,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
|
||||
}
|
||||
}
|
||||
if (!repoRoot) {
|
||||
const targetIsExternal = hasTargetOption(options)
|
||||
&& targetDir !== absCwd
|
||||
&& !isPathInside(targetDir, absCwd);
|
||||
if (targetIsExternal) {
|
||||
const targetRepoRoot = targetGitRoot || targetDir;
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: nearestTargetContextRoot(targetRepoRoot, targetDir) || targetRepoRoot,
|
||||
repoRoot: targetRepoRoot,
|
||||
isMonorepo: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
targetDir,
|
||||
projectRoot: nearestTargetContextRoot(absCwd, targetDir) || absCwd,
|
||||
@@ -223,6 +248,18 @@ function resolveProject(cwd = process.cwd(), options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function findGitBoundaryRoot(startDir) {
|
||||
let dir = path.resolve(startDir);
|
||||
const homeDir = path.resolve(os.homedir());
|
||||
while (true) {
|
||||
if (dir === homeDir) return null;
|
||||
if (hasGitBoundary(dir)) return dir;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function isPathInside(candidate, root) {
|
||||
const rel = path.relative(root, candidate);
|
||||
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
||||
@@ -1313,6 +1350,39 @@ function hookEnabledAt(root) {
|
||||
|
||||
const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents', 'grok']);
|
||||
|
||||
// Harness project settings are discovered by walking up from the resolved
|
||||
// project root. Its hook manifest can live at an enclosing git root, so
|
||||
// checking only projectRoot produces a false MANUAL_DETECTOR_REQUIRED
|
||||
// directive. Starting from projectRoot also prevents an explicit target from
|
||||
// borrowing an unrelated manifest near the caller. The walk itself is the
|
||||
// authority: do not append repoRoot afterward, because resolveProject can
|
||||
// retain an outer workspace root for a target inside an independent nested
|
||||
// Git repository.
|
||||
function hookManifestSearchRoots(ctx) {
|
||||
const roots = [];
|
||||
const seen = new Set();
|
||||
const add = (root) => {
|
||||
if (!root) return;
|
||||
const resolved = path.resolve(root);
|
||||
if (seen.has(resolved)) return;
|
||||
seen.add(resolved);
|
||||
roots.push(resolved);
|
||||
};
|
||||
|
||||
let current = path.resolve(ctx.projectRoot || process.cwd());
|
||||
const home = path.resolve(os.homedir());
|
||||
while (true) {
|
||||
if (current === home) break;
|
||||
add(current);
|
||||
if (hasGitBoundary(current)) break;
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
function automaticHookMode(ctx) {
|
||||
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
|
||||
return 'none';
|
||||
@@ -1320,8 +1390,10 @@ function automaticHookMode(ctx) {
|
||||
const activeRoot = path.resolve(ctx.projectRoot || process.cwd());
|
||||
if (!hookEnabledAt(activeRoot)) return 'none';
|
||||
const manifests = HOOK_MANIFESTS_BY_PROVIDER[IMPECCABLE_PROVIDER_ID] || [];
|
||||
const roots = [...new Set([process.cwd(), ctx.projectRoot, ctx.repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
|
||||
for (const root of roots) {
|
||||
for (const root of hookManifestSearchRoots(ctx)) {
|
||||
// A manifest can live above the resolved product. Honor the hook lifecycle
|
||||
// config beside that manifest before treating it as active coverage.
|
||||
if (!hookEnabledAt(root)) continue;
|
||||
for (const rel of manifests) {
|
||||
const raw = readJson(path.join(root, rel));
|
||||
if (raw?.hooks && valueHasHookMarker(raw.hooks)) {
|
||||
|
||||
@@ -18,8 +18,9 @@
|
||||
* Covers:
|
||||
* - Inline Next.js headers() with CSP string
|
||||
* - Nuxt routeRules / nitro.routeRules CSP headers
|
||||
* - "middleware": CSP set dynamically in middleware.{ts,js}.
|
||||
* Detected but not auto-patched in v1.
|
||||
* - "middleware": CSP set dynamically in middleware.{ts,js,mjs} or
|
||||
* Next.js 16's proxy.{ts,js,mjs} convention. Detected
|
||||
* but not auto-patched in v1.
|
||||
* - "meta-tag": <meta http-equiv="Content-Security-Policy"> in
|
||||
* layout files. Detected but not auto-patched in v1.
|
||||
* - null: no CSP signals found; no patch needed.
|
||||
@@ -77,9 +78,57 @@ const NUXT_ROUTE_RULES_SIGNALS = [
|
||||
/\bscript-src\b/,
|
||||
];
|
||||
|
||||
const NEXT_MIDDLEWARE_FILES = new Set([
|
||||
'middleware.ts',
|
||||
'middleware.js',
|
||||
'middleware.mjs',
|
||||
]);
|
||||
const NEXT_PROXY_FILES = new Set([
|
||||
'proxy.ts',
|
||||
'proxy.js',
|
||||
'proxy.mjs',
|
||||
]);
|
||||
const NEXT_CONFIG_FILES = [
|
||||
'next.config.js',
|
||||
'next.config.mjs',
|
||||
'next.config.cjs',
|
||||
'next.config.ts',
|
||||
'next.config.mts',
|
||||
'next.config.cts',
|
||||
];
|
||||
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
|
||||
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
|
||||
function hasNextProjectMarker(projectRoot) {
|
||||
if (NEXT_CONFIG_FILES.some(name => fs.existsSync(path.join(projectRoot, name)))) return true;
|
||||
if (['app', 'pages', 'src/app', 'src/pages'].some(rel => fs.existsSync(path.join(projectRoot, rel)))) return true;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
|
||||
return ['dependencies', 'devDependencies', 'peerDependencies']
|
||||
.some(group => pkg?.[group] && Object.prototype.hasOwnProperty.call(pkg[group], 'next'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isNextRequestHookFile(root, absPath, relPath, base) {
|
||||
if (NEXT_MIDDLEWARE_FILES.has(base)) return true;
|
||||
if (!NEXT_PROXY_FILES.has(base)) return false;
|
||||
const normalized = relPath.split(path.sep).join('/').toLowerCase();
|
||||
// Next.js 16 recognizes proxy at the project root or in the optional src/
|
||||
// directory, alongside app/ or pages/. The scan root is commonly a
|
||||
// monorepo, so also accept that placement relative to a nested directory
|
||||
// that carries a concrete Next.js project marker. A same-named helper
|
||||
// elsewhere in the tree is not the framework request hook.
|
||||
if (normalized === base || normalized === `src/${base}`) return true;
|
||||
const hookDir = path.dirname(absPath);
|
||||
const projectRoot = path.basename(hookDir).toLowerCase() === 'src'
|
||||
? path.dirname(hookDir)
|
||||
: hookDir;
|
||||
if (path.resolve(projectRoot) === path.resolve(root)) return true;
|
||||
return hasNextProjectMarker(projectRoot);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} cwd Project root.
|
||||
* @returns {{ shape: string|null, signals: string[] }}
|
||||
@@ -133,8 +182,7 @@ export function detectCsp(cwd = process.cwd()) {
|
||||
|
||||
// === detect-only shapes ===
|
||||
|
||||
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
|
||||
MIDDLEWARE_HINT.test(body)) {
|
||||
if (isNextRequestHookFile(cwd, absPath, relPath, base) && MIDDLEWARE_HINT.test(body)) {
|
||||
hits.middleware.push(relPath);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user