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": "", + "commentSyntax": "jsx" + }, + "sourceFiles": ["proxy.ts", "app/layout.tsx"], + "generatedFiles": [], + "wrapCases": [], + "csp": { + "shape": "middleware", + "signals": ["proxy.ts:Content-Security-Policy"] + } +} diff --git a/tests/framework-fixtures/nextjs-proxy-csp/gitignore.txt b/tests/framework-fixtures/nextjs-proxy-csp/gitignore.txt new file mode 100644 index 000000000..7c8ed2342 --- /dev/null +++ b/tests/framework-fixtures/nextjs-proxy-csp/gitignore.txt @@ -0,0 +1,3 @@ +node_modules/ +.next/ +out/