diff --git a/skill/scripts/context.mjs b/skill/scripts/context.mjs
index cb16553c2..d50aae6eb 100644
--- a/skill/scripts/context.mjs
+++ b/skill/scripts/context.mjs
@@ -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)) {
diff --git a/skill/scripts/detect-csp.mjs b/skill/scripts/detect-csp.mjs
index a13505d23..0b5a19772 100644
--- a/skill/scripts/detect-csp.mjs
+++ b/skill/scripts/detect-csp.mjs
@@ -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": 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);
}
diff --git a/tests/context.test.mjs b/tests/context.test.mjs
index c747164df..95b0a22ef 100644
--- a/tests/context.test.mjs
+++ b/tests/context.test.mjs
@@ -1198,6 +1198,29 @@ describe('context.mjs CLI', () => {
assert.match(disabled.stdout, /detect\.mjs --json /);
});
+ 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' });
diff --git a/tests/framework-fixtures/README.md b/tests/framework-fixtures/README.md
index a4f871ebb..2251863d6 100644
--- a/tests/framework-fixtures/README.md
+++ b/tests/framework-fixtures/README.md
@@ -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:
diff --git a/tests/framework-fixtures/nextjs-proxy-csp/files/app/layout.tsx b/tests/framework-fixtures/nextjs-proxy-csp/files/app/layout.tsx
new file mode 100644
index 000000000..e53180eeb
--- /dev/null
+++ b/tests/framework-fixtures/nextjs-proxy-csp/files/app/layout.tsx
@@ -0,0 +1,9 @@
+import type { ReactNode } from "react";
+
+export default function RootLayout({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/tests/framework-fixtures/nextjs-proxy-csp/files/proxy.ts b/tests/framework-fixtures/nextjs-proxy-csp/files/proxy.ts
new file mode 100644
index 000000000..fe03376fb
--- /dev/null
+++ b/tests/framework-fixtures/nextjs-proxy-csp/files/proxy.ts
@@ -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;
+}
diff --git a/tests/framework-fixtures/nextjs-proxy-csp/fixture.json b/tests/framework-fixtures/nextjs-proxy-csp/fixture.json
new file mode 100644
index 000000000..b82c8c5db
--- /dev/null
+++ b/tests/framework-fixtures/nextjs-proxy-csp/fixture.json
@@ -0,0 +1,15 @@
+{
+ "name": "Next.js 16 (proxy CSP)",
+ "config": {
+ "files": ["app/layout.tsx"],
+ "insertBefore": "