feat(live): CSP detection + consent-gated patch flow at first-time setup

Real-world tests (EAC Next turborepo) confirmed that CSP is the common
blocker for live mode. Adds setup-time detection with a one-time user
consent flow — the patch becomes a permanent, dev-guarded entry in the
user's own config, not a transient add/remove.

## Changes

- New detect-csp.mjs helper: grep-based classifier returning
  { shape, signals }. Shape is one of:
    - "shared-helper" (monorepo CSP helper with additional*Src arrays)
    - "inline-headers" (literal CSP string in headers())
    - "middleware" (response.headers.set in middleware.ts; detect-only v1)
    - "meta-tag" (<meta http-equiv>; detect-only v1)
    - null (no CSP)
  Max depth 6, skips node_modules / build / cache dirs, 64KB per file.

- cspChecked boolean on config.json. First-run setup runs detection;
  subsequent runs skip. Users re-trigger by deleting the flag.
  Validator accepts it.

- Skill live.md gains:
    - CSP detection step in first-time setup (gated by cspChecked)
    - Consent-prompt template (so every agent phrases it the same way)
    - Shape 1 patch template: append `...__impeccableLiveDev` to
      additionalScriptSrc/additionalConnectSrc in the app's config
    - Shape 2 patch template: two-point edit — declare a dev-only
      variable, interpolate into script-src and connect-src in the
      CSP literal string
    - Troubleshooting note for "said no but now live doesn't work"

## Fixtures

- nextjs-turborepo/: Turborepo shape (shared CSP helper with
  additionalScriptSrc options). Sanitized from a real monorepo so the
  patch mechanics get tested against realistic layering. Includes
  expected-after-patch.ts for human/agent review.

- nextjs-inline-csp/: app-level next.config.js with a literal CSP
  string. Includes expected-after-patch.js showing the Shape 2 edit.

## Tests

Framework-fixture harness extended with a detect-csp shape-classification
assertion per fixture. 42 tests across 7 fixtures pass. Clean fixtures
(vite-react, nextjs-app, astro, sveltekit, multipage-with-generator)
correctly return shape: null.

## Deliberately not doing

- No patches[] array, no marker-based rollback, no add/remove lifecycle.
  The patch is a permanent dev-guarded config line — the same kind of
  edit a user would make themselves.
- No base URL rewriting or proxy mechanism. Script tag still points at
  localhost:8400; CSP permits it once patched. No browser-side changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-04-21 23:41:11 -07:00
co-authored by Claude Opus 4.7
parent 444f881295
commit d5480caee3
50 changed files with 3601 additions and 13 deletions
+16
View File
@@ -18,6 +18,7 @@ import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { isGeneratedFile } from '../source/skills/impeccable/scripts/is-generated.mjs';
import { detectCsp } from '../source/skills/impeccable/scripts/detect-csp.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SCRIPTS_DIR = join(__dirname, '..', 'source', 'skills', 'impeccable', 'scripts');
@@ -149,6 +150,21 @@ for (const name of listFixtures()) {
}
});
it('detect-csp classifies CSP shape correctly', () => {
const { tmp, fixture } = stageFixture(name);
try {
const expected = fixture.csp?.shape ?? null;
const result = detectCsp(tmp);
assert.equal(
result.shape,
expected,
`expected CSP shape ${expected}, got ${result.shape}; signals: ${JSON.stringify(result.signals)}`
);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
it('live-wrap routes to the expected source (or emits the expected fallback)', () => {
const { tmp, fixture } = stageFixture(name);
try {
+11 -1
View File
@@ -26,10 +26,18 @@ Representative project shapes for exercising live mode against different framewo
"expectedFile": "where wrap should land (relative to fixture root)",
"expectsError": "optional error code, e.g. element_not_in_source"
}
]
],
"csp": {
"shape": "shared-helper | inline-headers | middleware | meta-tag | null",
"signals": ["diagnostic hints — paths where CSP was detected"],
"patchTarget": "which file the agent should modify",
"expectedAfter": "filename of the reference post-patch output inside this fixture"
}
}
```
The `expectedAfter` file lives alongside `fixture.json` (not inside `files/`) and is a human/agent-review reference — tests don't auto-apply the patch.
## Current fixtures
| Fixture | Shape |
@@ -39,5 +47,7 @@ Representative project shapes for exercising live mode against different framewo
| `astro/` | `src/layouts/Layout.astro` as inject target. HTML comments. |
| `sveltekit/` | `src/app.html` shell + `src/routes/+page.svelte`. |
| `multipage-with-generator/` | `src/` tracked, `dist/` gitignored. Exercises the is-generated guard and `element_not_in_source` fallback. |
| `nextjs-turborepo/` | Monorepo with shared CSP helper (`createBaseNextConfig`). Exercises CSP shape 1 (shared-helper). |
| `nextjs-inline-csp/` | App-level `next.config.js` with a literal CSP string. Exercises CSP shape 2 (inline-headers). |
Add new fixtures by cloning a directory, swapping files, and updating `fixture.json`.
@@ -0,0 +1,33 @@
// Reference output for agent/human review — not executed by tests.
// After the Shape 2 (inline-headers) CSP patch is applied, next.config.js
// should look like this.
/** @type {import('next').NextConfig} */
// Dev-only allowance so impeccable live mode can load. Empty string in any
// non-development environment.
const __impeccableLiveDev =
process.env.NODE_ENV === "development" ? " http://localhost:8400" : "";
module.exports = {
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "Content-Security-Policy",
value:
"default-src 'self'; " +
`script-src 'self' 'unsafe-inline' 'unsafe-eval'${__impeccableLiveDev}; ` +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: blob:; " +
`connect-src 'self'${__impeccableLiveDev}; ` +
"frame-ancestors 'self';",
},
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
],
},
];
},
};
@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import type React from "react";
export const metadata: Metadata = {
title: "Inline CSP Fixture",
description: "Minimal app with a literal CSP header for live-mode tests.",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
@@ -0,0 +1,23 @@
/** @type {import('next').NextConfig} */
module.exports = {
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "Content-Security-Policy",
value:
"default-src 'self'; " +
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: blob:; " +
"connect-src 'self'; " +
"frame-ancestors 'self';",
},
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
],
},
];
},
};
@@ -0,0 +1,21 @@
{
"name": "Next.js (inline CSP headers)",
"config": {
"files": ["app/layout.tsx"],
"insertBefore": "</body>",
"commentSyntax": "jsx"
},
"sourceFiles": ["next.config.js", "app/layout.tsx"],
"generatedFiles": [],
"wrapCases": [],
"csp": {
"shape": "inline-headers",
"signals": [
"next.config.js:Content-Security-Policy",
"next.config.js:script-src",
"next.config.js:connect-src"
],
"patchTarget": "next.config.js",
"expectedAfter": "expected-after-patch.js"
}
}
@@ -0,0 +1,3 @@
node_modules/
.next/
out/
@@ -0,0 +1,48 @@
// Reference output for agent/human review — not executed by tests.
// After the Shape 1 (shared-helper) CSP patch is applied, apps/web/next.config.ts
// should look like this.
import {
buildSupabaseRemotePatterns,
createBaseNextConfig,
} from "@app/shared/next-config";
import type { NextConfig } from "next";
const posthogHost =
process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com";
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV;
// empty array in any non-development environment.
const __impeccableLiveDev =
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
const baseConfig = createBaseNextConfig({
appName: "web",
enableMapbox: true,
additionalImgSrc: ["https:", "https://*.googleusercontent.com"],
additionalScriptSrc: [posthogHost, ...__impeccableLiveDev],
additionalConnectSrc: [posthogHost, ...__impeccableLiveDev],
});
const nextConfig: NextConfig = {
...baseConfig,
devIndicators: {
position: "bottom-right",
},
experimental: {
...baseConfig.experimental,
},
typescript: {
ignoreBuildErrors: true,
},
images: {
remotePatterns: buildSupabaseRemotePatterns(),
dangerouslyAllowLocalIP: process.env.NODE_ENV === "development",
},
};
export default nextConfig;
@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import type React from "react";
export const metadata: Metadata = {
title: "Turborepo Fixture",
description: "Minimal monorepo app layout for live-mode tests.",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
@@ -0,0 +1,39 @@
import {
buildSupabaseRemotePatterns,
createBaseNextConfig,
} from "@app/shared/next-config";
import type { NextConfig } from "next";
const posthogHost =
process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com";
const baseConfig = createBaseNextConfig({
appName: "web",
enableMapbox: true,
additionalImgSrc: ["https:", "https://*.googleusercontent.com"],
additionalScriptSrc: [posthogHost],
additionalConnectSrc: [posthogHost],
});
const nextConfig: NextConfig = {
...baseConfig,
devIndicators: {
position: "bottom-right",
},
experimental: {
...baseConfig.experimental,
},
typescript: {
ignoreBuildErrors: true,
},
images: {
remotePatterns: buildSupabaseRemotePatterns(),
dangerouslyAllowLocalIP: process.env.NODE_ENV === "development",
},
};
export default nextConfig;
@@ -0,0 +1,287 @@
/**
* Shared Next.js configuration utilities
*
* Reusable configuration for Next.js apps in a monorepo: CSP headers,
* webpack tweaks, remote image patterns, rewrites.
*
* Derived from a real monorepo shape — sanitized of company-specific identifiers
* but structurally identical so patch mechanics get tested against realistic
* CSP/rewrite layering.
*/
import type { NextConfig } from "next";
import {
buildConnectSrc,
getSupabaseOrigin,
HSTS_VALUE,
PERMISSIONS_POLICY_VALUE,
} from "../security/origins";
export interface SharedNextConfigOptions {
appName?: string;
additionalImgSrc?: string[];
additionalConnectSrc?: string[];
additionalScriptSrc?: string[];
enableMapbox?: boolean;
serverExternalPackages?: string[];
optimizePackageImports?: string[];
transpilePackages?: string[];
}
const DEFAULT_WORKSPACE_TRANSPILE_PACKAGES = [
"@app/backend",
"@app/database",
"@app/mcp-ui",
"@app/shared",
"@app/supabase-client",
"@app/ui",
];
const KNOWN_SUPABASE_CUSTOM_DOMAINS = [
"db.example.com",
"db-staging.example.com",
] as const;
const KNOWN_SUPABASE_CUSTOM_ORIGINS = KNOWN_SUPABASE_CUSTOM_DOMAINS.map(
(h) => `https://${h}`
);
interface CSPConfig {
scriptSrc: string;
styleSrc: string;
imgSrc: string;
fontSrc: string;
connectSrc: string;
frameSrc: string;
workerSrc: string;
childSrc: string;
}
function buildCSPConfig(options: SharedNextConfigOptions = {}): CSPConfig {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "";
const supabaseOrigin = getSupabaseOrigin();
const connectSrcBase = buildConnectSrc(apiUrl);
const isPreview = true;
const scriptSrc = [
"'self'",
"'unsafe-eval'",
"'unsafe-inline'",
"https://va.vercel-scripts.com",
...(isPreview ? ["https://vercel.live"] : []),
...(options.additionalScriptSrc || []),
].join(" ");
const styleSrc = [
"'self'",
"'unsafe-inline'",
...(isPreview ? ["https://fonts.googleapis.com"] : []),
].join(" ");
const imgSrc = [
"'self'",
"data:",
"blob:",
"http://localhost:54321",
"http://127.0.0.1:54321",
"https://*.supabase.co",
...KNOWN_SUPABASE_CUSTOM_ORIGINS,
...(supabaseOrigin &&
!KNOWN_SUPABASE_CUSTOM_ORIGINS.includes(
supabaseOrigin as (typeof KNOWN_SUPABASE_CUSTOM_ORIGINS)[number]
)
? [supabaseOrigin]
: []),
...(options.enableMapbox
? ["https://api.mapbox.com", "https://*.tiles.mapbox.com"]
: []),
...(isPreview ? ["https://vercel.com", "https://vercel.live"] : []),
...(options.additionalImgSrc || []),
].join(" ");
const fontSrc = [
"'self'",
"data:",
...(options.enableMapbox ? ["https://api.mapbox.com"] : []),
...(isPreview ? ["https://fonts.gstatic.com", "https://vercel.live"] : []),
].join(" ");
const connectExtras = [
...(options.enableMapbox
? [
"https://api.mapbox.com",
"https://*.tiles.mapbox.com",
"https://events.mapbox.com",
]
: []),
...(isPreview
? ["https://vercel.live", "wss://*.pusher.com", "https://*.pusher.com"]
: []),
...(options.additionalConnectSrc || []),
];
const connectSrc = [...connectSrcBase, ...connectExtras].join(" ");
const frameSrc = isPreview ? "'self' https://vercel.live" : "'self'";
const workerSrc = "'self' blob:";
const childSrc = "'self' blob:";
return {
scriptSrc,
styleSrc,
imgSrc,
fontSrc,
connectSrc,
frameSrc,
workerSrc,
childSrc,
};
}
export function buildSecurityHeaders(
options: SharedNextConfigOptions = {}
): NextConfig["headers"] {
return () => {
const csp = buildCSPConfig(options);
const isPreview = true;
return Promise.resolve([
{
source: "/(.*)",
headers: [
{
key: "Content-Security-Policy",
value: `default-src 'self'; script-src ${csp.scriptSrc}; style-src ${csp.styleSrc}; img-src ${csp.imgSrc}; font-src ${csp.fontSrc}; connect-src ${csp.connectSrc}; frame-src ${csp.frameSrc}; worker-src ${csp.workerSrc}; child-src ${csp.childSrc}; frame-ancestors 'self';`,
},
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
{ key: "Strict-Transport-Security", value: HSTS_VALUE },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: PERMISSIONS_POLICY_VALUE },
{ key: "Cross-Origin-Embedder-Policy", value: "unsafe-none" },
{ key: "Cross-Origin-Resource-Policy", value: "cross-origin" },
{
key: "Cross-Origin-Opener-Policy",
value: "same-origin-allow-popups",
},
...(isPreview
? [
{
key: "Access-Control-Allow-Origin",
value: "https://vercel.live",
},
{
key: "Access-Control-Allow-Methods",
value: "GET,POST,PUT,PATCH,DELETE,OPTIONS",
},
{ key: "Access-Control-Allow-Headers", value: "*" },
{ key: "Access-Control-Allow-Credentials", value: "true" },
{ key: "Vary", value: "Origin" },
]
: []),
],
},
]);
};
}
export function buildApiProxyRewrites(): NextConfig["rewrites"] {
return () => {
const rewrites: Array<{ source: string; destination: string }> = [];
if (
process.env.NEXT_PUBLIC_API_PROXY === "1" &&
process.env.NEXT_PUBLIC_API_URL
) {
const proxyPath = process.env.NEXT_PUBLIC_API_PROXY_PATH || "/backend";
rewrites.push({
source: `${proxyPath}/:path*`,
destination: `${process.env.NEXT_PUBLIC_API_URL}/:path*`,
});
}
return {
beforeFiles: rewrites,
afterFiles: [],
fallback: [],
};
};
}
type WebpackConfig = Parameters<NonNullable<NextConfig["webpack"]>>[0];
type WebpackContext = Parameters<NonNullable<NextConfig["webpack"]>>[1];
export function buildWebpackConfig(
options: SharedNextConfigOptions = {}
): NextConfig["webpack"] {
return (config: WebpackConfig, context: WebpackContext) => {
if (context.isServer && options.serverExternalPackages?.length) {
config.externals = config.externals || [];
if (Array.isArray(config.externals)) {
for (const pkg of options.serverExternalPackages) {
config.externals.push({ [pkg]: `commonjs ${pkg}` });
}
}
}
return config;
};
}
export function buildSupabaseRemotePatterns(): Array<{
protocol: "http" | "https";
hostname: string;
port?: string;
pathname: string;
}> {
return [
{
protocol: "http",
hostname: "localhost",
port: "54321",
pathname: "/storage/v1/object/public/**",
},
{
protocol: "https",
hostname: "*.supabase.co",
pathname: "/storage/v1/object/public/**",
},
...KNOWN_SUPABASE_CUSTOM_DOMAINS.map((hostname) => ({
protocol: "https" as const,
hostname,
pathname: "/storage/v1/object/public/**",
})),
];
}
/**
* Create a shared Next.js configuration base. Apps extend this via spread.
*/
export function createBaseNextConfig(
options: SharedNextConfigOptions = {}
): NextConfig {
const transpilePackages = Array.from(
new Set([
...DEFAULT_WORKSPACE_TRANSPILE_PACKAGES,
...(options.transpilePackages || []),
])
);
return {
experimental: {
turbopackFileSystemCacheForDev: true,
...(options.optimizePackageImports && {
optimizePackageImports: options.optimizePackageImports,
}),
},
env: {
VERCEL_RELATED_PROJECTS: process.env.VERCEL_RELATED_PROJECTS || "",
VERCEL_ENV: process.env.VERCEL_ENV || "",
},
...(options.serverExternalPackages && {
serverExternalPackages: options.serverExternalPackages,
}),
transpilePackages,
webpack: buildWebpackConfig(options),
headers: buildSecurityHeaders(options),
rewrites: buildApiProxyRewrites(),
};
}
@@ -0,0 +1,82 @@
/*
Shared security helpers for CORS/CSP across apps.
Configure once via env: ALLOWED_ORIGINS (CSV), ALLOW_VERCEL_PREVIEWS (1/0),
VERCEL_TEAM_SLUG, ALLOW_LOCALHOST_ORIGINS (1/0).
Derived from a real monorepo shape — sanitized of company-specific identifiers
but structurally identical so patch mechanics get tested against realistic
CSP/rewrite layering.
*/
export interface CorsPolicyOptions {
allowVercelPreviews?: boolean;
vercelTeamSlug?: string;
allowLocalhost?: boolean;
}
export function parseCsvEnv(value?: string | null): string[] {
return (value || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
export function getBaseAllowedOrigins(): string[] {
const fromCsv = parseCsvEnv(process.env.ALLOWED_ORIGINS);
return Array.from(new Set([...fromCsv]));
}
const LOCALHOST_REGEX = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/;
export function localhostRegex(): RegExp {
return LOCALHOST_REGEX;
}
export function vercelPreviewRegex(teamSlug: string): RegExp {
const safeSlug = teamSlug.replace(/[^a-z0-9-]/gi, "");
return new RegExp(`^https:\\/\\/.*-${safeSlug}\\.vercel\\.app$`, "i");
}
export const PERMISSIONS_POLICY_VALUE =
"accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(self), payment=(), usb=()";
export const HSTS_VALUE = "max-age=31536000; includeSubDomains; preload";
export function getSupabaseOrigin(): string {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL;
if (!url) return "";
try {
return new URL(url).origin;
} catch {
return "";
}
}
export function buildConnectSrc(apiUrl?: string): string[] {
const apiOrigin = apiUrl ? new URL(apiUrl).origin : "";
const base: string[] = ["'self'", "https://*.supabase.co"];
// Only include localhost allowances outside production
if (process.env.NODE_ENV !== "production") {
base.push(
"http://localhost:*",
"http://127.0.0.1:*",
"ws://localhost:*",
"ws://127.0.0.1:*"
);
}
const allowPreviews = (process.env.ALLOW_VERCEL_PREVIEWS || "1") !== "0";
if (allowPreviews) {
base.push("https://*.vercel.app");
}
if (apiOrigin) base.push(apiOrigin);
const supabaseOrigin = getSupabaseOrigin();
if (supabaseOrigin && !base.includes(supabaseOrigin)) {
base.push(supabaseOrigin);
}
return base;
}
@@ -0,0 +1,32 @@
{
"name": "Next.js (Turborepo, shared CSP helper)",
"config": {
"files": ["apps/web/app/layout.tsx"],
"insertBefore": "</body>",
"commentSyntax": "jsx"
},
"sourceFiles": [
"apps/web/next.config.ts",
"apps/web/app/layout.tsx",
"packages/shared/src/next-config/index.ts",
"packages/shared/src/security/origins.ts"
],
"generatedFiles": [],
"wrapCases": [
{
"name": "wraps element in app source",
"args": { "classes": "page", "tag": "main" },
"expectsError": "element_not_found"
}
],
"csp": {
"shape": "shared-helper",
"signals": [
"packages/shared/src/next-config/index.ts:buildCSPConfig",
"packages/shared/src/next-config/index.ts:additionalScriptSrc",
"apps/web/next.config.ts:additionalScriptSrc"
],
"patchTarget": "apps/web/next.config.ts",
"expectedAfter": "expected-after-patch.ts"
}
}
@@ -0,0 +1,4 @@
node_modules/
.next/
.turbo/
out/