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' ? '{/*' : '<!--'; }