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.
This commit is contained in:
Paul Bakaus
2026-09-02 09:13:53 -07:00
parent a264199177
commit be43532192
8 changed files with 109 additions and 6 deletions
+34 -2
View File
@@ -1313,6 +1313,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 active
// working directory. Context resolution can intentionally select a nested
// product root even when the harness project (and its hook manifest) lives at
// the enclosing git root, so checking only projectRoot/repoRoot produces a
// false MANUAL_DETECTOR_REQUIRED directive. Mirror the ancestor lookup through
// the nearest git boundary, while retaining exact resolved roots for explicit
// targets outside the invoking directory.
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(process.cwd());
const home = path.resolve(os.homedir());
while (true) {
add(current);
if (current === home || hasGitBoundary(current)) break;
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
add(ctx.projectRoot);
add(ctx.repoRoot);
return roots;
}
function automaticHookMode(ctx) {
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
return 'none';
@@ -1320,8 +1353,7 @@ 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)) {
for (const rel of manifests) {
const raw = readJson(path.join(root, rel));
if (raw?.hooks && valueHasHookMarker(raw.hooks)) {
+12 -4
View File
@@ -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,6 +78,14 @@ const NUXT_ROUTE_RULES_SIGNALS = [
/\bscript-src\b/,
];
const NEXT_REQUEST_HOOK_FILES = new Set([
'middleware.ts',
'middleware.js',
'middleware.mjs',
'proxy.ts',
'proxy.js',
'proxy.mjs',
]);
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
@@ -133,8 +142,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 (NEXT_REQUEST_HOOK_FILES.has(base) && MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
+23
View File
@@ -1198,6 +1198,29 @@ describe('context.mjs CLI', () => {
assert.match(disabled.stdout, /detect\.mjs --json <changed targets>/);
});
it('finds the active hook manifest at an enclosing harness project root', () => {
const scripts = path.join(scratch, 'bundle', 'skills', 'impeccable', 'scripts');
stageContextBundle(scripts, { providerId: 'claude-code' });
const repo = path.join(scratch, 'repo');
const project = path.join(repo, 'web');
fs.mkdirSync(path.join(repo, '.git'), { recursive: true });
fs.mkdirSync(path.join(repo, '.claude'), { recursive: true });
fs.mkdirSync(project, { recursive: true });
fs.writeFileSync(path.join(project, 'PRODUCT.md'), '# Nested web product\n');
fs.writeFileSync(path.join(repo, '.claude', 'settings.local.json'), JSON.stringify({
hooks: { Stop: [{ hooks: [{ command: 'node .claude/skills/impeccable/scripts/hook.mjs' }] }] },
}));
const res = spawnSync(process.execPath, [path.join(scripts, 'context.mjs')], {
cwd: project,
encoding: 'utf8',
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
});
assert.equal(res.status, 0, res.stderr);
assert.doesNotMatch(res.stdout, /MANUAL_DETECTOR_REQUIRED:/);
});
it('adds no detector directive when a per-edit-only hook is active', () => {
const scripts = path.join(scratch, 'bundle', 'skills', 'impeccable', 'scripts');
stageContextBundle(scripts, { providerId: 'cursor' });
+3
View File
@@ -94,6 +94,9 @@ Fixtures can also opt into a **runtime E2E** pass that actually installs depende
}
```
The legacy `middleware` shape name covers CSP set in either Next.js
`middleware.*` files or the Next.js 16 `proxy.*` convention.
The `expectedAfter` file lives alongside `fixture.json` (not inside `files/`) and is a human/agent-review reference — tests don't auto-apply the patch.
The `runtime` block is optional. Fixtures without it only run the static unit checks (is-generated, inject, wrap, csp-detect). Fixtures *with* it additionally run the E2E suite in `tests/live-e2e.test.mjs` (`bun run test:live-e2e`), which:
@@ -0,0 +1,9 @@
import type { ReactNode } from "react";
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
@@ -0,0 +1,10 @@
import { NextResponse, type NextRequest } from "next/server";
export function proxy(request: NextRequest) {
const response = NextResponse.next({ request });
response.headers.set(
"Content-Security-Policy",
"default-src 'self'; script-src 'self' 'nonce-runtime'; connect-src 'self'",
);
return response;
}
@@ -0,0 +1,15 @@
{
"name": "Next.js 16 (proxy CSP)",
"config": {
"files": ["app/layout.tsx"],
"insertBefore": "</body>",
"commentSyntax": "jsx"
},
"sourceFiles": ["proxy.ts", "app/layout.tsx"],
"generatedFiles": [],
"wrapCases": [],
"csp": {
"shape": "middleware",
"signals": ["proxy.ts:Content-Security-Policy"]
}
}
@@ -0,0 +1,3 @@
node_modules/
.next/
out/