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
+77 -1
View File
@@ -291,10 +291,13 @@ Schema:
{
"files": ["<path>", "<path>", ...],
"insertBefore": "</body>",
"commentSyntax": "html"
"commentSyntax": "html",
"cspChecked": true
}
```
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
| Framework | `files` | `insertBefore` | `commentSyntax` |
@@ -311,4 +314,77 @@ Pick an anchor that exists in every file (`</body>` almost always works). Use `i
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
### CSP detection (first-time only)
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
Otherwise, run the detection helper:
```bash
node {{scripts_path}}/detect-csp.mjs
```
Output: `{ shape, signals }` where `shape` is one of `shared-helper`, `inline-headers`, `middleware`, `meta-tag`, or `null`.
- **`null`** — no CSP; skip to writing `config.json` with `cspChecked: true`.
- **`shared-helper`** — monorepo with a CSP builder that accepts `additionalScriptSrc` / `additionalConnectSrc` arrays. Auto-patchable. See *Shape 1* below.
- **`inline-headers`** — CSP built as a literal string inside `next.config.*` (or equivalent) `headers()`. Auto-patchable. See *Shape 2* below.
- **`middleware`** or **`meta-tag`** — rarer. Detected but not auto-patched in v1. Show the user the detected files and ask them to add `http://localhost:8400` to `script-src` and `connect-src` manually, then mark `cspChecked: true` and proceed.
#### Consent prompt template
Use this phrasing so the experience is consistent across agents:
> **CSP patch needed.** I detected a Content Security Policy in your project that blocks `http://localhost:8400` — the live picker won't load without an allowance. Here's the change I'd make:
>
> ```diff
> [file: <patchTarget>]
> [exact diff, 25 lines]
> ```
>
> It's guarded by `NODE_ENV === "development"` so the extra entry only appears in dev and never reaches production. You can remove it any time by reverting this file. Apply? [y/n]
On "no": skip the patch, mention live won't work until the user adds the allowance manually, still write `cspChecked: true` (the question's been asked).
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
#### Shape 1 — shared helper with `additional*Src` arrays
The app config calls something like `createBaseNextConfig({ additionalScriptSrc: [...], additionalConnectSrc: [...] })`. Patch the *app's* config (not the shared helper) so the monorepo root stays clean. Add near the top of the app's `next.config.ts`:
```ts
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
const __impeccableLiveDev =
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
```
Append `...__impeccableLiveDev` to both `additionalScriptSrc` and `additionalConnectSrc` array options.
Idempotency: if `__impeccableLiveDev` already exists in the file, the patch is already applied; skip asking and just mark `cspChecked: true`.
See `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` for the full desired output.
#### Shape 2 — inline CSP string in `headers()`
A literal CSP string inside a `headers()` function or return value. Two-point patch: declare a dev-only variable near the top, interpolate it into the CSP string at the `script-src` and `connect-src` segments.
```ts
const __impeccableLiveDev =
process.env.NODE_ENV === "development" ? " http://localhost:8400" : "";
```
Then, inside the CSP value string:
- `script-src 'self' 'unsafe-inline'``script-src 'self' 'unsafe-inline'${__impeccableLiveDev}`
- `connect-src 'self'``connect-src 'self'${__impeccableLiveDev}`
(Leading space on the dev string so it concatenates cleanly into the existing value.)
Read the current file, locate the exact CSP string, quote the two directive edits in the consent prompt, write after confirmation.
See `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` for the full desired output.
### Troubleshooting
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs` — setup will ask again.
Then re-run `live.mjs`.
@@ -0,0 +1,167 @@
/**
* Scan a project tree for Content-Security-Policy signals and classify the
* shape so the agent knows which patch template to propose.
*
* Used at first-time `live.mjs` setup. Mechanical (grep-based) — no network,
* no dev server, no JS evaluation. The classification drives a user-facing
* consent prompt; the agent does the actual patch writing.
*
* Shape taxonomy:
* - "shared-helper": monorepo with a `createBaseNextConfig`-style helper
* that accepts `additionalScriptSrc`/`additionalConnectSrc`
* arrays. Patch the app's config to append a dev-only
* localhost entry to those arrays.
* - "inline-headers": Content-Security-Policy built inline in a Next/Nuxt/
* SvelteKit config's headers() function with a literal
* value string. Patch the CSP string in place.
* - "middleware": CSP set in middleware.{ts,js}. 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.
*/
import fs from 'node:fs';
import path from 'node:path';
const SKIP_DIRS = new Set([
'node_modules',
'.git',
'.next',
'.turbo',
'.svelte-kit',
'.nuxt',
'.astro',
'dist',
'build',
'out',
'.vercel',
]);
const SCAN_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.tsx', '.jsx']);
const LAYOUT_EXTS = new Set(['.tsx', '.jsx', '.astro', '.vue', '.svelte', '.html']);
const MAX_DEPTH = 6;
const MAX_READ_BYTES = 64 * 1024;
const SHARED_HELPER_SIGNALS = [
/\bbuildCSPConfig\b/,
/\bbuildSecurityHeaders\b/,
/\badditionalScriptSrc\b/,
/\badditionalConnectSrc\b/,
/\bcreateBaseNextConfig\b/,
];
const INLINE_HEADER_SIGNALS = [
/["']Content-Security-Policy["']/i,
/\bscript-src\b/,
/\bconnect-src\b/,
];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
*/
export function detectCsp(cwd = process.cwd()) {
const hits = { sharedHelper: [], inlineHeader: [], middleware: [], metaTag: [] };
walk(cwd, cwd, 0, (absPath, relPath, body) => {
const ext = path.extname(absPath);
const base = path.basename(absPath).toLowerCase();
// Shared helper: package exports, config factory
if (SCAN_EXTS.has(ext)) {
const matched = SHARED_HELPER_SIGNALS.some((re) => re.test(body));
const looksShared = /packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath);
if (matched && looksShared) {
hits.sharedHelper.push(relPath);
}
}
// Inline headers: Next/Nuxt/SvelteKit/Astro/Vite config files
if (SCAN_EXTS.has(ext) && /(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath)) {
const allInlineMatch = INLINE_HEADER_SIGNALS.every((re) => re.test(body));
if (allInlineMatch) {
hits.inlineHeader.push(relPath);
}
}
// Middleware CSP: middleware.{ts,js} at project root or app/
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
// Meta tag CSP: layouts / HTML files
if (LAYOUT_EXTS.has(ext) && META_TAG_HINT.test(body)) {
hits.metaTag.push(relPath);
}
});
// Classification priority: shared-helper > inline-headers > middleware > meta-tag.
// A monorepo with a shared helper is always that shape, even if an individual
// app file also happens to contain a CSP literal.
if (hits.sharedHelper.length > 0) {
return {
shape: 'shared-helper',
signals: hits.sharedHelper,
};
}
if (hits.inlineHeader.length > 0) {
return {
shape: 'inline-headers',
signals: hits.inlineHeader,
};
}
if (hits.middleware.length > 0) {
return {
shape: 'middleware',
signals: hits.middleware,
};
}
if (hits.metaTag.length > 0) {
return {
shape: 'meta-tag',
signals: hits.metaTag,
};
}
return { shape: null, signals: [] };
}
function walk(root, dir, depth, visit) {
if (depth > MAX_DEPTH) return;
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return; }
for (const entry of entries) {
const abs = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
walk(root, abs, depth + 1, visit);
continue;
}
if (!entry.isFile()) continue;
const ext = path.extname(entry.name);
if (!SCAN_EXTS.has(ext) && !LAYOUT_EXTS.has(ext)) continue;
let body;
try {
const fd = fs.openSync(abs, 'r');
try {
const buf = Buffer.alloc(MAX_READ_BYTES);
const n = fs.readSync(fd, buf, 0, MAX_READ_BYTES, 0);
body = buf.slice(0, n).toString('utf-8');
} finally { fs.closeSync(fd); }
} catch { continue; }
visit(abs, path.relative(root, abs), body);
}
}
// CLI mode
const _running = process.argv[1];
if (_running?.endsWith('detect-csp.mjs') || _running?.endsWith('detect-csp.mjs/')) {
const result = detectCsp(process.cwd());
console.log(JSON.stringify(result, null, 2));
}
@@ -126,6 +126,9 @@ function validateConfig(cfg) {
if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') {
throw new Error("config.commentSyntax must be 'html' or 'jsx'");
}
if (cfg.cspChecked !== undefined && typeof cfg.cspChecked !== 'boolean') {
throw new Error("config.cspChecked, if present, must be a boolean");
}
}
function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }
+77 -1
View File
@@ -291,10 +291,13 @@ Schema:
{
"files": ["<path>", "<path>", ...],
"insertBefore": "</body>",
"commentSyntax": "html"
"commentSyntax": "html",
"cspChecked": true
}
```
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
| Framework | `files` | `insertBefore` | `commentSyntax` |
@@ -311,4 +314,77 @@ Pick an anchor that exists in every file (`</body>` almost always works). Use `i
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
### CSP detection (first-time only)
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
Otherwise, run the detection helper:
```bash
node {{scripts_path}}/detect-csp.mjs
```
Output: `{ shape, signals }` where `shape` is one of `shared-helper`, `inline-headers`, `middleware`, `meta-tag`, or `null`.
- **`null`** — no CSP; skip to writing `config.json` with `cspChecked: true`.
- **`shared-helper`** — monorepo with a CSP builder that accepts `additionalScriptSrc` / `additionalConnectSrc` arrays. Auto-patchable. See *Shape 1* below.
- **`inline-headers`** — CSP built as a literal string inside `next.config.*` (or equivalent) `headers()`. Auto-patchable. See *Shape 2* below.
- **`middleware`** or **`meta-tag`** — rarer. Detected but not auto-patched in v1. Show the user the detected files and ask them to add `http://localhost:8400` to `script-src` and `connect-src` manually, then mark `cspChecked: true` and proceed.
#### Consent prompt template
Use this phrasing so the experience is consistent across agents:
> **CSP patch needed.** I detected a Content Security Policy in your project that blocks `http://localhost:8400` — the live picker won't load without an allowance. Here's the change I'd make:
>
> ```diff
> [file: <patchTarget>]
> [exact diff, 25 lines]
> ```
>
> It's guarded by `NODE_ENV === "development"` so the extra entry only appears in dev and never reaches production. You can remove it any time by reverting this file. Apply? [y/n]
On "no": skip the patch, mention live won't work until the user adds the allowance manually, still write `cspChecked: true` (the question's been asked).
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
#### Shape 1 — shared helper with `additional*Src` arrays
The app config calls something like `createBaseNextConfig({ additionalScriptSrc: [...], additionalConnectSrc: [...] })`. Patch the *app's* config (not the shared helper) so the monorepo root stays clean. Add near the top of the app's `next.config.ts`:
```ts
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
const __impeccableLiveDev =
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
```
Append `...__impeccableLiveDev` to both `additionalScriptSrc` and `additionalConnectSrc` array options.
Idempotency: if `__impeccableLiveDev` already exists in the file, the patch is already applied; skip asking and just mark `cspChecked: true`.
See `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` for the full desired output.
#### Shape 2 — inline CSP string in `headers()`
A literal CSP string inside a `headers()` function or return value. Two-point patch: declare a dev-only variable near the top, interpolate it into the CSP string at the `script-src` and `connect-src` segments.
```ts
const __impeccableLiveDev =
process.env.NODE_ENV === "development" ? " http://localhost:8400" : "";
```
Then, inside the CSP value string:
- `script-src 'self' 'unsafe-inline'``script-src 'self' 'unsafe-inline'${__impeccableLiveDev}`
- `connect-src 'self'``connect-src 'self'${__impeccableLiveDev}`
(Leading space on the dev string so it concatenates cleanly into the existing value.)
Read the current file, locate the exact CSP string, quote the two directive edits in the consent prompt, write after confirmation.
See `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` for the full desired output.
### Troubleshooting
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs` — setup will ask again.
Then re-run `live.mjs`.
@@ -0,0 +1,167 @@
/**
* Scan a project tree for Content-Security-Policy signals and classify the
* shape so the agent knows which patch template to propose.
*
* Used at first-time `live.mjs` setup. Mechanical (grep-based) — no network,
* no dev server, no JS evaluation. The classification drives a user-facing
* consent prompt; the agent does the actual patch writing.
*
* Shape taxonomy:
* - "shared-helper": monorepo with a `createBaseNextConfig`-style helper
* that accepts `additionalScriptSrc`/`additionalConnectSrc`
* arrays. Patch the app's config to append a dev-only
* localhost entry to those arrays.
* - "inline-headers": Content-Security-Policy built inline in a Next/Nuxt/
* SvelteKit config's headers() function with a literal
* value string. Patch the CSP string in place.
* - "middleware": CSP set in middleware.{ts,js}. 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.
*/
import fs from 'node:fs';
import path from 'node:path';
const SKIP_DIRS = new Set([
'node_modules',
'.git',
'.next',
'.turbo',
'.svelte-kit',
'.nuxt',
'.astro',
'dist',
'build',
'out',
'.vercel',
]);
const SCAN_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.tsx', '.jsx']);
const LAYOUT_EXTS = new Set(['.tsx', '.jsx', '.astro', '.vue', '.svelte', '.html']);
const MAX_DEPTH = 6;
const MAX_READ_BYTES = 64 * 1024;
const SHARED_HELPER_SIGNALS = [
/\bbuildCSPConfig\b/,
/\bbuildSecurityHeaders\b/,
/\badditionalScriptSrc\b/,
/\badditionalConnectSrc\b/,
/\bcreateBaseNextConfig\b/,
];
const INLINE_HEADER_SIGNALS = [
/["']Content-Security-Policy["']/i,
/\bscript-src\b/,
/\bconnect-src\b/,
];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
*/
export function detectCsp(cwd = process.cwd()) {
const hits = { sharedHelper: [], inlineHeader: [], middleware: [], metaTag: [] };
walk(cwd, cwd, 0, (absPath, relPath, body) => {
const ext = path.extname(absPath);
const base = path.basename(absPath).toLowerCase();
// Shared helper: package exports, config factory
if (SCAN_EXTS.has(ext)) {
const matched = SHARED_HELPER_SIGNALS.some((re) => re.test(body));
const looksShared = /packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath);
if (matched && looksShared) {
hits.sharedHelper.push(relPath);
}
}
// Inline headers: Next/Nuxt/SvelteKit/Astro/Vite config files
if (SCAN_EXTS.has(ext) && /(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath)) {
const allInlineMatch = INLINE_HEADER_SIGNALS.every((re) => re.test(body));
if (allInlineMatch) {
hits.inlineHeader.push(relPath);
}
}
// Middleware CSP: middleware.{ts,js} at project root or app/
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
// Meta tag CSP: layouts / HTML files
if (LAYOUT_EXTS.has(ext) && META_TAG_HINT.test(body)) {
hits.metaTag.push(relPath);
}
});
// Classification priority: shared-helper > inline-headers > middleware > meta-tag.
// A monorepo with a shared helper is always that shape, even if an individual
// app file also happens to contain a CSP literal.
if (hits.sharedHelper.length > 0) {
return {
shape: 'shared-helper',
signals: hits.sharedHelper,
};
}
if (hits.inlineHeader.length > 0) {
return {
shape: 'inline-headers',
signals: hits.inlineHeader,
};
}
if (hits.middleware.length > 0) {
return {
shape: 'middleware',
signals: hits.middleware,
};
}
if (hits.metaTag.length > 0) {
return {
shape: 'meta-tag',
signals: hits.metaTag,
};
}
return { shape: null, signals: [] };
}
function walk(root, dir, depth, visit) {
if (depth > MAX_DEPTH) return;
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return; }
for (const entry of entries) {
const abs = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
walk(root, abs, depth + 1, visit);
continue;
}
if (!entry.isFile()) continue;
const ext = path.extname(entry.name);
if (!SCAN_EXTS.has(ext) && !LAYOUT_EXTS.has(ext)) continue;
let body;
try {
const fd = fs.openSync(abs, 'r');
try {
const buf = Buffer.alloc(MAX_READ_BYTES);
const n = fs.readSync(fd, buf, 0, MAX_READ_BYTES, 0);
body = buf.slice(0, n).toString('utf-8');
} finally { fs.closeSync(fd); }
} catch { continue; }
visit(abs, path.relative(root, abs), body);
}
}
// CLI mode
const _running = process.argv[1];
if (_running?.endsWith('detect-csp.mjs') || _running?.endsWith('detect-csp.mjs/')) {
const result = detectCsp(process.cwd());
console.log(JSON.stringify(result, null, 2));
}
@@ -126,6 +126,9 @@ function validateConfig(cfg) {
if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') {
throw new Error("config.commentSyntax must be 'html' or 'jsx'");
}
if (cfg.cspChecked !== undefined && typeof cfg.cspChecked !== 'boolean') {
throw new Error("config.cspChecked, if present, must be a boolean");
}
}
function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }
+77 -1
View File
@@ -291,10 +291,13 @@ Schema:
{
"files": ["<path>", "<path>", ...],
"insertBefore": "</body>",
"commentSyntax": "html"
"commentSyntax": "html",
"cspChecked": true
}
```
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
| Framework | `files` | `insertBefore` | `commentSyntax` |
@@ -311,4 +314,77 @@ Pick an anchor that exists in every file (`</body>` almost always works). Use `i
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
### CSP detection (first-time only)
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
Otherwise, run the detection helper:
```bash
node {{scripts_path}}/detect-csp.mjs
```
Output: `{ shape, signals }` where `shape` is one of `shared-helper`, `inline-headers`, `middleware`, `meta-tag`, or `null`.
- **`null`** — no CSP; skip to writing `config.json` with `cspChecked: true`.
- **`shared-helper`** — monorepo with a CSP builder that accepts `additionalScriptSrc` / `additionalConnectSrc` arrays. Auto-patchable. See *Shape 1* below.
- **`inline-headers`** — CSP built as a literal string inside `next.config.*` (or equivalent) `headers()`. Auto-patchable. See *Shape 2* below.
- **`middleware`** or **`meta-tag`** — rarer. Detected but not auto-patched in v1. Show the user the detected files and ask them to add `http://localhost:8400` to `script-src` and `connect-src` manually, then mark `cspChecked: true` and proceed.
#### Consent prompt template
Use this phrasing so the experience is consistent across agents:
> **CSP patch needed.** I detected a Content Security Policy in your project that blocks `http://localhost:8400` — the live picker won't load without an allowance. Here's the change I'd make:
>
> ```diff
> [file: <patchTarget>]
> [exact diff, 25 lines]
> ```
>
> It's guarded by `NODE_ENV === "development"` so the extra entry only appears in dev and never reaches production. You can remove it any time by reverting this file. Apply? [y/n]
On "no": skip the patch, mention live won't work until the user adds the allowance manually, still write `cspChecked: true` (the question's been asked).
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
#### Shape 1 — shared helper with `additional*Src` arrays
The app config calls something like `createBaseNextConfig({ additionalScriptSrc: [...], additionalConnectSrc: [...] })`. Patch the *app's* config (not the shared helper) so the monorepo root stays clean. Add near the top of the app's `next.config.ts`:
```ts
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
const __impeccableLiveDev =
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
```
Append `...__impeccableLiveDev` to both `additionalScriptSrc` and `additionalConnectSrc` array options.
Idempotency: if `__impeccableLiveDev` already exists in the file, the patch is already applied; skip asking and just mark `cspChecked: true`.
See `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` for the full desired output.
#### Shape 2 — inline CSP string in `headers()`
A literal CSP string inside a `headers()` function or return value. Two-point patch: declare a dev-only variable near the top, interpolate it into the CSP string at the `script-src` and `connect-src` segments.
```ts
const __impeccableLiveDev =
process.env.NODE_ENV === "development" ? " http://localhost:8400" : "";
```
Then, inside the CSP value string:
- `script-src 'self' 'unsafe-inline'``script-src 'self' 'unsafe-inline'${__impeccableLiveDev}`
- `connect-src 'self'``connect-src 'self'${__impeccableLiveDev}`
(Leading space on the dev string so it concatenates cleanly into the existing value.)
Read the current file, locate the exact CSP string, quote the two directive edits in the consent prompt, write after confirmation.
See `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` for the full desired output.
### Troubleshooting
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs` — setup will ask again.
Then re-run `live.mjs`.
@@ -0,0 +1,167 @@
/**
* Scan a project tree for Content-Security-Policy signals and classify the
* shape so the agent knows which patch template to propose.
*
* Used at first-time `live.mjs` setup. Mechanical (grep-based) — no network,
* no dev server, no JS evaluation. The classification drives a user-facing
* consent prompt; the agent does the actual patch writing.
*
* Shape taxonomy:
* - "shared-helper": monorepo with a `createBaseNextConfig`-style helper
* that accepts `additionalScriptSrc`/`additionalConnectSrc`
* arrays. Patch the app's config to append a dev-only
* localhost entry to those arrays.
* - "inline-headers": Content-Security-Policy built inline in a Next/Nuxt/
* SvelteKit config's headers() function with a literal
* value string. Patch the CSP string in place.
* - "middleware": CSP set in middleware.{ts,js}. 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.
*/
import fs from 'node:fs';
import path from 'node:path';
const SKIP_DIRS = new Set([
'node_modules',
'.git',
'.next',
'.turbo',
'.svelte-kit',
'.nuxt',
'.astro',
'dist',
'build',
'out',
'.vercel',
]);
const SCAN_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.tsx', '.jsx']);
const LAYOUT_EXTS = new Set(['.tsx', '.jsx', '.astro', '.vue', '.svelte', '.html']);
const MAX_DEPTH = 6;
const MAX_READ_BYTES = 64 * 1024;
const SHARED_HELPER_SIGNALS = [
/\bbuildCSPConfig\b/,
/\bbuildSecurityHeaders\b/,
/\badditionalScriptSrc\b/,
/\badditionalConnectSrc\b/,
/\bcreateBaseNextConfig\b/,
];
const INLINE_HEADER_SIGNALS = [
/["']Content-Security-Policy["']/i,
/\bscript-src\b/,
/\bconnect-src\b/,
];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
*/
export function detectCsp(cwd = process.cwd()) {
const hits = { sharedHelper: [], inlineHeader: [], middleware: [], metaTag: [] };
walk(cwd, cwd, 0, (absPath, relPath, body) => {
const ext = path.extname(absPath);
const base = path.basename(absPath).toLowerCase();
// Shared helper: package exports, config factory
if (SCAN_EXTS.has(ext)) {
const matched = SHARED_HELPER_SIGNALS.some((re) => re.test(body));
const looksShared = /packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath);
if (matched && looksShared) {
hits.sharedHelper.push(relPath);
}
}
// Inline headers: Next/Nuxt/SvelteKit/Astro/Vite config files
if (SCAN_EXTS.has(ext) && /(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath)) {
const allInlineMatch = INLINE_HEADER_SIGNALS.every((re) => re.test(body));
if (allInlineMatch) {
hits.inlineHeader.push(relPath);
}
}
// Middleware CSP: middleware.{ts,js} at project root or app/
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
// Meta tag CSP: layouts / HTML files
if (LAYOUT_EXTS.has(ext) && META_TAG_HINT.test(body)) {
hits.metaTag.push(relPath);
}
});
// Classification priority: shared-helper > inline-headers > middleware > meta-tag.
// A monorepo with a shared helper is always that shape, even if an individual
// app file also happens to contain a CSP literal.
if (hits.sharedHelper.length > 0) {
return {
shape: 'shared-helper',
signals: hits.sharedHelper,
};
}
if (hits.inlineHeader.length > 0) {
return {
shape: 'inline-headers',
signals: hits.inlineHeader,
};
}
if (hits.middleware.length > 0) {
return {
shape: 'middleware',
signals: hits.middleware,
};
}
if (hits.metaTag.length > 0) {
return {
shape: 'meta-tag',
signals: hits.metaTag,
};
}
return { shape: null, signals: [] };
}
function walk(root, dir, depth, visit) {
if (depth > MAX_DEPTH) return;
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return; }
for (const entry of entries) {
const abs = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
walk(root, abs, depth + 1, visit);
continue;
}
if (!entry.isFile()) continue;
const ext = path.extname(entry.name);
if (!SCAN_EXTS.has(ext) && !LAYOUT_EXTS.has(ext)) continue;
let body;
try {
const fd = fs.openSync(abs, 'r');
try {
const buf = Buffer.alloc(MAX_READ_BYTES);
const n = fs.readSync(fd, buf, 0, MAX_READ_BYTES, 0);
body = buf.slice(0, n).toString('utf-8');
} finally { fs.closeSync(fd); }
} catch { continue; }
visit(abs, path.relative(root, abs), body);
}
}
// CLI mode
const _running = process.argv[1];
if (_running?.endsWith('detect-csp.mjs') || _running?.endsWith('detect-csp.mjs/')) {
const result = detectCsp(process.cwd());
console.log(JSON.stringify(result, null, 2));
}
@@ -126,6 +126,9 @@ function validateConfig(cfg) {
if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') {
throw new Error("config.commentSyntax must be 'html' or 'jsx'");
}
if (cfg.cspChecked !== undefined && typeof cfg.cspChecked !== 'boolean') {
throw new Error("config.cspChecked, if present, must be a boolean");
}
}
function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }
+77 -1
View File
@@ -291,10 +291,13 @@ Schema:
{
"files": ["<path>", "<path>", ...],
"insertBefore": "</body>",
"commentSyntax": "html"
"commentSyntax": "html",
"cspChecked": true
}
```
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
| Framework | `files` | `insertBefore` | `commentSyntax` |
@@ -311,4 +314,77 @@ Pick an anchor that exists in every file (`</body>` almost always works). Use `i
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
### CSP detection (first-time only)
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
Otherwise, run the detection helper:
```bash
node {{scripts_path}}/detect-csp.mjs
```
Output: `{ shape, signals }` where `shape` is one of `shared-helper`, `inline-headers`, `middleware`, `meta-tag`, or `null`.
- **`null`** — no CSP; skip to writing `config.json` with `cspChecked: true`.
- **`shared-helper`** — monorepo with a CSP builder that accepts `additionalScriptSrc` / `additionalConnectSrc` arrays. Auto-patchable. See *Shape 1* below.
- **`inline-headers`** — CSP built as a literal string inside `next.config.*` (or equivalent) `headers()`. Auto-patchable. See *Shape 2* below.
- **`middleware`** or **`meta-tag`** — rarer. Detected but not auto-patched in v1. Show the user the detected files and ask them to add `http://localhost:8400` to `script-src` and `connect-src` manually, then mark `cspChecked: true` and proceed.
#### Consent prompt template
Use this phrasing so the experience is consistent across agents:
> **CSP patch needed.** I detected a Content Security Policy in your project that blocks `http://localhost:8400` — the live picker won't load without an allowance. Here's the change I'd make:
>
> ```diff
> [file: <patchTarget>]
> [exact diff, 25 lines]
> ```
>
> It's guarded by `NODE_ENV === "development"` so the extra entry only appears in dev and never reaches production. You can remove it any time by reverting this file. Apply? [y/n]
On "no": skip the patch, mention live won't work until the user adds the allowance manually, still write `cspChecked: true` (the question's been asked).
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
#### Shape 1 — shared helper with `additional*Src` arrays
The app config calls something like `createBaseNextConfig({ additionalScriptSrc: [...], additionalConnectSrc: [...] })`. Patch the *app's* config (not the shared helper) so the monorepo root stays clean. Add near the top of the app's `next.config.ts`:
```ts
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
const __impeccableLiveDev =
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
```
Append `...__impeccableLiveDev` to both `additionalScriptSrc` and `additionalConnectSrc` array options.
Idempotency: if `__impeccableLiveDev` already exists in the file, the patch is already applied; skip asking and just mark `cspChecked: true`.
See `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` for the full desired output.
#### Shape 2 — inline CSP string in `headers()`
A literal CSP string inside a `headers()` function or return value. Two-point patch: declare a dev-only variable near the top, interpolate it into the CSP string at the `script-src` and `connect-src` segments.
```ts
const __impeccableLiveDev =
process.env.NODE_ENV === "development" ? " http://localhost:8400" : "";
```
Then, inside the CSP value string:
- `script-src 'self' 'unsafe-inline'``script-src 'self' 'unsafe-inline'${__impeccableLiveDev}`
- `connect-src 'self'``connect-src 'self'${__impeccableLiveDev}`
(Leading space on the dev string so it concatenates cleanly into the existing value.)
Read the current file, locate the exact CSP string, quote the two directive edits in the consent prompt, write after confirmation.
See `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` for the full desired output.
### Troubleshooting
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs` — setup will ask again.
Then re-run `live.mjs`.
@@ -0,0 +1,167 @@
/**
* Scan a project tree for Content-Security-Policy signals and classify the
* shape so the agent knows which patch template to propose.
*
* Used at first-time `live.mjs` setup. Mechanical (grep-based) — no network,
* no dev server, no JS evaluation. The classification drives a user-facing
* consent prompt; the agent does the actual patch writing.
*
* Shape taxonomy:
* - "shared-helper": monorepo with a `createBaseNextConfig`-style helper
* that accepts `additionalScriptSrc`/`additionalConnectSrc`
* arrays. Patch the app's config to append a dev-only
* localhost entry to those arrays.
* - "inline-headers": Content-Security-Policy built inline in a Next/Nuxt/
* SvelteKit config's headers() function with a literal
* value string. Patch the CSP string in place.
* - "middleware": CSP set in middleware.{ts,js}. 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.
*/
import fs from 'node:fs';
import path from 'node:path';
const SKIP_DIRS = new Set([
'node_modules',
'.git',
'.next',
'.turbo',
'.svelte-kit',
'.nuxt',
'.astro',
'dist',
'build',
'out',
'.vercel',
]);
const SCAN_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.tsx', '.jsx']);
const LAYOUT_EXTS = new Set(['.tsx', '.jsx', '.astro', '.vue', '.svelte', '.html']);
const MAX_DEPTH = 6;
const MAX_READ_BYTES = 64 * 1024;
const SHARED_HELPER_SIGNALS = [
/\bbuildCSPConfig\b/,
/\bbuildSecurityHeaders\b/,
/\badditionalScriptSrc\b/,
/\badditionalConnectSrc\b/,
/\bcreateBaseNextConfig\b/,
];
const INLINE_HEADER_SIGNALS = [
/["']Content-Security-Policy["']/i,
/\bscript-src\b/,
/\bconnect-src\b/,
];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
*/
export function detectCsp(cwd = process.cwd()) {
const hits = { sharedHelper: [], inlineHeader: [], middleware: [], metaTag: [] };
walk(cwd, cwd, 0, (absPath, relPath, body) => {
const ext = path.extname(absPath);
const base = path.basename(absPath).toLowerCase();
// Shared helper: package exports, config factory
if (SCAN_EXTS.has(ext)) {
const matched = SHARED_HELPER_SIGNALS.some((re) => re.test(body));
const looksShared = /packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath);
if (matched && looksShared) {
hits.sharedHelper.push(relPath);
}
}
// Inline headers: Next/Nuxt/SvelteKit/Astro/Vite config files
if (SCAN_EXTS.has(ext) && /(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath)) {
const allInlineMatch = INLINE_HEADER_SIGNALS.every((re) => re.test(body));
if (allInlineMatch) {
hits.inlineHeader.push(relPath);
}
}
// Middleware CSP: middleware.{ts,js} at project root or app/
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
// Meta tag CSP: layouts / HTML files
if (LAYOUT_EXTS.has(ext) && META_TAG_HINT.test(body)) {
hits.metaTag.push(relPath);
}
});
// Classification priority: shared-helper > inline-headers > middleware > meta-tag.
// A monorepo with a shared helper is always that shape, even if an individual
// app file also happens to contain a CSP literal.
if (hits.sharedHelper.length > 0) {
return {
shape: 'shared-helper',
signals: hits.sharedHelper,
};
}
if (hits.inlineHeader.length > 0) {
return {
shape: 'inline-headers',
signals: hits.inlineHeader,
};
}
if (hits.middleware.length > 0) {
return {
shape: 'middleware',
signals: hits.middleware,
};
}
if (hits.metaTag.length > 0) {
return {
shape: 'meta-tag',
signals: hits.metaTag,
};
}
return { shape: null, signals: [] };
}
function walk(root, dir, depth, visit) {
if (depth > MAX_DEPTH) return;
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return; }
for (const entry of entries) {
const abs = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
walk(root, abs, depth + 1, visit);
continue;
}
if (!entry.isFile()) continue;
const ext = path.extname(entry.name);
if (!SCAN_EXTS.has(ext) && !LAYOUT_EXTS.has(ext)) continue;
let body;
try {
const fd = fs.openSync(abs, 'r');
try {
const buf = Buffer.alloc(MAX_READ_BYTES);
const n = fs.readSync(fd, buf, 0, MAX_READ_BYTES, 0);
body = buf.slice(0, n).toString('utf-8');
} finally { fs.closeSync(fd); }
} catch { continue; }
visit(abs, path.relative(root, abs), body);
}
}
// CLI mode
const _running = process.argv[1];
if (_running?.endsWith('detect-csp.mjs') || _running?.endsWith('detect-csp.mjs/')) {
const result = detectCsp(process.cwd());
console.log(JSON.stringify(result, null, 2));
}
@@ -126,6 +126,9 @@ function validateConfig(cfg) {
if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') {
throw new Error("config.commentSyntax must be 'html' or 'jsx'");
}
if (cfg.cspChecked !== undefined && typeof cfg.cspChecked !== 'boolean') {
throw new Error("config.cspChecked, if present, must be a boolean");
}
}
function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }
+77 -1
View File
@@ -291,10 +291,13 @@ Schema:
{
"files": ["<path>", "<path>", ...],
"insertBefore": "</body>",
"commentSyntax": "html"
"commentSyntax": "html",
"cspChecked": true
}
```
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
| Framework | `files` | `insertBefore` | `commentSyntax` |
@@ -311,4 +314,77 @@ Pick an anchor that exists in every file (`</body>` almost always works). Use `i
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
### CSP detection (first-time only)
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
Otherwise, run the detection helper:
```bash
node {{scripts_path}}/detect-csp.mjs
```
Output: `{ shape, signals }` where `shape` is one of `shared-helper`, `inline-headers`, `middleware`, `meta-tag`, or `null`.
- **`null`** — no CSP; skip to writing `config.json` with `cspChecked: true`.
- **`shared-helper`** — monorepo with a CSP builder that accepts `additionalScriptSrc` / `additionalConnectSrc` arrays. Auto-patchable. See *Shape 1* below.
- **`inline-headers`** — CSP built as a literal string inside `next.config.*` (or equivalent) `headers()`. Auto-patchable. See *Shape 2* below.
- **`middleware`** or **`meta-tag`** — rarer. Detected but not auto-patched in v1. Show the user the detected files and ask them to add `http://localhost:8400` to `script-src` and `connect-src` manually, then mark `cspChecked: true` and proceed.
#### Consent prompt template
Use this phrasing so the experience is consistent across agents:
> **CSP patch needed.** I detected a Content Security Policy in your project that blocks `http://localhost:8400` — the live picker won't load without an allowance. Here's the change I'd make:
>
> ```diff
> [file: <patchTarget>]
> [exact diff, 25 lines]
> ```
>
> It's guarded by `NODE_ENV === "development"` so the extra entry only appears in dev and never reaches production. You can remove it any time by reverting this file. Apply? [y/n]
On "no": skip the patch, mention live won't work until the user adds the allowance manually, still write `cspChecked: true` (the question's been asked).
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
#### Shape 1 — shared helper with `additional*Src` arrays
The app config calls something like `createBaseNextConfig({ additionalScriptSrc: [...], additionalConnectSrc: [...] })`. Patch the *app's* config (not the shared helper) so the monorepo root stays clean. Add near the top of the app's `next.config.ts`:
```ts
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
const __impeccableLiveDev =
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
```
Append `...__impeccableLiveDev` to both `additionalScriptSrc` and `additionalConnectSrc` array options.
Idempotency: if `__impeccableLiveDev` already exists in the file, the patch is already applied; skip asking and just mark `cspChecked: true`.
See `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` for the full desired output.
#### Shape 2 — inline CSP string in `headers()`
A literal CSP string inside a `headers()` function or return value. Two-point patch: declare a dev-only variable near the top, interpolate it into the CSP string at the `script-src` and `connect-src` segments.
```ts
const __impeccableLiveDev =
process.env.NODE_ENV === "development" ? " http://localhost:8400" : "";
```
Then, inside the CSP value string:
- `script-src 'self' 'unsafe-inline'``script-src 'self' 'unsafe-inline'${__impeccableLiveDev}`
- `connect-src 'self'``connect-src 'self'${__impeccableLiveDev}`
(Leading space on the dev string so it concatenates cleanly into the existing value.)
Read the current file, locate the exact CSP string, quote the two directive edits in the consent prompt, write after confirmation.
See `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` for the full desired output.
### Troubleshooting
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs` — setup will ask again.
Then re-run `live.mjs`.
@@ -0,0 +1,167 @@
/**
* Scan a project tree for Content-Security-Policy signals and classify the
* shape so the agent knows which patch template to propose.
*
* Used at first-time `live.mjs` setup. Mechanical (grep-based) — no network,
* no dev server, no JS evaluation. The classification drives a user-facing
* consent prompt; the agent does the actual patch writing.
*
* Shape taxonomy:
* - "shared-helper": monorepo with a `createBaseNextConfig`-style helper
* that accepts `additionalScriptSrc`/`additionalConnectSrc`
* arrays. Patch the app's config to append a dev-only
* localhost entry to those arrays.
* - "inline-headers": Content-Security-Policy built inline in a Next/Nuxt/
* SvelteKit config's headers() function with a literal
* value string. Patch the CSP string in place.
* - "middleware": CSP set in middleware.{ts,js}. 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.
*/
import fs from 'node:fs';
import path from 'node:path';
const SKIP_DIRS = new Set([
'node_modules',
'.git',
'.next',
'.turbo',
'.svelte-kit',
'.nuxt',
'.astro',
'dist',
'build',
'out',
'.vercel',
]);
const SCAN_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.tsx', '.jsx']);
const LAYOUT_EXTS = new Set(['.tsx', '.jsx', '.astro', '.vue', '.svelte', '.html']);
const MAX_DEPTH = 6;
const MAX_READ_BYTES = 64 * 1024;
const SHARED_HELPER_SIGNALS = [
/\bbuildCSPConfig\b/,
/\bbuildSecurityHeaders\b/,
/\badditionalScriptSrc\b/,
/\badditionalConnectSrc\b/,
/\bcreateBaseNextConfig\b/,
];
const INLINE_HEADER_SIGNALS = [
/["']Content-Security-Policy["']/i,
/\bscript-src\b/,
/\bconnect-src\b/,
];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
*/
export function detectCsp(cwd = process.cwd()) {
const hits = { sharedHelper: [], inlineHeader: [], middleware: [], metaTag: [] };
walk(cwd, cwd, 0, (absPath, relPath, body) => {
const ext = path.extname(absPath);
const base = path.basename(absPath).toLowerCase();
// Shared helper: package exports, config factory
if (SCAN_EXTS.has(ext)) {
const matched = SHARED_HELPER_SIGNALS.some((re) => re.test(body));
const looksShared = /packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath);
if (matched && looksShared) {
hits.sharedHelper.push(relPath);
}
}
// Inline headers: Next/Nuxt/SvelteKit/Astro/Vite config files
if (SCAN_EXTS.has(ext) && /(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath)) {
const allInlineMatch = INLINE_HEADER_SIGNALS.every((re) => re.test(body));
if (allInlineMatch) {
hits.inlineHeader.push(relPath);
}
}
// Middleware CSP: middleware.{ts,js} at project root or app/
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
// Meta tag CSP: layouts / HTML files
if (LAYOUT_EXTS.has(ext) && META_TAG_HINT.test(body)) {
hits.metaTag.push(relPath);
}
});
// Classification priority: shared-helper > inline-headers > middleware > meta-tag.
// A monorepo with a shared helper is always that shape, even if an individual
// app file also happens to contain a CSP literal.
if (hits.sharedHelper.length > 0) {
return {
shape: 'shared-helper',
signals: hits.sharedHelper,
};
}
if (hits.inlineHeader.length > 0) {
return {
shape: 'inline-headers',
signals: hits.inlineHeader,
};
}
if (hits.middleware.length > 0) {
return {
shape: 'middleware',
signals: hits.middleware,
};
}
if (hits.metaTag.length > 0) {
return {
shape: 'meta-tag',
signals: hits.metaTag,
};
}
return { shape: null, signals: [] };
}
function walk(root, dir, depth, visit) {
if (depth > MAX_DEPTH) return;
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return; }
for (const entry of entries) {
const abs = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
walk(root, abs, depth + 1, visit);
continue;
}
if (!entry.isFile()) continue;
const ext = path.extname(entry.name);
if (!SCAN_EXTS.has(ext) && !LAYOUT_EXTS.has(ext)) continue;
let body;
try {
const fd = fs.openSync(abs, 'r');
try {
const buf = Buffer.alloc(MAX_READ_BYTES);
const n = fs.readSync(fd, buf, 0, MAX_READ_BYTES, 0);
body = buf.slice(0, n).toString('utf-8');
} finally { fs.closeSync(fd); }
} catch { continue; }
visit(abs, path.relative(root, abs), body);
}
}
// CLI mode
const _running = process.argv[1];
if (_running?.endsWith('detect-csp.mjs') || _running?.endsWith('detect-csp.mjs/')) {
const result = detectCsp(process.cwd());
console.log(JSON.stringify(result, null, 2));
}
@@ -126,6 +126,9 @@ function validateConfig(cfg) {
if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') {
throw new Error("config.commentSyntax must be 'html' or 'jsx'");
}
if (cfg.cspChecked !== undefined && typeof cfg.cspChecked !== 'boolean') {
throw new Error("config.cspChecked, if present, must be a boolean");
}
}
function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }
+77 -1
View File
@@ -291,10 +291,13 @@ Schema:
{
"files": ["<path>", "<path>", ...],
"insertBefore": "</body>",
"commentSyntax": "html"
"commentSyntax": "html",
"cspChecked": true
}
```
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
| Framework | `files` | `insertBefore` | `commentSyntax` |
@@ -311,4 +314,77 @@ Pick an anchor that exists in every file (`</body>` almost always works). Use `i
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
### CSP detection (first-time only)
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
Otherwise, run the detection helper:
```bash
node {{scripts_path}}/detect-csp.mjs
```
Output: `{ shape, signals }` where `shape` is one of `shared-helper`, `inline-headers`, `middleware`, `meta-tag`, or `null`.
- **`null`** — no CSP; skip to writing `config.json` with `cspChecked: true`.
- **`shared-helper`** — monorepo with a CSP builder that accepts `additionalScriptSrc` / `additionalConnectSrc` arrays. Auto-patchable. See *Shape 1* below.
- **`inline-headers`** — CSP built as a literal string inside `next.config.*` (or equivalent) `headers()`. Auto-patchable. See *Shape 2* below.
- **`middleware`** or **`meta-tag`** — rarer. Detected but not auto-patched in v1. Show the user the detected files and ask them to add `http://localhost:8400` to `script-src` and `connect-src` manually, then mark `cspChecked: true` and proceed.
#### Consent prompt template
Use this phrasing so the experience is consistent across agents:
> **CSP patch needed.** I detected a Content Security Policy in your project that blocks `http://localhost:8400` — the live picker won't load without an allowance. Here's the change I'd make:
>
> ```diff
> [file: <patchTarget>]
> [exact diff, 25 lines]
> ```
>
> It's guarded by `NODE_ENV === "development"` so the extra entry only appears in dev and never reaches production. You can remove it any time by reverting this file. Apply? [y/n]
On "no": skip the patch, mention live won't work until the user adds the allowance manually, still write `cspChecked: true` (the question's been asked).
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
#### Shape 1 — shared helper with `additional*Src` arrays
The app config calls something like `createBaseNextConfig({ additionalScriptSrc: [...], additionalConnectSrc: [...] })`. Patch the *app's* config (not the shared helper) so the monorepo root stays clean. Add near the top of the app's `next.config.ts`:
```ts
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
const __impeccableLiveDev =
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
```
Append `...__impeccableLiveDev` to both `additionalScriptSrc` and `additionalConnectSrc` array options.
Idempotency: if `__impeccableLiveDev` already exists in the file, the patch is already applied; skip asking and just mark `cspChecked: true`.
See `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` for the full desired output.
#### Shape 2 — inline CSP string in `headers()`
A literal CSP string inside a `headers()` function or return value. Two-point patch: declare a dev-only variable near the top, interpolate it into the CSP string at the `script-src` and `connect-src` segments.
```ts
const __impeccableLiveDev =
process.env.NODE_ENV === "development" ? " http://localhost:8400" : "";
```
Then, inside the CSP value string:
- `script-src 'self' 'unsafe-inline'``script-src 'self' 'unsafe-inline'${__impeccableLiveDev}`
- `connect-src 'self'``connect-src 'self'${__impeccableLiveDev}`
(Leading space on the dev string so it concatenates cleanly into the existing value.)
Read the current file, locate the exact CSP string, quote the two directive edits in the consent prompt, write after confirmation.
See `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` for the full desired output.
### Troubleshooting
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs` — setup will ask again.
Then re-run `live.mjs`.
@@ -0,0 +1,167 @@
/**
* Scan a project tree for Content-Security-Policy signals and classify the
* shape so the agent knows which patch template to propose.
*
* Used at first-time `live.mjs` setup. Mechanical (grep-based) — no network,
* no dev server, no JS evaluation. The classification drives a user-facing
* consent prompt; the agent does the actual patch writing.
*
* Shape taxonomy:
* - "shared-helper": monorepo with a `createBaseNextConfig`-style helper
* that accepts `additionalScriptSrc`/`additionalConnectSrc`
* arrays. Patch the app's config to append a dev-only
* localhost entry to those arrays.
* - "inline-headers": Content-Security-Policy built inline in a Next/Nuxt/
* SvelteKit config's headers() function with a literal
* value string. Patch the CSP string in place.
* - "middleware": CSP set in middleware.{ts,js}. 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.
*/
import fs from 'node:fs';
import path from 'node:path';
const SKIP_DIRS = new Set([
'node_modules',
'.git',
'.next',
'.turbo',
'.svelte-kit',
'.nuxt',
'.astro',
'dist',
'build',
'out',
'.vercel',
]);
const SCAN_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.tsx', '.jsx']);
const LAYOUT_EXTS = new Set(['.tsx', '.jsx', '.astro', '.vue', '.svelte', '.html']);
const MAX_DEPTH = 6;
const MAX_READ_BYTES = 64 * 1024;
const SHARED_HELPER_SIGNALS = [
/\bbuildCSPConfig\b/,
/\bbuildSecurityHeaders\b/,
/\badditionalScriptSrc\b/,
/\badditionalConnectSrc\b/,
/\bcreateBaseNextConfig\b/,
];
const INLINE_HEADER_SIGNALS = [
/["']Content-Security-Policy["']/i,
/\bscript-src\b/,
/\bconnect-src\b/,
];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
*/
export function detectCsp(cwd = process.cwd()) {
const hits = { sharedHelper: [], inlineHeader: [], middleware: [], metaTag: [] };
walk(cwd, cwd, 0, (absPath, relPath, body) => {
const ext = path.extname(absPath);
const base = path.basename(absPath).toLowerCase();
// Shared helper: package exports, config factory
if (SCAN_EXTS.has(ext)) {
const matched = SHARED_HELPER_SIGNALS.some((re) => re.test(body));
const looksShared = /packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath);
if (matched && looksShared) {
hits.sharedHelper.push(relPath);
}
}
// Inline headers: Next/Nuxt/SvelteKit/Astro/Vite config files
if (SCAN_EXTS.has(ext) && /(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath)) {
const allInlineMatch = INLINE_HEADER_SIGNALS.every((re) => re.test(body));
if (allInlineMatch) {
hits.inlineHeader.push(relPath);
}
}
// Middleware CSP: middleware.{ts,js} at project root or app/
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
// Meta tag CSP: layouts / HTML files
if (LAYOUT_EXTS.has(ext) && META_TAG_HINT.test(body)) {
hits.metaTag.push(relPath);
}
});
// Classification priority: shared-helper > inline-headers > middleware > meta-tag.
// A monorepo with a shared helper is always that shape, even if an individual
// app file also happens to contain a CSP literal.
if (hits.sharedHelper.length > 0) {
return {
shape: 'shared-helper',
signals: hits.sharedHelper,
};
}
if (hits.inlineHeader.length > 0) {
return {
shape: 'inline-headers',
signals: hits.inlineHeader,
};
}
if (hits.middleware.length > 0) {
return {
shape: 'middleware',
signals: hits.middleware,
};
}
if (hits.metaTag.length > 0) {
return {
shape: 'meta-tag',
signals: hits.metaTag,
};
}
return { shape: null, signals: [] };
}
function walk(root, dir, depth, visit) {
if (depth > MAX_DEPTH) return;
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return; }
for (const entry of entries) {
const abs = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
walk(root, abs, depth + 1, visit);
continue;
}
if (!entry.isFile()) continue;
const ext = path.extname(entry.name);
if (!SCAN_EXTS.has(ext) && !LAYOUT_EXTS.has(ext)) continue;
let body;
try {
const fd = fs.openSync(abs, 'r');
try {
const buf = Buffer.alloc(MAX_READ_BYTES);
const n = fs.readSync(fd, buf, 0, MAX_READ_BYTES, 0);
body = buf.slice(0, n).toString('utf-8');
} finally { fs.closeSync(fd); }
} catch { continue; }
visit(abs, path.relative(root, abs), body);
}
}
// CLI mode
const _running = process.argv[1];
if (_running?.endsWith('detect-csp.mjs') || _running?.endsWith('detect-csp.mjs/')) {
const result = detectCsp(process.cwd());
console.log(JSON.stringify(result, null, 2));
}
@@ -126,6 +126,9 @@ function validateConfig(cfg) {
if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') {
throw new Error("config.commentSyntax must be 'html' or 'jsx'");
}
if (cfg.cspChecked !== undefined && typeof cfg.cspChecked !== 'boolean') {
throw new Error("config.cspChecked, if present, must be a boolean");
}
}
function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }
+77 -1
View File
@@ -291,10 +291,13 @@ Schema:
{
"files": ["<path>", "<path>", ...],
"insertBefore": "</body>",
"commentSyntax": "html"
"commentSyntax": "html",
"cspChecked": true
}
```
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
| Framework | `files` | `insertBefore` | `commentSyntax` |
@@ -311,4 +314,77 @@ Pick an anchor that exists in every file (`</body>` almost always works). Use `i
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
### CSP detection (first-time only)
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
Otherwise, run the detection helper:
```bash
node {{scripts_path}}/detect-csp.mjs
```
Output: `{ shape, signals }` where `shape` is one of `shared-helper`, `inline-headers`, `middleware`, `meta-tag`, or `null`.
- **`null`** — no CSP; skip to writing `config.json` with `cspChecked: true`.
- **`shared-helper`** — monorepo with a CSP builder that accepts `additionalScriptSrc` / `additionalConnectSrc` arrays. Auto-patchable. See *Shape 1* below.
- **`inline-headers`** — CSP built as a literal string inside `next.config.*` (or equivalent) `headers()`. Auto-patchable. See *Shape 2* below.
- **`middleware`** or **`meta-tag`** — rarer. Detected but not auto-patched in v1. Show the user the detected files and ask them to add `http://localhost:8400` to `script-src` and `connect-src` manually, then mark `cspChecked: true` and proceed.
#### Consent prompt template
Use this phrasing so the experience is consistent across agents:
> **CSP patch needed.** I detected a Content Security Policy in your project that blocks `http://localhost:8400` — the live picker won't load without an allowance. Here's the change I'd make:
>
> ```diff
> [file: <patchTarget>]
> [exact diff, 25 lines]
> ```
>
> It's guarded by `NODE_ENV === "development"` so the extra entry only appears in dev and never reaches production. You can remove it any time by reverting this file. Apply? [y/n]
On "no": skip the patch, mention live won't work until the user adds the allowance manually, still write `cspChecked: true` (the question's been asked).
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
#### Shape 1 — shared helper with `additional*Src` arrays
The app config calls something like `createBaseNextConfig({ additionalScriptSrc: [...], additionalConnectSrc: [...] })`. Patch the *app's* config (not the shared helper) so the monorepo root stays clean. Add near the top of the app's `next.config.ts`:
```ts
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
const __impeccableLiveDev =
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
```
Append `...__impeccableLiveDev` to both `additionalScriptSrc` and `additionalConnectSrc` array options.
Idempotency: if `__impeccableLiveDev` already exists in the file, the patch is already applied; skip asking and just mark `cspChecked: true`.
See `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` for the full desired output.
#### Shape 2 — inline CSP string in `headers()`
A literal CSP string inside a `headers()` function or return value. Two-point patch: declare a dev-only variable near the top, interpolate it into the CSP string at the `script-src` and `connect-src` segments.
```ts
const __impeccableLiveDev =
process.env.NODE_ENV === "development" ? " http://localhost:8400" : "";
```
Then, inside the CSP value string:
- `script-src 'self' 'unsafe-inline'``script-src 'self' 'unsafe-inline'${__impeccableLiveDev}`
- `connect-src 'self'``connect-src 'self'${__impeccableLiveDev}`
(Leading space on the dev string so it concatenates cleanly into the existing value.)
Read the current file, locate the exact CSP string, quote the two directive edits in the consent prompt, write after confirmation.
See `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` for the full desired output.
### Troubleshooting
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs` — setup will ask again.
Then re-run `live.mjs`.
@@ -0,0 +1,167 @@
/**
* Scan a project tree for Content-Security-Policy signals and classify the
* shape so the agent knows which patch template to propose.
*
* Used at first-time `live.mjs` setup. Mechanical (grep-based) — no network,
* no dev server, no JS evaluation. The classification drives a user-facing
* consent prompt; the agent does the actual patch writing.
*
* Shape taxonomy:
* - "shared-helper": monorepo with a `createBaseNextConfig`-style helper
* that accepts `additionalScriptSrc`/`additionalConnectSrc`
* arrays. Patch the app's config to append a dev-only
* localhost entry to those arrays.
* - "inline-headers": Content-Security-Policy built inline in a Next/Nuxt/
* SvelteKit config's headers() function with a literal
* value string. Patch the CSP string in place.
* - "middleware": CSP set in middleware.{ts,js}. 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.
*/
import fs from 'node:fs';
import path from 'node:path';
const SKIP_DIRS = new Set([
'node_modules',
'.git',
'.next',
'.turbo',
'.svelte-kit',
'.nuxt',
'.astro',
'dist',
'build',
'out',
'.vercel',
]);
const SCAN_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.tsx', '.jsx']);
const LAYOUT_EXTS = new Set(['.tsx', '.jsx', '.astro', '.vue', '.svelte', '.html']);
const MAX_DEPTH = 6;
const MAX_READ_BYTES = 64 * 1024;
const SHARED_HELPER_SIGNALS = [
/\bbuildCSPConfig\b/,
/\bbuildSecurityHeaders\b/,
/\badditionalScriptSrc\b/,
/\badditionalConnectSrc\b/,
/\bcreateBaseNextConfig\b/,
];
const INLINE_HEADER_SIGNALS = [
/["']Content-Security-Policy["']/i,
/\bscript-src\b/,
/\bconnect-src\b/,
];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
*/
export function detectCsp(cwd = process.cwd()) {
const hits = { sharedHelper: [], inlineHeader: [], middleware: [], metaTag: [] };
walk(cwd, cwd, 0, (absPath, relPath, body) => {
const ext = path.extname(absPath);
const base = path.basename(absPath).toLowerCase();
// Shared helper: package exports, config factory
if (SCAN_EXTS.has(ext)) {
const matched = SHARED_HELPER_SIGNALS.some((re) => re.test(body));
const looksShared = /packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath);
if (matched && looksShared) {
hits.sharedHelper.push(relPath);
}
}
// Inline headers: Next/Nuxt/SvelteKit/Astro/Vite config files
if (SCAN_EXTS.has(ext) && /(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath)) {
const allInlineMatch = INLINE_HEADER_SIGNALS.every((re) => re.test(body));
if (allInlineMatch) {
hits.inlineHeader.push(relPath);
}
}
// Middleware CSP: middleware.{ts,js} at project root or app/
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
// Meta tag CSP: layouts / HTML files
if (LAYOUT_EXTS.has(ext) && META_TAG_HINT.test(body)) {
hits.metaTag.push(relPath);
}
});
// Classification priority: shared-helper > inline-headers > middleware > meta-tag.
// A monorepo with a shared helper is always that shape, even if an individual
// app file also happens to contain a CSP literal.
if (hits.sharedHelper.length > 0) {
return {
shape: 'shared-helper',
signals: hits.sharedHelper,
};
}
if (hits.inlineHeader.length > 0) {
return {
shape: 'inline-headers',
signals: hits.inlineHeader,
};
}
if (hits.middleware.length > 0) {
return {
shape: 'middleware',
signals: hits.middleware,
};
}
if (hits.metaTag.length > 0) {
return {
shape: 'meta-tag',
signals: hits.metaTag,
};
}
return { shape: null, signals: [] };
}
function walk(root, dir, depth, visit) {
if (depth > MAX_DEPTH) return;
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return; }
for (const entry of entries) {
const abs = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
walk(root, abs, depth + 1, visit);
continue;
}
if (!entry.isFile()) continue;
const ext = path.extname(entry.name);
if (!SCAN_EXTS.has(ext) && !LAYOUT_EXTS.has(ext)) continue;
let body;
try {
const fd = fs.openSync(abs, 'r');
try {
const buf = Buffer.alloc(MAX_READ_BYTES);
const n = fs.readSync(fd, buf, 0, MAX_READ_BYTES, 0);
body = buf.slice(0, n).toString('utf-8');
} finally { fs.closeSync(fd); }
} catch { continue; }
visit(abs, path.relative(root, abs), body);
}
}
// CLI mode
const _running = process.argv[1];
if (_running?.endsWith('detect-csp.mjs') || _running?.endsWith('detect-csp.mjs/')) {
const result = detectCsp(process.cwd());
console.log(JSON.stringify(result, null, 2));
}
@@ -126,6 +126,9 @@ function validateConfig(cfg) {
if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') {
throw new Error("config.commentSyntax must be 'html' or 'jsx'");
}
if (cfg.cspChecked !== undefined && typeof cfg.cspChecked !== 'boolean') {
throw new Error("config.cspChecked, if present, must be a boolean");
}
}
function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }
+77 -1
View File
@@ -291,10 +291,13 @@ Schema:
{
"files": ["<path>", "<path>", ...],
"insertBefore": "</body>",
"commentSyntax": "html"
"commentSyntax": "html",
"cspChecked": true
}
```
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
| Framework | `files` | `insertBefore` | `commentSyntax` |
@@ -311,4 +314,77 @@ Pick an anchor that exists in every file (`</body>` almost always works). Use `i
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
### CSP detection (first-time only)
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
Otherwise, run the detection helper:
```bash
node {{scripts_path}}/detect-csp.mjs
```
Output: `{ shape, signals }` where `shape` is one of `shared-helper`, `inline-headers`, `middleware`, `meta-tag`, or `null`.
- **`null`** — no CSP; skip to writing `config.json` with `cspChecked: true`.
- **`shared-helper`** — monorepo with a CSP builder that accepts `additionalScriptSrc` / `additionalConnectSrc` arrays. Auto-patchable. See *Shape 1* below.
- **`inline-headers`** — CSP built as a literal string inside `next.config.*` (or equivalent) `headers()`. Auto-patchable. See *Shape 2* below.
- **`middleware`** or **`meta-tag`** — rarer. Detected but not auto-patched in v1. Show the user the detected files and ask them to add `http://localhost:8400` to `script-src` and `connect-src` manually, then mark `cspChecked: true` and proceed.
#### Consent prompt template
Use this phrasing so the experience is consistent across agents:
> **CSP patch needed.** I detected a Content Security Policy in your project that blocks `http://localhost:8400` — the live picker won't load without an allowance. Here's the change I'd make:
>
> ```diff
> [file: <patchTarget>]
> [exact diff, 25 lines]
> ```
>
> It's guarded by `NODE_ENV === "development"` so the extra entry only appears in dev and never reaches production. You can remove it any time by reverting this file. Apply? [y/n]
On "no": skip the patch, mention live won't work until the user adds the allowance manually, still write `cspChecked: true` (the question's been asked).
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
#### Shape 1 — shared helper with `additional*Src` arrays
The app config calls something like `createBaseNextConfig({ additionalScriptSrc: [...], additionalConnectSrc: [...] })`. Patch the *app's* config (not the shared helper) so the monorepo root stays clean. Add near the top of the app's `next.config.ts`:
```ts
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
const __impeccableLiveDev =
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
```
Append `...__impeccableLiveDev` to both `additionalScriptSrc` and `additionalConnectSrc` array options.
Idempotency: if `__impeccableLiveDev` already exists in the file, the patch is already applied; skip asking and just mark `cspChecked: true`.
See `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` for the full desired output.
#### Shape 2 — inline CSP string in `headers()`
A literal CSP string inside a `headers()` function or return value. Two-point patch: declare a dev-only variable near the top, interpolate it into the CSP string at the `script-src` and `connect-src` segments.
```ts
const __impeccableLiveDev =
process.env.NODE_ENV === "development" ? " http://localhost:8400" : "";
```
Then, inside the CSP value string:
- `script-src 'self' 'unsafe-inline'``script-src 'self' 'unsafe-inline'${__impeccableLiveDev}`
- `connect-src 'self'``connect-src 'self'${__impeccableLiveDev}`
(Leading space on the dev string so it concatenates cleanly into the existing value.)
Read the current file, locate the exact CSP string, quote the two directive edits in the consent prompt, write after confirmation.
See `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` for the full desired output.
### Troubleshooting
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs` — setup will ask again.
Then re-run `live.mjs`.
@@ -0,0 +1,167 @@
/**
* Scan a project tree for Content-Security-Policy signals and classify the
* shape so the agent knows which patch template to propose.
*
* Used at first-time `live.mjs` setup. Mechanical (grep-based) — no network,
* no dev server, no JS evaluation. The classification drives a user-facing
* consent prompt; the agent does the actual patch writing.
*
* Shape taxonomy:
* - "shared-helper": monorepo with a `createBaseNextConfig`-style helper
* that accepts `additionalScriptSrc`/`additionalConnectSrc`
* arrays. Patch the app's config to append a dev-only
* localhost entry to those arrays.
* - "inline-headers": Content-Security-Policy built inline in a Next/Nuxt/
* SvelteKit config's headers() function with a literal
* value string. Patch the CSP string in place.
* - "middleware": CSP set in middleware.{ts,js}. 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.
*/
import fs from 'node:fs';
import path from 'node:path';
const SKIP_DIRS = new Set([
'node_modules',
'.git',
'.next',
'.turbo',
'.svelte-kit',
'.nuxt',
'.astro',
'dist',
'build',
'out',
'.vercel',
]);
const SCAN_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.tsx', '.jsx']);
const LAYOUT_EXTS = new Set(['.tsx', '.jsx', '.astro', '.vue', '.svelte', '.html']);
const MAX_DEPTH = 6;
const MAX_READ_BYTES = 64 * 1024;
const SHARED_HELPER_SIGNALS = [
/\bbuildCSPConfig\b/,
/\bbuildSecurityHeaders\b/,
/\badditionalScriptSrc\b/,
/\badditionalConnectSrc\b/,
/\bcreateBaseNextConfig\b/,
];
const INLINE_HEADER_SIGNALS = [
/["']Content-Security-Policy["']/i,
/\bscript-src\b/,
/\bconnect-src\b/,
];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
*/
export function detectCsp(cwd = process.cwd()) {
const hits = { sharedHelper: [], inlineHeader: [], middleware: [], metaTag: [] };
walk(cwd, cwd, 0, (absPath, relPath, body) => {
const ext = path.extname(absPath);
const base = path.basename(absPath).toLowerCase();
// Shared helper: package exports, config factory
if (SCAN_EXTS.has(ext)) {
const matched = SHARED_HELPER_SIGNALS.some((re) => re.test(body));
const looksShared = /packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath);
if (matched && looksShared) {
hits.sharedHelper.push(relPath);
}
}
// Inline headers: Next/Nuxt/SvelteKit/Astro/Vite config files
if (SCAN_EXTS.has(ext) && /(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath)) {
const allInlineMatch = INLINE_HEADER_SIGNALS.every((re) => re.test(body));
if (allInlineMatch) {
hits.inlineHeader.push(relPath);
}
}
// Middleware CSP: middleware.{ts,js} at project root or app/
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
// Meta tag CSP: layouts / HTML files
if (LAYOUT_EXTS.has(ext) && META_TAG_HINT.test(body)) {
hits.metaTag.push(relPath);
}
});
// Classification priority: shared-helper > inline-headers > middleware > meta-tag.
// A monorepo with a shared helper is always that shape, even if an individual
// app file also happens to contain a CSP literal.
if (hits.sharedHelper.length > 0) {
return {
shape: 'shared-helper',
signals: hits.sharedHelper,
};
}
if (hits.inlineHeader.length > 0) {
return {
shape: 'inline-headers',
signals: hits.inlineHeader,
};
}
if (hits.middleware.length > 0) {
return {
shape: 'middleware',
signals: hits.middleware,
};
}
if (hits.metaTag.length > 0) {
return {
shape: 'meta-tag',
signals: hits.metaTag,
};
}
return { shape: null, signals: [] };
}
function walk(root, dir, depth, visit) {
if (depth > MAX_DEPTH) return;
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return; }
for (const entry of entries) {
const abs = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
walk(root, abs, depth + 1, visit);
continue;
}
if (!entry.isFile()) continue;
const ext = path.extname(entry.name);
if (!SCAN_EXTS.has(ext) && !LAYOUT_EXTS.has(ext)) continue;
let body;
try {
const fd = fs.openSync(abs, 'r');
try {
const buf = Buffer.alloc(MAX_READ_BYTES);
const n = fs.readSync(fd, buf, 0, MAX_READ_BYTES, 0);
body = buf.slice(0, n).toString('utf-8');
} finally { fs.closeSync(fd); }
} catch { continue; }
visit(abs, path.relative(root, abs), body);
}
}
// CLI mode
const _running = process.argv[1];
if (_running?.endsWith('detect-csp.mjs') || _running?.endsWith('detect-csp.mjs/')) {
const result = detectCsp(process.cwd());
console.log(JSON.stringify(result, null, 2));
}
@@ -126,6 +126,9 @@ function validateConfig(cfg) {
if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') {
throw new Error("config.commentSyntax must be 'html' or 'jsx'");
}
if (cfg.cspChecked !== undefined && typeof cfg.cspChecked !== 'boolean') {
throw new Error("config.cspChecked, if present, must be a boolean");
}
}
function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }
+77 -1
View File
@@ -291,10 +291,13 @@ Schema:
{
"files": ["<path>", "<path>", ...],
"insertBefore": "</body>",
"commentSyntax": "html"
"commentSyntax": "html",
"cspChecked": true
}
```
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
| Framework | `files` | `insertBefore` | `commentSyntax` |
@@ -311,4 +314,77 @@ Pick an anchor that exists in every file (`</body>` almost always works). Use `i
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
### CSP detection (first-time only)
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
Otherwise, run the detection helper:
```bash
node {{scripts_path}}/detect-csp.mjs
```
Output: `{ shape, signals }` where `shape` is one of `shared-helper`, `inline-headers`, `middleware`, `meta-tag`, or `null`.
- **`null`** — no CSP; skip to writing `config.json` with `cspChecked: true`.
- **`shared-helper`** — monorepo with a CSP builder that accepts `additionalScriptSrc` / `additionalConnectSrc` arrays. Auto-patchable. See *Shape 1* below.
- **`inline-headers`** — CSP built as a literal string inside `next.config.*` (or equivalent) `headers()`. Auto-patchable. See *Shape 2* below.
- **`middleware`** or **`meta-tag`** — rarer. Detected but not auto-patched in v1. Show the user the detected files and ask them to add `http://localhost:8400` to `script-src` and `connect-src` manually, then mark `cspChecked: true` and proceed.
#### Consent prompt template
Use this phrasing so the experience is consistent across agents:
> **CSP patch needed.** I detected a Content Security Policy in your project that blocks `http://localhost:8400` — the live picker won't load without an allowance. Here's the change I'd make:
>
> ```diff
> [file: <patchTarget>]
> [exact diff, 25 lines]
> ```
>
> It's guarded by `NODE_ENV === "development"` so the extra entry only appears in dev and never reaches production. You can remove it any time by reverting this file. Apply? [y/n]
On "no": skip the patch, mention live won't work until the user adds the allowance manually, still write `cspChecked: true` (the question's been asked).
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
#### Shape 1 — shared helper with `additional*Src` arrays
The app config calls something like `createBaseNextConfig({ additionalScriptSrc: [...], additionalConnectSrc: [...] })`. Patch the *app's* config (not the shared helper) so the monorepo root stays clean. Add near the top of the app's `next.config.ts`:
```ts
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
const __impeccableLiveDev =
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
```
Append `...__impeccableLiveDev` to both `additionalScriptSrc` and `additionalConnectSrc` array options.
Idempotency: if `__impeccableLiveDev` already exists in the file, the patch is already applied; skip asking and just mark `cspChecked: true`.
See `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` for the full desired output.
#### Shape 2 — inline CSP string in `headers()`
A literal CSP string inside a `headers()` function or return value. Two-point patch: declare a dev-only variable near the top, interpolate it into the CSP string at the `script-src` and `connect-src` segments.
```ts
const __impeccableLiveDev =
process.env.NODE_ENV === "development" ? " http://localhost:8400" : "";
```
Then, inside the CSP value string:
- `script-src 'self' 'unsafe-inline'``script-src 'self' 'unsafe-inline'${__impeccableLiveDev}`
- `connect-src 'self'``connect-src 'self'${__impeccableLiveDev}`
(Leading space on the dev string so it concatenates cleanly into the existing value.)
Read the current file, locate the exact CSP string, quote the two directive edits in the consent prompt, write after confirmation.
See `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` for the full desired output.
### Troubleshooting
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs` — setup will ask again.
Then re-run `live.mjs`.
@@ -0,0 +1,167 @@
/**
* Scan a project tree for Content-Security-Policy signals and classify the
* shape so the agent knows which patch template to propose.
*
* Used at first-time `live.mjs` setup. Mechanical (grep-based) — no network,
* no dev server, no JS evaluation. The classification drives a user-facing
* consent prompt; the agent does the actual patch writing.
*
* Shape taxonomy:
* - "shared-helper": monorepo with a `createBaseNextConfig`-style helper
* that accepts `additionalScriptSrc`/`additionalConnectSrc`
* arrays. Patch the app's config to append a dev-only
* localhost entry to those arrays.
* - "inline-headers": Content-Security-Policy built inline in a Next/Nuxt/
* SvelteKit config's headers() function with a literal
* value string. Patch the CSP string in place.
* - "middleware": CSP set in middleware.{ts,js}. 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.
*/
import fs from 'node:fs';
import path from 'node:path';
const SKIP_DIRS = new Set([
'node_modules',
'.git',
'.next',
'.turbo',
'.svelte-kit',
'.nuxt',
'.astro',
'dist',
'build',
'out',
'.vercel',
]);
const SCAN_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.tsx', '.jsx']);
const LAYOUT_EXTS = new Set(['.tsx', '.jsx', '.astro', '.vue', '.svelte', '.html']);
const MAX_DEPTH = 6;
const MAX_READ_BYTES = 64 * 1024;
const SHARED_HELPER_SIGNALS = [
/\bbuildCSPConfig\b/,
/\bbuildSecurityHeaders\b/,
/\badditionalScriptSrc\b/,
/\badditionalConnectSrc\b/,
/\bcreateBaseNextConfig\b/,
];
const INLINE_HEADER_SIGNALS = [
/["']Content-Security-Policy["']/i,
/\bscript-src\b/,
/\bconnect-src\b/,
];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
*/
export function detectCsp(cwd = process.cwd()) {
const hits = { sharedHelper: [], inlineHeader: [], middleware: [], metaTag: [] };
walk(cwd, cwd, 0, (absPath, relPath, body) => {
const ext = path.extname(absPath);
const base = path.basename(absPath).toLowerCase();
// Shared helper: package exports, config factory
if (SCAN_EXTS.has(ext)) {
const matched = SHARED_HELPER_SIGNALS.some((re) => re.test(body));
const looksShared = /packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath);
if (matched && looksShared) {
hits.sharedHelper.push(relPath);
}
}
// Inline headers: Next/Nuxt/SvelteKit/Astro/Vite config files
if (SCAN_EXTS.has(ext) && /(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath)) {
const allInlineMatch = INLINE_HEADER_SIGNALS.every((re) => re.test(body));
if (allInlineMatch) {
hits.inlineHeader.push(relPath);
}
}
// Middleware CSP: middleware.{ts,js} at project root or app/
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
// Meta tag CSP: layouts / HTML files
if (LAYOUT_EXTS.has(ext) && META_TAG_HINT.test(body)) {
hits.metaTag.push(relPath);
}
});
// Classification priority: shared-helper > inline-headers > middleware > meta-tag.
// A monorepo with a shared helper is always that shape, even if an individual
// app file also happens to contain a CSP literal.
if (hits.sharedHelper.length > 0) {
return {
shape: 'shared-helper',
signals: hits.sharedHelper,
};
}
if (hits.inlineHeader.length > 0) {
return {
shape: 'inline-headers',
signals: hits.inlineHeader,
};
}
if (hits.middleware.length > 0) {
return {
shape: 'middleware',
signals: hits.middleware,
};
}
if (hits.metaTag.length > 0) {
return {
shape: 'meta-tag',
signals: hits.metaTag,
};
}
return { shape: null, signals: [] };
}
function walk(root, dir, depth, visit) {
if (depth > MAX_DEPTH) return;
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return; }
for (const entry of entries) {
const abs = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
walk(root, abs, depth + 1, visit);
continue;
}
if (!entry.isFile()) continue;
const ext = path.extname(entry.name);
if (!SCAN_EXTS.has(ext) && !LAYOUT_EXTS.has(ext)) continue;
let body;
try {
const fd = fs.openSync(abs, 'r');
try {
const buf = Buffer.alloc(MAX_READ_BYTES);
const n = fs.readSync(fd, buf, 0, MAX_READ_BYTES, 0);
body = buf.slice(0, n).toString('utf-8');
} finally { fs.closeSync(fd); }
} catch { continue; }
visit(abs, path.relative(root, abs), body);
}
}
// CLI mode
const _running = process.argv[1];
if (_running?.endsWith('detect-csp.mjs') || _running?.endsWith('detect-csp.mjs/')) {
const result = detectCsp(process.cwd());
console.log(JSON.stringify(result, null, 2));
}
@@ -126,6 +126,9 @@ function validateConfig(cfg) {
if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') {
throw new Error("config.commentSyntax must be 'html' or 'jsx'");
}
if (cfg.cspChecked !== undefined && typeof cfg.cspChecked !== 'boolean') {
throw new Error("config.cspChecked, if present, must be a boolean");
}
}
function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }
+77 -1
View File
@@ -291,10 +291,13 @@ Schema:
{
"files": ["<path>", "<path>", ...],
"insertBefore": "</body>",
"commentSyntax": "html"
"commentSyntax": "html",
"cspChecked": true
}
```
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
| Framework | `files` | `insertBefore` | `commentSyntax` |
@@ -311,4 +314,77 @@ Pick an anchor that exists in every file (`</body>` almost always works). Use `i
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
### CSP detection (first-time only)
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
Otherwise, run the detection helper:
```bash
node {{scripts_path}}/detect-csp.mjs
```
Output: `{ shape, signals }` where `shape` is one of `shared-helper`, `inline-headers`, `middleware`, `meta-tag`, or `null`.
- **`null`** — no CSP; skip to writing `config.json` with `cspChecked: true`.
- **`shared-helper`** — monorepo with a CSP builder that accepts `additionalScriptSrc` / `additionalConnectSrc` arrays. Auto-patchable. See *Shape 1* below.
- **`inline-headers`** — CSP built as a literal string inside `next.config.*` (or equivalent) `headers()`. Auto-patchable. See *Shape 2* below.
- **`middleware`** or **`meta-tag`** — rarer. Detected but not auto-patched in v1. Show the user the detected files and ask them to add `http://localhost:8400` to `script-src` and `connect-src` manually, then mark `cspChecked: true` and proceed.
#### Consent prompt template
Use this phrasing so the experience is consistent across agents:
> **CSP patch needed.** I detected a Content Security Policy in your project that blocks `http://localhost:8400` — the live picker won't load without an allowance. Here's the change I'd make:
>
> ```diff
> [file: <patchTarget>]
> [exact diff, 25 lines]
> ```
>
> It's guarded by `NODE_ENV === "development"` so the extra entry only appears in dev and never reaches production. You can remove it any time by reverting this file. Apply? [y/n]
On "no": skip the patch, mention live won't work until the user adds the allowance manually, still write `cspChecked: true` (the question's been asked).
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
#### Shape 1 — shared helper with `additional*Src` arrays
The app config calls something like `createBaseNextConfig({ additionalScriptSrc: [...], additionalConnectSrc: [...] })`. Patch the *app's* config (not the shared helper) so the monorepo root stays clean. Add near the top of the app's `next.config.ts`:
```ts
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
const __impeccableLiveDev =
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
```
Append `...__impeccableLiveDev` to both `additionalScriptSrc` and `additionalConnectSrc` array options.
Idempotency: if `__impeccableLiveDev` already exists in the file, the patch is already applied; skip asking and just mark `cspChecked: true`.
See `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` for the full desired output.
#### Shape 2 — inline CSP string in `headers()`
A literal CSP string inside a `headers()` function or return value. Two-point patch: declare a dev-only variable near the top, interpolate it into the CSP string at the `script-src` and `connect-src` segments.
```ts
const __impeccableLiveDev =
process.env.NODE_ENV === "development" ? " http://localhost:8400" : "";
```
Then, inside the CSP value string:
- `script-src 'self' 'unsafe-inline'``script-src 'self' 'unsafe-inline'${__impeccableLiveDev}`
- `connect-src 'self'``connect-src 'self'${__impeccableLiveDev}`
(Leading space on the dev string so it concatenates cleanly into the existing value.)
Read the current file, locate the exact CSP string, quote the two directive edits in the consent prompt, write after confirmation.
See `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` for the full desired output.
### Troubleshooting
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs` — setup will ask again.
Then re-run `live.mjs`.
@@ -0,0 +1,167 @@
/**
* Scan a project tree for Content-Security-Policy signals and classify the
* shape so the agent knows which patch template to propose.
*
* Used at first-time `live.mjs` setup. Mechanical (grep-based) — no network,
* no dev server, no JS evaluation. The classification drives a user-facing
* consent prompt; the agent does the actual patch writing.
*
* Shape taxonomy:
* - "shared-helper": monorepo with a `createBaseNextConfig`-style helper
* that accepts `additionalScriptSrc`/`additionalConnectSrc`
* arrays. Patch the app's config to append a dev-only
* localhost entry to those arrays.
* - "inline-headers": Content-Security-Policy built inline in a Next/Nuxt/
* SvelteKit config's headers() function with a literal
* value string. Patch the CSP string in place.
* - "middleware": CSP set in middleware.{ts,js}. 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.
*/
import fs from 'node:fs';
import path from 'node:path';
const SKIP_DIRS = new Set([
'node_modules',
'.git',
'.next',
'.turbo',
'.svelte-kit',
'.nuxt',
'.astro',
'dist',
'build',
'out',
'.vercel',
]);
const SCAN_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.tsx', '.jsx']);
const LAYOUT_EXTS = new Set(['.tsx', '.jsx', '.astro', '.vue', '.svelte', '.html']);
const MAX_DEPTH = 6;
const MAX_READ_BYTES = 64 * 1024;
const SHARED_HELPER_SIGNALS = [
/\bbuildCSPConfig\b/,
/\bbuildSecurityHeaders\b/,
/\badditionalScriptSrc\b/,
/\badditionalConnectSrc\b/,
/\bcreateBaseNextConfig\b/,
];
const INLINE_HEADER_SIGNALS = [
/["']Content-Security-Policy["']/i,
/\bscript-src\b/,
/\bconnect-src\b/,
];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
*/
export function detectCsp(cwd = process.cwd()) {
const hits = { sharedHelper: [], inlineHeader: [], middleware: [], metaTag: [] };
walk(cwd, cwd, 0, (absPath, relPath, body) => {
const ext = path.extname(absPath);
const base = path.basename(absPath).toLowerCase();
// Shared helper: package exports, config factory
if (SCAN_EXTS.has(ext)) {
const matched = SHARED_HELPER_SIGNALS.some((re) => re.test(body));
const looksShared = /packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath);
if (matched && looksShared) {
hits.sharedHelper.push(relPath);
}
}
// Inline headers: Next/Nuxt/SvelteKit/Astro/Vite config files
if (SCAN_EXTS.has(ext) && /(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath)) {
const allInlineMatch = INLINE_HEADER_SIGNALS.every((re) => re.test(body));
if (allInlineMatch) {
hits.inlineHeader.push(relPath);
}
}
// Middleware CSP: middleware.{ts,js} at project root or app/
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
// Meta tag CSP: layouts / HTML files
if (LAYOUT_EXTS.has(ext) && META_TAG_HINT.test(body)) {
hits.metaTag.push(relPath);
}
});
// Classification priority: shared-helper > inline-headers > middleware > meta-tag.
// A monorepo with a shared helper is always that shape, even if an individual
// app file also happens to contain a CSP literal.
if (hits.sharedHelper.length > 0) {
return {
shape: 'shared-helper',
signals: hits.sharedHelper,
};
}
if (hits.inlineHeader.length > 0) {
return {
shape: 'inline-headers',
signals: hits.inlineHeader,
};
}
if (hits.middleware.length > 0) {
return {
shape: 'middleware',
signals: hits.middleware,
};
}
if (hits.metaTag.length > 0) {
return {
shape: 'meta-tag',
signals: hits.metaTag,
};
}
return { shape: null, signals: [] };
}
function walk(root, dir, depth, visit) {
if (depth > MAX_DEPTH) return;
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return; }
for (const entry of entries) {
const abs = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
walk(root, abs, depth + 1, visit);
continue;
}
if (!entry.isFile()) continue;
const ext = path.extname(entry.name);
if (!SCAN_EXTS.has(ext) && !LAYOUT_EXTS.has(ext)) continue;
let body;
try {
const fd = fs.openSync(abs, 'r');
try {
const buf = Buffer.alloc(MAX_READ_BYTES);
const n = fs.readSync(fd, buf, 0, MAX_READ_BYTES, 0);
body = buf.slice(0, n).toString('utf-8');
} finally { fs.closeSync(fd); }
} catch { continue; }
visit(abs, path.relative(root, abs), body);
}
}
// CLI mode
const _running = process.argv[1];
if (_running?.endsWith('detect-csp.mjs') || _running?.endsWith('detect-csp.mjs/')) {
const result = detectCsp(process.cwd());
console.log(JSON.stringify(result, null, 2));
}
@@ -126,6 +126,9 @@ function validateConfig(cfg) {
if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') {
throw new Error("config.commentSyntax must be 'html' or 'jsx'");
}
if (cfg.cspChecked !== undefined && typeof cfg.cspChecked !== 'boolean') {
throw new Error("config.cspChecked, if present, must be a boolean");
}
}
function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }
+77 -1
View File
@@ -291,10 +291,13 @@ Schema:
{
"files": ["<path>", "<path>", ...],
"insertBefore": "</body>",
"commentSyntax": "html"
"commentSyntax": "html",
"cspChecked": true
}
```
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
| Framework | `files` | `insertBefore` | `commentSyntax` |
@@ -311,4 +314,77 @@ Pick an anchor that exists in every file (`</body>` almost always works). Use `i
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
### CSP detection (first-time only)
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
Otherwise, run the detection helper:
```bash
node {{scripts_path}}/detect-csp.mjs
```
Output: `{ shape, signals }` where `shape` is one of `shared-helper`, `inline-headers`, `middleware`, `meta-tag`, or `null`.
- **`null`** — no CSP; skip to writing `config.json` with `cspChecked: true`.
- **`shared-helper`** — monorepo with a CSP builder that accepts `additionalScriptSrc` / `additionalConnectSrc` arrays. Auto-patchable. See *Shape 1* below.
- **`inline-headers`** — CSP built as a literal string inside `next.config.*` (or equivalent) `headers()`. Auto-patchable. See *Shape 2* below.
- **`middleware`** or **`meta-tag`** — rarer. Detected but not auto-patched in v1. Show the user the detected files and ask them to add `http://localhost:8400` to `script-src` and `connect-src` manually, then mark `cspChecked: true` and proceed.
#### Consent prompt template
Use this phrasing so the experience is consistent across agents:
> **CSP patch needed.** I detected a Content Security Policy in your project that blocks `http://localhost:8400` — the live picker won't load without an allowance. Here's the change I'd make:
>
> ```diff
> [file: <patchTarget>]
> [exact diff, 25 lines]
> ```
>
> It's guarded by `NODE_ENV === "development"` so the extra entry only appears in dev and never reaches production. You can remove it any time by reverting this file. Apply? [y/n]
On "no": skip the patch, mention live won't work until the user adds the allowance manually, still write `cspChecked: true` (the question's been asked).
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
#### Shape 1 — shared helper with `additional*Src` arrays
The app config calls something like `createBaseNextConfig({ additionalScriptSrc: [...], additionalConnectSrc: [...] })`. Patch the *app's* config (not the shared helper) so the monorepo root stays clean. Add near the top of the app's `next.config.ts`:
```ts
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
const __impeccableLiveDev =
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
```
Append `...__impeccableLiveDev` to both `additionalScriptSrc` and `additionalConnectSrc` array options.
Idempotency: if `__impeccableLiveDev` already exists in the file, the patch is already applied; skip asking and just mark `cspChecked: true`.
See `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` for the full desired output.
#### Shape 2 — inline CSP string in `headers()`
A literal CSP string inside a `headers()` function or return value. Two-point patch: declare a dev-only variable near the top, interpolate it into the CSP string at the `script-src` and `connect-src` segments.
```ts
const __impeccableLiveDev =
process.env.NODE_ENV === "development" ? " http://localhost:8400" : "";
```
Then, inside the CSP value string:
- `script-src 'self' 'unsafe-inline'``script-src 'self' 'unsafe-inline'${__impeccableLiveDev}`
- `connect-src 'self'``connect-src 'self'${__impeccableLiveDev}`
(Leading space on the dev string so it concatenates cleanly into the existing value.)
Read the current file, locate the exact CSP string, quote the two directive edits in the consent prompt, write after confirmation.
See `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` for the full desired output.
### Troubleshooting
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs` — setup will ask again.
Then re-run `live.mjs`.
@@ -0,0 +1,167 @@
/**
* Scan a project tree for Content-Security-Policy signals and classify the
* shape so the agent knows which patch template to propose.
*
* Used at first-time `live.mjs` setup. Mechanical (grep-based) — no network,
* no dev server, no JS evaluation. The classification drives a user-facing
* consent prompt; the agent does the actual patch writing.
*
* Shape taxonomy:
* - "shared-helper": monorepo with a `createBaseNextConfig`-style helper
* that accepts `additionalScriptSrc`/`additionalConnectSrc`
* arrays. Patch the app's config to append a dev-only
* localhost entry to those arrays.
* - "inline-headers": Content-Security-Policy built inline in a Next/Nuxt/
* SvelteKit config's headers() function with a literal
* value string. Patch the CSP string in place.
* - "middleware": CSP set in middleware.{ts,js}. 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.
*/
import fs from 'node:fs';
import path from 'node:path';
const SKIP_DIRS = new Set([
'node_modules',
'.git',
'.next',
'.turbo',
'.svelte-kit',
'.nuxt',
'.astro',
'dist',
'build',
'out',
'.vercel',
]);
const SCAN_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.tsx', '.jsx']);
const LAYOUT_EXTS = new Set(['.tsx', '.jsx', '.astro', '.vue', '.svelte', '.html']);
const MAX_DEPTH = 6;
const MAX_READ_BYTES = 64 * 1024;
const SHARED_HELPER_SIGNALS = [
/\bbuildCSPConfig\b/,
/\bbuildSecurityHeaders\b/,
/\badditionalScriptSrc\b/,
/\badditionalConnectSrc\b/,
/\bcreateBaseNextConfig\b/,
];
const INLINE_HEADER_SIGNALS = [
/["']Content-Security-Policy["']/i,
/\bscript-src\b/,
/\bconnect-src\b/,
];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
*/
export function detectCsp(cwd = process.cwd()) {
const hits = { sharedHelper: [], inlineHeader: [], middleware: [], metaTag: [] };
walk(cwd, cwd, 0, (absPath, relPath, body) => {
const ext = path.extname(absPath);
const base = path.basename(absPath).toLowerCase();
// Shared helper: package exports, config factory
if (SCAN_EXTS.has(ext)) {
const matched = SHARED_HELPER_SIGNALS.some((re) => re.test(body));
const looksShared = /packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath);
if (matched && looksShared) {
hits.sharedHelper.push(relPath);
}
}
// Inline headers: Next/Nuxt/SvelteKit/Astro/Vite config files
if (SCAN_EXTS.has(ext) && /(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath)) {
const allInlineMatch = INLINE_HEADER_SIGNALS.every((re) => re.test(body));
if (allInlineMatch) {
hits.inlineHeader.push(relPath);
}
}
// Middleware CSP: middleware.{ts,js} at project root or app/
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
// Meta tag CSP: layouts / HTML files
if (LAYOUT_EXTS.has(ext) && META_TAG_HINT.test(body)) {
hits.metaTag.push(relPath);
}
});
// Classification priority: shared-helper > inline-headers > middleware > meta-tag.
// A monorepo with a shared helper is always that shape, even if an individual
// app file also happens to contain a CSP literal.
if (hits.sharedHelper.length > 0) {
return {
shape: 'shared-helper',
signals: hits.sharedHelper,
};
}
if (hits.inlineHeader.length > 0) {
return {
shape: 'inline-headers',
signals: hits.inlineHeader,
};
}
if (hits.middleware.length > 0) {
return {
shape: 'middleware',
signals: hits.middleware,
};
}
if (hits.metaTag.length > 0) {
return {
shape: 'meta-tag',
signals: hits.metaTag,
};
}
return { shape: null, signals: [] };
}
function walk(root, dir, depth, visit) {
if (depth > MAX_DEPTH) return;
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return; }
for (const entry of entries) {
const abs = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
walk(root, abs, depth + 1, visit);
continue;
}
if (!entry.isFile()) continue;
const ext = path.extname(entry.name);
if (!SCAN_EXTS.has(ext) && !LAYOUT_EXTS.has(ext)) continue;
let body;
try {
const fd = fs.openSync(abs, 'r');
try {
const buf = Buffer.alloc(MAX_READ_BYTES);
const n = fs.readSync(fd, buf, 0, MAX_READ_BYTES, 0);
body = buf.slice(0, n).toString('utf-8');
} finally { fs.closeSync(fd); }
} catch { continue; }
visit(abs, path.relative(root, abs), body);
}
}
// CLI mode
const _running = process.argv[1];
if (_running?.endsWith('detect-csp.mjs') || _running?.endsWith('detect-csp.mjs/')) {
const result = detectCsp(process.cwd());
console.log(JSON.stringify(result, null, 2));
}
@@ -126,6 +126,9 @@ function validateConfig(cfg) {
if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') {
throw new Error("config.commentSyntax must be 'html' or 'jsx'");
}
if (cfg.cspChecked !== undefined && typeof cfg.cspChecked !== 'boolean') {
throw new Error("config.cspChecked, if present, must be a boolean");
}
}
function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }
+77 -1
View File
@@ -291,10 +291,13 @@ Schema:
{
"files": ["<path>", "<path>", ...],
"insertBefore": "</body>",
"commentSyntax": "html"
"commentSyntax": "html",
"cspChecked": true
}
```
`cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed).
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
| Framework | `files` | `insertBefore` | `commentSyntax` |
@@ -311,4 +314,77 @@ Pick an anchor that exists in every file (`</body>` almost always works). Use `i
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
### CSP detection (first-time only)
If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks.
Otherwise, run the detection helper:
```bash
node {{scripts_path}}/detect-csp.mjs
```
Output: `{ shape, signals }` where `shape` is one of `shared-helper`, `inline-headers`, `middleware`, `meta-tag`, or `null`.
- **`null`** — no CSP; skip to writing `config.json` with `cspChecked: true`.
- **`shared-helper`** — monorepo with a CSP builder that accepts `additionalScriptSrc` / `additionalConnectSrc` arrays. Auto-patchable. See *Shape 1* below.
- **`inline-headers`** — CSP built as a literal string inside `next.config.*` (or equivalent) `headers()`. Auto-patchable. See *Shape 2* below.
- **`middleware`** or **`meta-tag`** — rarer. Detected but not auto-patched in v1. Show the user the detected files and ask them to add `http://localhost:8400` to `script-src` and `connect-src` manually, then mark `cspChecked: true` and proceed.
#### Consent prompt template
Use this phrasing so the experience is consistent across agents:
> **CSP patch needed.** I detected a Content Security Policy in your project that blocks `http://localhost:8400` — the live picker won't load without an allowance. Here's the change I'd make:
>
> ```diff
> [file: <patchTarget>]
> [exact diff, 25 lines]
> ```
>
> It's guarded by `NODE_ENV === "development"` so the extra entry only appears in dev and never reaches production. You can remove it any time by reverting this file. Apply? [y/n]
On "no": skip the patch, mention live won't work until the user adds the allowance manually, still write `cspChecked: true` (the question's been asked).
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
#### Shape 1 — shared helper with `additional*Src` arrays
The app config calls something like `createBaseNextConfig({ additionalScriptSrc: [...], additionalConnectSrc: [...] })`. Patch the *app's* config (not the shared helper) so the monorepo root stays clean. Add near the top of the app's `next.config.ts`:
```ts
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
const __impeccableLiveDev =
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
```
Append `...__impeccableLiveDev` to both `additionalScriptSrc` and `additionalConnectSrc` array options.
Idempotency: if `__impeccableLiveDev` already exists in the file, the patch is already applied; skip asking and just mark `cspChecked: true`.
See `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` for the full desired output.
#### Shape 2 — inline CSP string in `headers()`
A literal CSP string inside a `headers()` function or return value. Two-point patch: declare a dev-only variable near the top, interpolate it into the CSP string at the `script-src` and `connect-src` segments.
```ts
const __impeccableLiveDev =
process.env.NODE_ENV === "development" ? " http://localhost:8400" : "";
```
Then, inside the CSP value string:
- `script-src 'self' 'unsafe-inline'``script-src 'self' 'unsafe-inline'${__impeccableLiveDev}`
- `connect-src 'self'``connect-src 'self'${__impeccableLiveDev}`
(Leading space on the dev string so it concatenates cleanly into the existing value.)
Read the current file, locate the exact CSP string, quote the two directive edits in the consent prompt, write after confirmation.
See `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` for the full desired output.
### Troubleshooting
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs` — setup will ask again.
Then re-run `live.mjs`.
@@ -0,0 +1,167 @@
/**
* Scan a project tree for Content-Security-Policy signals and classify the
* shape so the agent knows which patch template to propose.
*
* Used at first-time `live.mjs` setup. Mechanical (grep-based) — no network,
* no dev server, no JS evaluation. The classification drives a user-facing
* consent prompt; the agent does the actual patch writing.
*
* Shape taxonomy:
* - "shared-helper": monorepo with a `createBaseNextConfig`-style helper
* that accepts `additionalScriptSrc`/`additionalConnectSrc`
* arrays. Patch the app's config to append a dev-only
* localhost entry to those arrays.
* - "inline-headers": Content-Security-Policy built inline in a Next/Nuxt/
* SvelteKit config's headers() function with a literal
* value string. Patch the CSP string in place.
* - "middleware": CSP set in middleware.{ts,js}. 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.
*/
import fs from 'node:fs';
import path from 'node:path';
const SKIP_DIRS = new Set([
'node_modules',
'.git',
'.next',
'.turbo',
'.svelte-kit',
'.nuxt',
'.astro',
'dist',
'build',
'out',
'.vercel',
]);
const SCAN_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.tsx', '.jsx']);
const LAYOUT_EXTS = new Set(['.tsx', '.jsx', '.astro', '.vue', '.svelte', '.html']);
const MAX_DEPTH = 6;
const MAX_READ_BYTES = 64 * 1024;
const SHARED_HELPER_SIGNALS = [
/\bbuildCSPConfig\b/,
/\bbuildSecurityHeaders\b/,
/\badditionalScriptSrc\b/,
/\badditionalConnectSrc\b/,
/\bcreateBaseNextConfig\b/,
];
const INLINE_HEADER_SIGNALS = [
/["']Content-Security-Policy["']/i,
/\bscript-src\b/,
/\bconnect-src\b/,
];
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
/**
* @param {string} cwd Project root.
* @returns {{ shape: string|null, signals: string[] }}
*/
export function detectCsp(cwd = process.cwd()) {
const hits = { sharedHelper: [], inlineHeader: [], middleware: [], metaTag: [] };
walk(cwd, cwd, 0, (absPath, relPath, body) => {
const ext = path.extname(absPath);
const base = path.basename(absPath).toLowerCase();
// Shared helper: package exports, config factory
if (SCAN_EXTS.has(ext)) {
const matched = SHARED_HELPER_SIGNALS.some((re) => re.test(body));
const looksShared = /packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath);
if (matched && looksShared) {
hits.sharedHelper.push(relPath);
}
}
// Inline headers: Next/Nuxt/SvelteKit/Astro/Vite config files
if (SCAN_EXTS.has(ext) && /(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath)) {
const allInlineMatch = INLINE_HEADER_SIGNALS.every((re) => re.test(body));
if (allInlineMatch) {
hits.inlineHeader.push(relPath);
}
}
// Middleware CSP: middleware.{ts,js} at project root or app/
if ((base === 'middleware.ts' || base === 'middleware.js' || base === 'middleware.mjs') &&
MIDDLEWARE_HINT.test(body)) {
hits.middleware.push(relPath);
}
// Meta tag CSP: layouts / HTML files
if (LAYOUT_EXTS.has(ext) && META_TAG_HINT.test(body)) {
hits.metaTag.push(relPath);
}
});
// Classification priority: shared-helper > inline-headers > middleware > meta-tag.
// A monorepo with a shared helper is always that shape, even if an individual
// app file also happens to contain a CSP literal.
if (hits.sharedHelper.length > 0) {
return {
shape: 'shared-helper',
signals: hits.sharedHelper,
};
}
if (hits.inlineHeader.length > 0) {
return {
shape: 'inline-headers',
signals: hits.inlineHeader,
};
}
if (hits.middleware.length > 0) {
return {
shape: 'middleware',
signals: hits.middleware,
};
}
if (hits.metaTag.length > 0) {
return {
shape: 'meta-tag',
signals: hits.metaTag,
};
}
return { shape: null, signals: [] };
}
function walk(root, dir, depth, visit) {
if (depth > MAX_DEPTH) return;
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return; }
for (const entry of entries) {
const abs = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
walk(root, abs, depth + 1, visit);
continue;
}
if (!entry.isFile()) continue;
const ext = path.extname(entry.name);
if (!SCAN_EXTS.has(ext) && !LAYOUT_EXTS.has(ext)) continue;
let body;
try {
const fd = fs.openSync(abs, 'r');
try {
const buf = Buffer.alloc(MAX_READ_BYTES);
const n = fs.readSync(fd, buf, 0, MAX_READ_BYTES, 0);
body = buf.slice(0, n).toString('utf-8');
} finally { fs.closeSync(fd); }
} catch { continue; }
visit(abs, path.relative(root, abs), body);
}
}
// CLI mode
const _running = process.argv[1];
if (_running?.endsWith('detect-csp.mjs') || _running?.endsWith('detect-csp.mjs/')) {
const result = detectCsp(process.cwd());
console.log(JSON.stringify(result, null, 2));
}
@@ -126,6 +126,9 @@ function validateConfig(cfg) {
if (cfg.commentSyntax !== 'html' && cfg.commentSyntax !== 'jsx') {
throw new Error("config.commentSyntax must be 'html' or 'jsx'");
}
if (cfg.cspChecked !== undefined && typeof cfg.cspChecked !== 'boolean') {
throw new Error("config.cspChecked, if present, must be a boolean");
}
}
function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }
+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/