mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
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:
co-authored by
Claude Opus 4.7
parent
444f881295
commit
d5480caee3
@@ -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, 2–5 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' ? '{/*' : '<!--'; }
|
||||
|
||||
Reference in New Issue
Block a user