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:
Paul Bakaus
2026-09-02 14:10:46 -04:00
committed by GitHub
parent a264199177
commit 672ca29642
9 changed files with 413 additions and 11 deletions
+211 -5
View File
@@ -408,7 +408,7 @@ describe('loadContext (monorepo project context)', () => {
assert.equal(ctx.designPath, null);
});
it('resolves an explicit root target into a nested-git workspace child', () => {
it('keeps an explicit root target inside its nested Git repository', () => {
write('package.json', JSON.stringify({
private: true,
workspaces: ['repos/*'],
@@ -421,13 +421,13 @@ describe('loadContext (monorepo project context)', () => {
const project = path.join(scratch, 'repos', 'standalone');
const ctx = loadContext(scratch, { targetPath: 'repos/standalone/src/App.jsx' });
assert.equal(ctx.isMonorepo, true);
assert.equal(ctx.isMonorepo, false);
assert.equal(ctx.projectRoot, project);
assert.equal(ctx.repoRoot, scratch);
assert.equal(ctx.repoRoot, project);
assert.match(ctx.product, /Standalone product/);
assert.match(ctx.design, /Outer design/);
assert.equal(ctx.design, null);
assert.equal(ctx.productPath, path.join('repos', 'standalone', 'PRODUCT.md'));
assert.equal(ctx.designPath, 'DESIGN.md');
assert.equal(ctx.designPath, null);
});
it('supports double-star workspace patterns by resolving the shallow child project', () => {
@@ -1198,6 +1198,212 @@ 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:/);
fs.mkdirSync(path.join(repo, '.impeccable'), { recursive: true });
fs.writeFileSync(path.join(repo, '.impeccable', 'config.local.json'), JSON.stringify({
hook: { enabled: false },
}));
const disabled = 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(disabled.status, 0, disabled.stderr);
assert.match(disabled.stdout, /MANUAL_DETECTOR_REQUIRED:/);
});
it('does not borrow a hook manifest from the invoking workspace when targeting a sibling', () => {
const scripts = path.join(scratch, 'bundle', 'skills', 'impeccable', 'scripts');
stageContextBundle(scripts, { providerId: 'claude-code' });
const repo = path.join(scratch, 'repo');
const caller = path.join(repo, 'apps', 'marketing');
const target = path.join(repo, 'apps', 'dashboard');
fs.mkdirSync(path.join(repo, '.git'), { recursive: true });
fs.mkdirSync(path.join(caller, '.claude'), { recursive: true });
fs.mkdirSync(path.join(target, 'src'), { recursive: true });
fs.writeFileSync(path.join(repo, 'package.json'), JSON.stringify({ private: true, workspaces: ['apps/*'] }));
fs.writeFileSync(path.join(repo, 'turbo.json'), JSON.stringify({ tasks: {} }));
fs.writeFileSync(path.join(caller, 'package.json'), JSON.stringify({ name: 'marketing' }));
fs.writeFileSync(path.join(target, 'package.json'), JSON.stringify({ name: 'dashboard' }));
fs.writeFileSync(path.join(target, 'PRODUCT.md'), '# Dashboard\n');
fs.writeFileSync(path.join(target, 'src', 'App.jsx'), 'export default function App() { return "dashboard"; }\n');
fs.writeFileSync(path.join(caller, '.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'),
'--target',
path.join(target, 'src', 'App.jsx'),
], {
cwd: caller,
encoding: 'utf8',
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
});
assert.equal(res.status, 0, res.stderr);
assert.match(res.stdout, /MANUAL_DETECTOR_REQUIRED:/);
});
it('does not borrow an outer workspace hook for a target in a nested Git repository', () => {
const scripts = path.join(scratch, 'bundle', 'skills', 'impeccable', 'scripts');
stageContextBundle(scripts, { providerId: 'claude-code' });
const repo = path.join(scratch, 'repo');
const target = path.join(repo, 'repos', 'standalone');
fs.mkdirSync(path.join(repo, '.git'), { recursive: true });
fs.mkdirSync(path.join(repo, '.claude'), { recursive: true });
fs.mkdirSync(path.join(target, '.git'), { recursive: true });
fs.mkdirSync(path.join(target, 'src'), { recursive: true });
fs.writeFileSync(path.join(repo, 'package.json'), JSON.stringify({ private: true, workspaces: ['repos/*'] }));
fs.writeFileSync(path.join(repo, '.claude', 'settings.local.json'), JSON.stringify({
hooks: { Stop: [{ hooks: [{ command: 'node .claude/skills/impeccable/scripts/hook.mjs' }] }] },
}));
fs.writeFileSync(path.join(target, 'package.json'), JSON.stringify({ name: 'standalone' }));
fs.writeFileSync(path.join(target, 'PRODUCT.md'), '# Standalone\n');
fs.writeFileSync(path.join(target, 'src', 'App.jsx'), 'export default function App() { return "standalone"; }\n');
const res = spawnSync(process.execPath, [
path.join(scripts, 'context.mjs'),
'--target',
path.join('repos', 'standalone', 'src', 'App.jsx'),
], {
cwd: repo,
encoding: 'utf8',
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
});
assert.equal(res.status, 0, res.stderr);
assert.match(res.stdout, /"projectRoot": ".*\/repos\/standalone"/);
assert.match(res.stdout, /MANUAL_DETECTOR_REQUIRED:/);
});
it('treats a markerless nested Git target as an independent repository', () => {
const scripts = path.join(scratch, 'bundle', 'skills', 'impeccable', 'scripts');
stageContextBundle(scripts, { providerId: 'claude-code' });
const repo = path.join(scratch, 'repo');
const target = path.join(repo, 'repos', 'standalone');
fs.mkdirSync(path.join(repo, '.git'), { recursive: true });
fs.mkdirSync(path.join(repo, '.claude'), { recursive: true });
fs.mkdirSync(path.join(target, '.git'), { recursive: true });
fs.mkdirSync(path.join(target, 'src'), { recursive: true });
fs.writeFileSync(path.join(repo, 'package.json'), JSON.stringify({ private: true, workspaces: ['repos/*'] }));
fs.writeFileSync(path.join(repo, 'PRODUCT.md'), '# Outer product\n');
fs.writeFileSync(path.join(repo, '.claude', 'settings.local.json'), JSON.stringify({
hooks: { Stop: [{ hooks: [{ command: 'node .claude/skills/impeccable/scripts/hook.mjs' }] }] },
}));
fs.writeFileSync(path.join(target, 'src', 'App.jsx'), 'export default function App() { return "standalone"; }\n');
const res = spawnSync(process.execPath, [
path.join(scripts, 'context.mjs'),
'--target',
path.join('repos', 'standalone', 'src', 'App.jsx'),
], {
cwd: repo,
encoding: 'utf8',
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
});
assert.equal(res.status, 0, res.stderr);
assert.match(res.stdout, /"projectRoot": ".*\/repos\/standalone"/);
assert.match(res.stdout, /"repoRoot": ".*\/repos\/standalone"/);
assert.doesNotMatch(res.stdout, /# Outer product/);
assert.match(res.stdout, /MANUAL_DETECTOR_REQUIRED:/);
});
it('does not borrow the caller hook for a target in an independent sibling repository', () => {
const scripts = path.join(scratch, 'bundle', 'skills', 'impeccable', 'scripts');
stageContextBundle(scripts, { providerId: 'claude-code' });
const caller = path.join(scratch, 'caller');
const target = path.join(scratch, 'target');
fs.mkdirSync(path.join(caller, '.git'), { recursive: true });
fs.mkdirSync(path.join(caller, '.claude'), { recursive: true });
fs.mkdirSync(path.join(target, '.git'), { recursive: true });
fs.mkdirSync(path.join(target, 'src'), { recursive: true });
fs.writeFileSync(path.join(caller, 'PRODUCT.md'), '# Caller\n');
fs.writeFileSync(path.join(caller, '.claude', 'settings.local.json'), JSON.stringify({
hooks: { Stop: [{ hooks: [{ command: 'node .claude/skills/impeccable/scripts/hook.mjs' }] }] },
}));
fs.writeFileSync(path.join(target, 'PRODUCT.md'), '# Target\n');
fs.writeFileSync(path.join(target, 'src', 'App.jsx'), 'export default function App() { return "target"; }\n');
const res = spawnSync(process.execPath, [
path.join(scripts, 'context.mjs'),
'--target',
path.join('..', 'target', 'src', 'App.jsx'),
], {
cwd: caller,
encoding: 'utf8',
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
});
assert.equal(res.status, 0, res.stderr);
assert.match(res.stdout, /"projectRoot": ".*\/target"/);
assert.match(res.stdout, /"repoRoot": ".*\/target"/);
assert.match(res.stdout, /# Target/);
assert.doesNotMatch(res.stdout, /# Caller/);
assert.match(res.stdout, /MANUAL_DETECTOR_REQUIRED:/);
});
it('does not treat a home-directory Git checkout as an external target repository', () => {
const scripts = path.join(scratch, 'bundle', 'skills', 'impeccable', 'scripts');
stageContextBundle(scripts, { providerId: 'claude-code' });
const fakeHome = path.join(scratch, 'home');
const caller = path.join(fakeHome, 'caller');
const target = path.join(fakeHome, 'target');
fs.mkdirSync(path.join(fakeHome, '.git'), { recursive: true });
fs.mkdirSync(path.join(fakeHome, '.claude'), { recursive: true });
fs.mkdirSync(caller, { recursive: true });
fs.mkdirSync(target, { recursive: true });
fs.writeFileSync(path.join(fakeHome, 'PRODUCT.md'), '# Home product\n');
fs.writeFileSync(path.join(fakeHome, '.claude', 'settings.local.json'), JSON.stringify({
hooks: { Stop: [{ hooks: [{ command: 'node .claude/skills/impeccable/scripts/hook.mjs' }] }] },
}));
fs.writeFileSync(path.join(target, 'PRODUCT.md'), '# Target product\n');
const res = spawnSync(process.execPath, [
path.join(scripts, 'context.mjs'),
'--target',
target,
], {
cwd: caller,
encoding: 'utf8',
env: {
...process.env,
HOME: fakeHome,
IMPECCABLE_NO_UPDATE_CHECK: '1',
IMPECCABLE_NO_STALENESS_CHECK: '1',
},
});
assert.equal(res.status, 0, res.stderr);
assert.match(res.stdout, /"projectRoot": ".*\/target"/);
assert.match(res.stdout, /"repoRoot": ".*\/target"/);
assert.match(res.stdout, /# Target product/);
assert.doesNotMatch(res.stdout, /# Home product/);
assert.match(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' });
+36
View File
@@ -284,3 +284,39 @@ for (const name of listFixtures()) {
});
});
}
describe('detectCsp — Next.js proxy placement', () => {
it('accepts proxy files at app roots and src roots but ignores same-named helpers', () => {
const source = `export function proxy() {
const response = new Response();
response.headers.set('Content-Security-Policy', "script-src 'self'");
return response;
}\n`;
for (const [relPath, expectedShape, markers = []] of [
['proxy.ts', 'middleware'],
['src/proxy.ts', 'middleware'],
['apps/web/proxy.ts', 'middleware', ['apps/web/app']],
['apps/docs/src/proxy.ts', 'middleware', ['apps/docs/src/pages']],
['apps/store/proxy.ts', 'middleware', ['apps/store/package.json']],
['lib/network/proxy.ts', null],
['apps/web/lib/proxy.ts', null, ['apps/web/app']],
]) {
const tmp = mkdtempSync(join(tmpdir(), 'impeccable-proxy-placement-'));
try {
mkdirSync(dirname(join(tmp, relPath)), { recursive: true });
for (const marker of markers) {
if (marker.endsWith('package.json')) {
mkdirSync(dirname(join(tmp, marker)), { recursive: true });
writeFileSync(join(tmp, marker), JSON.stringify({ dependencies: { next: '^16.0.0' } }));
} else {
mkdirSync(join(tmp, marker), { recursive: true });
}
}
writeFileSync(join(tmp, relPath), source);
assert.equal(detectCsp(tmp).shape, expectedShape, relPath);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
}
});
});
+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/