mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
feat(live): extend CSP detection to SvelteKit and Nuxt
Shape names renamed to describe the patch mechanism (what the agent does) rather than the origin (where the CSP lives). One template now covers multiple frameworks. ## Shape rename - shared-helper → append-arrays - inline-headers → append-string append-arrays applies wherever CSP is a structured directive array. append-string applies wherever CSP is a literal value string. ## New detection coverage - SvelteKit kit.csp.directives in svelte.config.js → append-arrays - Nuxt routeRules / nitro.routeRules CSP header → append-string - Nuxt-security module's contentSecurityPolicy → append-arrays ## New fixtures - sveltekit-csp/: SvelteKit config with kit.csp.directives. Includes expected-after-patch.js showing the array spread. - nuxt-csp/: Nuxt 3 config with routeRules CSP. Includes expected-after-patch.ts showing the string splice. ## Skill docs Single append-arrays template covers Next monorepo, SvelteKit, and Nuxt-security. Single append-string template covers inline Next headers() and Nuxt routeRules. Per-framework specifics listed as sub-bullets under each shape. 54 tests across 9 fixtures, all passing. Clean fixtures (plain vite, nextjs-app, astro, sveltekit, multipage-with-generator) still classify as shape: null. Astro and Vue (non-Nuxt) left unhandled by design: Astro has no first-party CSP mechanism; Vue without Nuxt is covered by the existing Vite fixture. Plain Svelte has no framework CSP primitive and inherits from its bundler (Vite/Rollup). 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
d5480caee3
commit
05b0ac3e1f
@@ -324,11 +324,16 @@ Otherwise, run the detection helper:
|
||||
node {{scripts_path}}/detect-csp.mjs
|
||||
```
|
||||
|
||||
Output: `{ shape, signals }` where `shape` is one of `shared-helper`, `inline-headers`, `middleware`, `meta-tag`, or `null`.
|
||||
Output: `{ shape, signals }` where `shape` is one of `append-arrays`, `append-string`, `middleware`, `meta-tag`, or `null`. The shape is named by *patch mechanism*, so one template covers many frameworks.
|
||||
|
||||
- **`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.
|
||||
- **`append-arrays`** — CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers:
|
||||
- Monorepo helpers with `additionalScriptSrc` / `additionalConnectSrc` options (Next.js + shared config package)
|
||||
- SvelteKit `kit.csp.directives`
|
||||
- Nuxt `nuxt-security` module's `contentSecurityPolicy`
|
||||
- **`append-string`** — CSP written as a literal value string. Auto-patchable. See *append-string* below. Covers:
|
||||
- Inline `next.config.*` `headers()` with a CSP literal
|
||||
- Nuxt `routeRules` / `nitro.routeRules` headers
|
||||
- **`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
|
||||
@@ -348,9 +353,11 @@ On "no": skip the patch, mention live won't work until the user adds the allowan
|
||||
|
||||
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
|
||||
|
||||
#### Shape 1 — shared helper with `additional*Src` arrays
|
||||
#### append-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`:
|
||||
CSP expressed as structured directive arrays. Patch mechanism: declare a dev-only array, spread it into the script-src and connect-src arrays.
|
||||
|
||||
**Declare near the top of the file that holds the CSP arrays:**
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
|
||||
@@ -358,30 +365,41 @@ const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
|
||||
```
|
||||
|
||||
Append `...__impeccableLiveDev` to both `additionalScriptSrc` and `additionalConnectSrc` array options.
|
||||
**Append `...__impeccableLiveDev` to the script-src and connect-src directive arrays.** Per-framework specifics:
|
||||
|
||||
- **Next.js + monorepo helper** — edit the *app's* `next.config.*` (not the shared helper), appending to `additionalScriptSrc` and `additionalConnectSrc` passed into `createBaseNextConfig` (or equivalent). Keeps the shared package clean.
|
||||
- **SvelteKit** — edit `svelte.config.js`, appending to `kit.csp.directives['script-src']` and `kit.csp.directives['connect-src']`.
|
||||
- **Nuxt + nuxt-security** — edit `nuxt.config.*`, appending to `security.headers.contentSecurityPolicy['script-src']` and `['connect-src']`.
|
||||
|
||||
Reference outputs:
|
||||
- `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` (Next.js)
|
||||
- `tests/framework-fixtures/sveltekit-csp/expected-after-patch.js` (SvelteKit)
|
||||
|
||||
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.
|
||||
#### append-string
|
||||
|
||||
#### 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.
|
||||
CSP built as a literal value string. Two-point patch: declare a dev-only string near the top, interpolate it into the CSP at the `script-src` and `connect-src` directives.
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load.
|
||||
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}`
|
||||
Then in 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.)
|
||||
(Leading space on the dev string so it concatenates cleanly into the existing value. Convert the literal CSP directives into template strings as part of the edit if they aren't already.)
|
||||
|
||||
Read the current file, locate the exact CSP string, quote the two directive edits in the consent prompt, write after confirmation.
|
||||
Per-framework specifics:
|
||||
- **Next.js inline `headers()`** — edit `next.config.*`, splicing the variable into the CSP value.
|
||||
- **Nuxt `routeRules`** — edit `nuxt.config.*`, splicing into the CSP in `routeRules['/**'].headers['Content-Security-Policy']`.
|
||||
|
||||
See `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` for the full desired output.
|
||||
Reference outputs:
|
||||
- `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` (Next.js)
|
||||
- `tests/framework-fixtures/nuxt-csp/expected-after-patch.ts` (Nuxt)
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
|
||||
@@ -80,11 +80,31 @@ export function findProjectRoot(startDir = process.cwd()) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable by reading its
|
||||
* SKILL.md and looking for the word "impeccable" (case-insensitive).
|
||||
* Returns false for non-existent paths or skills that don't match.
|
||||
* Load skills-lock.json from the project root, or null if missing/unreadable.
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir) {
|
||||
export function loadLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable. Prefers the
|
||||
* authoritative lock signal (source === "pbakaus/impeccable") when a
|
||||
* skillName and lock are supplied, and falls back to a SKILL.md
|
||||
* content check for older skills that predate the self-identification
|
||||
* convention.
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir, { skillName, lock } = {}) {
|
||||
// Authoritative: the lock file claims this skill is ours.
|
||||
if (skillName && lock?.skills?.[skillName]?.source === 'pbakaus/impeccable') {
|
||||
return true;
|
||||
}
|
||||
// Fallback: content heuristic for skills without a lock entry.
|
||||
const skillMd = join(skillDir, 'SKILL.md');
|
||||
if (!existsSync(skillMd)) return false;
|
||||
try {
|
||||
@@ -125,9 +145,12 @@ export function findSkillsDirs(projectRoot) {
|
||||
|
||||
/**
|
||||
* Remove deprecated skill directories/symlinks from all harness dirs.
|
||||
* Reads skills-lock.json so the authoritative "source" field can
|
||||
* drive deletion even when SKILL.md never mentions impeccable.
|
||||
* Returns an array of paths that were deleted.
|
||||
*/
|
||||
export function removeDeprecatedSkills(projectRoot) {
|
||||
export function removeDeprecatedSkills(projectRoot, lock) {
|
||||
if (lock === undefined) lock = loadLock(projectRoot);
|
||||
const targets = buildTargetNames();
|
||||
const skillsDirs = findSkillsDirs(projectRoot);
|
||||
const deleted = [];
|
||||
@@ -149,7 +172,9 @@ export function removeDeprecatedSkills(projectRoot) {
|
||||
// Symlink: check the target if it's alive, otherwise treat
|
||||
// dangling symlinks to deprecated names as safe to remove.
|
||||
const targetAlive = existsSync(skillPath);
|
||||
const isMatch = targetAlive ? isImpeccableSkill(skillPath) : true;
|
||||
const isMatch = targetAlive
|
||||
? isImpeccableSkill(skillPath, { skillName: name, lock })
|
||||
: true;
|
||||
if (isMatch) {
|
||||
unlinkSync(skillPath);
|
||||
deleted.push(skillPath);
|
||||
@@ -158,7 +183,7 @@ export function removeDeprecatedSkills(projectRoot) {
|
||||
}
|
||||
|
||||
// Regular directory -- verify it belongs to impeccable
|
||||
if (isImpeccableSkill(skillPath)) {
|
||||
if (isImpeccableSkill(skillPath, { skillName: name, lock })) {
|
||||
rmSync(skillPath, { recursive: true, force: true });
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
@@ -208,10 +233,15 @@ export function cleanSkillsLock(projectRoot) {
|
||||
|
||||
/**
|
||||
* Run the full cleanup. Returns a summary object.
|
||||
*
|
||||
* Order matters: read the lock and delete directories first, then
|
||||
* strip lock entries. Otherwise the authoritative signal is gone by
|
||||
* the time directory deletion runs.
|
||||
*/
|
||||
export function cleanup(projectRoot) {
|
||||
const root = projectRoot || findProjectRoot();
|
||||
const deletedPaths = removeDeprecatedSkills(root);
|
||||
const lock = loadLock(root);
|
||||
const deletedPaths = removeDeprecatedSkills(root, lock);
|
||||
const removedLockEntries = cleanSkillsLock(root);
|
||||
return { deletedPaths, removedLockEntries, projectRoot: root };
|
||||
}
|
||||
|
||||
@@ -6,18 +6,22 @@
|
||||
* 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.
|
||||
* Shapes are named by patch mechanism, not framework origin:
|
||||
* - "append-arrays": CSP defined as structured directive arrays. Patch
|
||||
* appends a dev-only localhost entry. Covers:
|
||||
* - Monorepo helpers with additional*Src options
|
||||
* (e.g. createBaseNextConfig for Next)
|
||||
* - SvelteKit kit.csp.directives
|
||||
* - nuxt-security module's contentSecurityPolicy
|
||||
* - "append-string": CSP built as a literal value string. Patch splices
|
||||
* a dev-only token into script-src and connect-src.
|
||||
* Covers:
|
||||
* - Inline Next.js headers() with CSP string
|
||||
* - Nuxt routeRules / nitro.routeRules CSP headers
|
||||
* - "middleware": CSP set dynamically in middleware.{ts,js}.
|
||||
* Detected but not auto-patched in v1.
|
||||
* - "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.
|
||||
*/
|
||||
|
||||
@@ -43,19 +47,35 @@ 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 = [
|
||||
// append-arrays signals: CSP expressed as structured directive arrays
|
||||
const MONOREPO_HELPER_SIGNALS = [
|
||||
/\bbuildCSPConfig\b/,
|
||||
/\bbuildSecurityHeaders\b/,
|
||||
/\badditionalScriptSrc\b/,
|
||||
/\badditionalConnectSrc\b/,
|
||||
/\bcreateBaseNextConfig\b/,
|
||||
];
|
||||
const SVELTEKIT_CSP_SIGNALS = [
|
||||
/\bkit\s*:/,
|
||||
/\bcsp\s*:/,
|
||||
/\bdirectives\s*:/,
|
||||
];
|
||||
const NUXT_SECURITY_SIGNALS = [
|
||||
/['"]nuxt-security['"]/,
|
||||
/\bcontentSecurityPolicy\b/,
|
||||
];
|
||||
|
||||
// append-string signals: CSP written as a literal value string
|
||||
const INLINE_HEADER_SIGNALS = [
|
||||
/["']Content-Security-Policy["']/i,
|
||||
/\bscript-src\b/,
|
||||
/\bconnect-src\b/,
|
||||
];
|
||||
const NUXT_ROUTE_RULES_SIGNALS = [
|
||||
/\brouteRules\b/,
|
||||
/Content-Security-Policy/i,
|
||||
/\bscript-src\b/,
|
||||
];
|
||||
|
||||
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
|
||||
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
@@ -65,67 +85,78 @@ const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
* @returns {{ shape: string|null, signals: string[] }}
|
||||
*/
|
||||
export function detectCsp(cwd = process.cwd()) {
|
||||
const hits = { sharedHelper: [], inlineHeader: [], middleware: [], metaTag: [] };
|
||||
const hits = { appendArrays: [], appendString: [], middleware: [], metaTag: [] };
|
||||
|
||||
walk(cwd, cwd, 0, (absPath, relPath, body) => {
|
||||
const ext = path.extname(absPath);
|
||||
const base = path.basename(absPath).toLowerCase();
|
||||
const isConfig = (name) =>
|
||||
new RegExp('(^|/)' + name + '\\.config\\.').test(relPath);
|
||||
|
||||
// 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);
|
||||
}
|
||||
// === append-arrays candidates ===
|
||||
|
||||
// Monorepo CSP helper: packages/*/src/.../(config|security)/*
|
||||
if (SCAN_EXTS.has(ext) &&
|
||||
/packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath) &&
|
||||
MONOREPO_HELPER_SIGNALS.some((re) => re.test(body))) {
|
||||
hits.appendArrays.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
// SvelteKit kit.csp.directives
|
||||
if (SCAN_EXTS.has(ext) && isConfig('svelte') &&
|
||||
SVELTEKIT_CSP_SIGNALS.every((re) => re.test(body))) {
|
||||
hits.appendArrays.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Middleware CSP: middleware.{ts,js} at project root or app/
|
||||
// Nuxt nuxt-security module
|
||||
if (SCAN_EXTS.has(ext) && isConfig('nuxt') &&
|
||||
NUXT_SECURITY_SIGNALS.every((re) => re.test(body))) {
|
||||
hits.appendArrays.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// === append-string candidates ===
|
||||
|
||||
// Inline headers in Next/Nuxt/SvelteKit/Astro/Vite config
|
||||
if (SCAN_EXTS.has(ext) &&
|
||||
/(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath) &&
|
||||
INLINE_HEADER_SIGNALS.every((re) => re.test(body))) {
|
||||
// Nuxt routeRules is a sub-shape of append-string; we already covered
|
||||
// nuxt-security above via return, so any remaining Nuxt CSP match here
|
||||
// is a route-rules / inline-headers case. Either way, same patch
|
||||
// mechanism.
|
||||
hits.appendString.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// === detect-only shapes ===
|
||||
|
||||
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,
|
||||
};
|
||||
// Priority: append-arrays > append-string > middleware > meta-tag.
|
||||
// Structured patches are safer than string splices; runtime and HTML
|
||||
// injection patches are less reliable and v1 doesn't auto-apply them.
|
||||
if (hits.appendArrays.length > 0) {
|
||||
return { shape: 'append-arrays', signals: hits.appendArrays };
|
||||
}
|
||||
if (hits.inlineHeader.length > 0) {
|
||||
return {
|
||||
shape: 'inline-headers',
|
||||
signals: hits.inlineHeader,
|
||||
};
|
||||
if (hits.appendString.length > 0) {
|
||||
return { shape: 'append-string', signals: hits.appendString };
|
||||
}
|
||||
if (hits.middleware.length > 0) {
|
||||
return {
|
||||
shape: 'middleware',
|
||||
signals: hits.middleware,
|
||||
};
|
||||
return { shape: 'middleware', signals: hits.middleware };
|
||||
}
|
||||
if (hits.metaTag.length > 0) {
|
||||
return {
|
||||
shape: 'meta-tag',
|
||||
signals: hits.metaTag,
|
||||
};
|
||||
return { shape: 'meta-tag', signals: hits.metaTag };
|
||||
}
|
||||
return { shape: null, signals: [] };
|
||||
}
|
||||
|
||||
@@ -324,11 +324,16 @@ Otherwise, run the detection helper:
|
||||
node {{scripts_path}}/detect-csp.mjs
|
||||
```
|
||||
|
||||
Output: `{ shape, signals }` where `shape` is one of `shared-helper`, `inline-headers`, `middleware`, `meta-tag`, or `null`.
|
||||
Output: `{ shape, signals }` where `shape` is one of `append-arrays`, `append-string`, `middleware`, `meta-tag`, or `null`. The shape is named by *patch mechanism*, so one template covers many frameworks.
|
||||
|
||||
- **`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.
|
||||
- **`append-arrays`** — CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers:
|
||||
- Monorepo helpers with `additionalScriptSrc` / `additionalConnectSrc` options (Next.js + shared config package)
|
||||
- SvelteKit `kit.csp.directives`
|
||||
- Nuxt `nuxt-security` module's `contentSecurityPolicy`
|
||||
- **`append-string`** — CSP written as a literal value string. Auto-patchable. See *append-string* below. Covers:
|
||||
- Inline `next.config.*` `headers()` with a CSP literal
|
||||
- Nuxt `routeRules` / `nitro.routeRules` headers
|
||||
- **`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
|
||||
@@ -348,9 +353,11 @@ On "no": skip the patch, mention live won't work until the user adds the allowan
|
||||
|
||||
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
|
||||
|
||||
#### Shape 1 — shared helper with `additional*Src` arrays
|
||||
#### append-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`:
|
||||
CSP expressed as structured directive arrays. Patch mechanism: declare a dev-only array, spread it into the script-src and connect-src arrays.
|
||||
|
||||
**Declare near the top of the file that holds the CSP arrays:**
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
|
||||
@@ -358,30 +365,41 @@ const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
|
||||
```
|
||||
|
||||
Append `...__impeccableLiveDev` to both `additionalScriptSrc` and `additionalConnectSrc` array options.
|
||||
**Append `...__impeccableLiveDev` to the script-src and connect-src directive arrays.** Per-framework specifics:
|
||||
|
||||
- **Next.js + monorepo helper** — edit the *app's* `next.config.*` (not the shared helper), appending to `additionalScriptSrc` and `additionalConnectSrc` passed into `createBaseNextConfig` (or equivalent). Keeps the shared package clean.
|
||||
- **SvelteKit** — edit `svelte.config.js`, appending to `kit.csp.directives['script-src']` and `kit.csp.directives['connect-src']`.
|
||||
- **Nuxt + nuxt-security** — edit `nuxt.config.*`, appending to `security.headers.contentSecurityPolicy['script-src']` and `['connect-src']`.
|
||||
|
||||
Reference outputs:
|
||||
- `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` (Next.js)
|
||||
- `tests/framework-fixtures/sveltekit-csp/expected-after-patch.js` (SvelteKit)
|
||||
|
||||
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.
|
||||
#### append-string
|
||||
|
||||
#### 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.
|
||||
CSP built as a literal value string. Two-point patch: declare a dev-only string near the top, interpolate it into the CSP at the `script-src` and `connect-src` directives.
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load.
|
||||
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}`
|
||||
Then in 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.)
|
||||
(Leading space on the dev string so it concatenates cleanly into the existing value. Convert the literal CSP directives into template strings as part of the edit if they aren't already.)
|
||||
|
||||
Read the current file, locate the exact CSP string, quote the two directive edits in the consent prompt, write after confirmation.
|
||||
Per-framework specifics:
|
||||
- **Next.js inline `headers()`** — edit `next.config.*`, splicing the variable into the CSP value.
|
||||
- **Nuxt `routeRules`** — edit `nuxt.config.*`, splicing into the CSP in `routeRules['/**'].headers['Content-Security-Policy']`.
|
||||
|
||||
See `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` for the full desired output.
|
||||
Reference outputs:
|
||||
- `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` (Next.js)
|
||||
- `tests/framework-fixtures/nuxt-csp/expected-after-patch.ts` (Nuxt)
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
|
||||
@@ -80,11 +80,31 @@ export function findProjectRoot(startDir = process.cwd()) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable by reading its
|
||||
* SKILL.md and looking for the word "impeccable" (case-insensitive).
|
||||
* Returns false for non-existent paths or skills that don't match.
|
||||
* Load skills-lock.json from the project root, or null if missing/unreadable.
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir) {
|
||||
export function loadLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable. Prefers the
|
||||
* authoritative lock signal (source === "pbakaus/impeccable") when a
|
||||
* skillName and lock are supplied, and falls back to a SKILL.md
|
||||
* content check for older skills that predate the self-identification
|
||||
* convention.
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir, { skillName, lock } = {}) {
|
||||
// Authoritative: the lock file claims this skill is ours.
|
||||
if (skillName && lock?.skills?.[skillName]?.source === 'pbakaus/impeccable') {
|
||||
return true;
|
||||
}
|
||||
// Fallback: content heuristic for skills without a lock entry.
|
||||
const skillMd = join(skillDir, 'SKILL.md');
|
||||
if (!existsSync(skillMd)) return false;
|
||||
try {
|
||||
@@ -125,9 +145,12 @@ export function findSkillsDirs(projectRoot) {
|
||||
|
||||
/**
|
||||
* Remove deprecated skill directories/symlinks from all harness dirs.
|
||||
* Reads skills-lock.json so the authoritative "source" field can
|
||||
* drive deletion even when SKILL.md never mentions impeccable.
|
||||
* Returns an array of paths that were deleted.
|
||||
*/
|
||||
export function removeDeprecatedSkills(projectRoot) {
|
||||
export function removeDeprecatedSkills(projectRoot, lock) {
|
||||
if (lock === undefined) lock = loadLock(projectRoot);
|
||||
const targets = buildTargetNames();
|
||||
const skillsDirs = findSkillsDirs(projectRoot);
|
||||
const deleted = [];
|
||||
@@ -149,7 +172,9 @@ export function removeDeprecatedSkills(projectRoot) {
|
||||
// Symlink: check the target if it's alive, otherwise treat
|
||||
// dangling symlinks to deprecated names as safe to remove.
|
||||
const targetAlive = existsSync(skillPath);
|
||||
const isMatch = targetAlive ? isImpeccableSkill(skillPath) : true;
|
||||
const isMatch = targetAlive
|
||||
? isImpeccableSkill(skillPath, { skillName: name, lock })
|
||||
: true;
|
||||
if (isMatch) {
|
||||
unlinkSync(skillPath);
|
||||
deleted.push(skillPath);
|
||||
@@ -158,7 +183,7 @@ export function removeDeprecatedSkills(projectRoot) {
|
||||
}
|
||||
|
||||
// Regular directory -- verify it belongs to impeccable
|
||||
if (isImpeccableSkill(skillPath)) {
|
||||
if (isImpeccableSkill(skillPath, { skillName: name, lock })) {
|
||||
rmSync(skillPath, { recursive: true, force: true });
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
@@ -208,10 +233,15 @@ export function cleanSkillsLock(projectRoot) {
|
||||
|
||||
/**
|
||||
* Run the full cleanup. Returns a summary object.
|
||||
*
|
||||
* Order matters: read the lock and delete directories first, then
|
||||
* strip lock entries. Otherwise the authoritative signal is gone by
|
||||
* the time directory deletion runs.
|
||||
*/
|
||||
export function cleanup(projectRoot) {
|
||||
const root = projectRoot || findProjectRoot();
|
||||
const deletedPaths = removeDeprecatedSkills(root);
|
||||
const lock = loadLock(root);
|
||||
const deletedPaths = removeDeprecatedSkills(root, lock);
|
||||
const removedLockEntries = cleanSkillsLock(root);
|
||||
return { deletedPaths, removedLockEntries, projectRoot: root };
|
||||
}
|
||||
|
||||
@@ -6,18 +6,22 @@
|
||||
* 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.
|
||||
* Shapes are named by patch mechanism, not framework origin:
|
||||
* - "append-arrays": CSP defined as structured directive arrays. Patch
|
||||
* appends a dev-only localhost entry. Covers:
|
||||
* - Monorepo helpers with additional*Src options
|
||||
* (e.g. createBaseNextConfig for Next)
|
||||
* - SvelteKit kit.csp.directives
|
||||
* - nuxt-security module's contentSecurityPolicy
|
||||
* - "append-string": CSP built as a literal value string. Patch splices
|
||||
* a dev-only token into script-src and connect-src.
|
||||
* Covers:
|
||||
* - Inline Next.js headers() with CSP string
|
||||
* - Nuxt routeRules / nitro.routeRules CSP headers
|
||||
* - "middleware": CSP set dynamically in middleware.{ts,js}.
|
||||
* Detected but not auto-patched in v1.
|
||||
* - "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.
|
||||
*/
|
||||
|
||||
@@ -43,19 +47,35 @@ 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 = [
|
||||
// append-arrays signals: CSP expressed as structured directive arrays
|
||||
const MONOREPO_HELPER_SIGNALS = [
|
||||
/\bbuildCSPConfig\b/,
|
||||
/\bbuildSecurityHeaders\b/,
|
||||
/\badditionalScriptSrc\b/,
|
||||
/\badditionalConnectSrc\b/,
|
||||
/\bcreateBaseNextConfig\b/,
|
||||
];
|
||||
const SVELTEKIT_CSP_SIGNALS = [
|
||||
/\bkit\s*:/,
|
||||
/\bcsp\s*:/,
|
||||
/\bdirectives\s*:/,
|
||||
];
|
||||
const NUXT_SECURITY_SIGNALS = [
|
||||
/['"]nuxt-security['"]/,
|
||||
/\bcontentSecurityPolicy\b/,
|
||||
];
|
||||
|
||||
// append-string signals: CSP written as a literal value string
|
||||
const INLINE_HEADER_SIGNALS = [
|
||||
/["']Content-Security-Policy["']/i,
|
||||
/\bscript-src\b/,
|
||||
/\bconnect-src\b/,
|
||||
];
|
||||
const NUXT_ROUTE_RULES_SIGNALS = [
|
||||
/\brouteRules\b/,
|
||||
/Content-Security-Policy/i,
|
||||
/\bscript-src\b/,
|
||||
];
|
||||
|
||||
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
|
||||
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
@@ -65,67 +85,78 @@ const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
* @returns {{ shape: string|null, signals: string[] }}
|
||||
*/
|
||||
export function detectCsp(cwd = process.cwd()) {
|
||||
const hits = { sharedHelper: [], inlineHeader: [], middleware: [], metaTag: [] };
|
||||
const hits = { appendArrays: [], appendString: [], middleware: [], metaTag: [] };
|
||||
|
||||
walk(cwd, cwd, 0, (absPath, relPath, body) => {
|
||||
const ext = path.extname(absPath);
|
||||
const base = path.basename(absPath).toLowerCase();
|
||||
const isConfig = (name) =>
|
||||
new RegExp('(^|/)' + name + '\\.config\\.').test(relPath);
|
||||
|
||||
// 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);
|
||||
}
|
||||
// === append-arrays candidates ===
|
||||
|
||||
// Monorepo CSP helper: packages/*/src/.../(config|security)/*
|
||||
if (SCAN_EXTS.has(ext) &&
|
||||
/packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath) &&
|
||||
MONOREPO_HELPER_SIGNALS.some((re) => re.test(body))) {
|
||||
hits.appendArrays.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
// SvelteKit kit.csp.directives
|
||||
if (SCAN_EXTS.has(ext) && isConfig('svelte') &&
|
||||
SVELTEKIT_CSP_SIGNALS.every((re) => re.test(body))) {
|
||||
hits.appendArrays.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Middleware CSP: middleware.{ts,js} at project root or app/
|
||||
// Nuxt nuxt-security module
|
||||
if (SCAN_EXTS.has(ext) && isConfig('nuxt') &&
|
||||
NUXT_SECURITY_SIGNALS.every((re) => re.test(body))) {
|
||||
hits.appendArrays.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// === append-string candidates ===
|
||||
|
||||
// Inline headers in Next/Nuxt/SvelteKit/Astro/Vite config
|
||||
if (SCAN_EXTS.has(ext) &&
|
||||
/(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath) &&
|
||||
INLINE_HEADER_SIGNALS.every((re) => re.test(body))) {
|
||||
// Nuxt routeRules is a sub-shape of append-string; we already covered
|
||||
// nuxt-security above via return, so any remaining Nuxt CSP match here
|
||||
// is a route-rules / inline-headers case. Either way, same patch
|
||||
// mechanism.
|
||||
hits.appendString.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// === detect-only shapes ===
|
||||
|
||||
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,
|
||||
};
|
||||
// Priority: append-arrays > append-string > middleware > meta-tag.
|
||||
// Structured patches are safer than string splices; runtime and HTML
|
||||
// injection patches are less reliable and v1 doesn't auto-apply them.
|
||||
if (hits.appendArrays.length > 0) {
|
||||
return { shape: 'append-arrays', signals: hits.appendArrays };
|
||||
}
|
||||
if (hits.inlineHeader.length > 0) {
|
||||
return {
|
||||
shape: 'inline-headers',
|
||||
signals: hits.inlineHeader,
|
||||
};
|
||||
if (hits.appendString.length > 0) {
|
||||
return { shape: 'append-string', signals: hits.appendString };
|
||||
}
|
||||
if (hits.middleware.length > 0) {
|
||||
return {
|
||||
shape: 'middleware',
|
||||
signals: hits.middleware,
|
||||
};
|
||||
return { shape: 'middleware', signals: hits.middleware };
|
||||
}
|
||||
if (hits.metaTag.length > 0) {
|
||||
return {
|
||||
shape: 'meta-tag',
|
||||
signals: hits.metaTag,
|
||||
};
|
||||
return { shape: 'meta-tag', signals: hits.metaTag };
|
||||
}
|
||||
return { shape: null, signals: [] };
|
||||
}
|
||||
|
||||
@@ -324,11 +324,16 @@ Otherwise, run the detection helper:
|
||||
node {{scripts_path}}/detect-csp.mjs
|
||||
```
|
||||
|
||||
Output: `{ shape, signals }` where `shape` is one of `shared-helper`, `inline-headers`, `middleware`, `meta-tag`, or `null`.
|
||||
Output: `{ shape, signals }` where `shape` is one of `append-arrays`, `append-string`, `middleware`, `meta-tag`, or `null`. The shape is named by *patch mechanism*, so one template covers many frameworks.
|
||||
|
||||
- **`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.
|
||||
- **`append-arrays`** — CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers:
|
||||
- Monorepo helpers with `additionalScriptSrc` / `additionalConnectSrc` options (Next.js + shared config package)
|
||||
- SvelteKit `kit.csp.directives`
|
||||
- Nuxt `nuxt-security` module's `contentSecurityPolicy`
|
||||
- **`append-string`** — CSP written as a literal value string. Auto-patchable. See *append-string* below. Covers:
|
||||
- Inline `next.config.*` `headers()` with a CSP literal
|
||||
- Nuxt `routeRules` / `nitro.routeRules` headers
|
||||
- **`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
|
||||
@@ -348,9 +353,11 @@ On "no": skip the patch, mention live won't work until the user adds the allowan
|
||||
|
||||
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
|
||||
|
||||
#### Shape 1 — shared helper with `additional*Src` arrays
|
||||
#### append-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`:
|
||||
CSP expressed as structured directive arrays. Patch mechanism: declare a dev-only array, spread it into the script-src and connect-src arrays.
|
||||
|
||||
**Declare near the top of the file that holds the CSP arrays:**
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
|
||||
@@ -358,30 +365,41 @@ const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
|
||||
```
|
||||
|
||||
Append `...__impeccableLiveDev` to both `additionalScriptSrc` and `additionalConnectSrc` array options.
|
||||
**Append `...__impeccableLiveDev` to the script-src and connect-src directive arrays.** Per-framework specifics:
|
||||
|
||||
- **Next.js + monorepo helper** — edit the *app's* `next.config.*` (not the shared helper), appending to `additionalScriptSrc` and `additionalConnectSrc` passed into `createBaseNextConfig` (or equivalent). Keeps the shared package clean.
|
||||
- **SvelteKit** — edit `svelte.config.js`, appending to `kit.csp.directives['script-src']` and `kit.csp.directives['connect-src']`.
|
||||
- **Nuxt + nuxt-security** — edit `nuxt.config.*`, appending to `security.headers.contentSecurityPolicy['script-src']` and `['connect-src']`.
|
||||
|
||||
Reference outputs:
|
||||
- `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` (Next.js)
|
||||
- `tests/framework-fixtures/sveltekit-csp/expected-after-patch.js` (SvelteKit)
|
||||
|
||||
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.
|
||||
#### append-string
|
||||
|
||||
#### 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.
|
||||
CSP built as a literal value string. Two-point patch: declare a dev-only string near the top, interpolate it into the CSP at the `script-src` and `connect-src` directives.
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load.
|
||||
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}`
|
||||
Then in 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.)
|
||||
(Leading space on the dev string so it concatenates cleanly into the existing value. Convert the literal CSP directives into template strings as part of the edit if they aren't already.)
|
||||
|
||||
Read the current file, locate the exact CSP string, quote the two directive edits in the consent prompt, write after confirmation.
|
||||
Per-framework specifics:
|
||||
- **Next.js inline `headers()`** — edit `next.config.*`, splicing the variable into the CSP value.
|
||||
- **Nuxt `routeRules`** — edit `nuxt.config.*`, splicing into the CSP in `routeRules['/**'].headers['Content-Security-Policy']`.
|
||||
|
||||
See `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` for the full desired output.
|
||||
Reference outputs:
|
||||
- `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` (Next.js)
|
||||
- `tests/framework-fixtures/nuxt-csp/expected-after-patch.ts` (Nuxt)
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
|
||||
@@ -80,11 +80,31 @@ export function findProjectRoot(startDir = process.cwd()) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable by reading its
|
||||
* SKILL.md and looking for the word "impeccable" (case-insensitive).
|
||||
* Returns false for non-existent paths or skills that don't match.
|
||||
* Load skills-lock.json from the project root, or null if missing/unreadable.
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir) {
|
||||
export function loadLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable. Prefers the
|
||||
* authoritative lock signal (source === "pbakaus/impeccable") when a
|
||||
* skillName and lock are supplied, and falls back to a SKILL.md
|
||||
* content check for older skills that predate the self-identification
|
||||
* convention.
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir, { skillName, lock } = {}) {
|
||||
// Authoritative: the lock file claims this skill is ours.
|
||||
if (skillName && lock?.skills?.[skillName]?.source === 'pbakaus/impeccable') {
|
||||
return true;
|
||||
}
|
||||
// Fallback: content heuristic for skills without a lock entry.
|
||||
const skillMd = join(skillDir, 'SKILL.md');
|
||||
if (!existsSync(skillMd)) return false;
|
||||
try {
|
||||
@@ -125,9 +145,12 @@ export function findSkillsDirs(projectRoot) {
|
||||
|
||||
/**
|
||||
* Remove deprecated skill directories/symlinks from all harness dirs.
|
||||
* Reads skills-lock.json so the authoritative "source" field can
|
||||
* drive deletion even when SKILL.md never mentions impeccable.
|
||||
* Returns an array of paths that were deleted.
|
||||
*/
|
||||
export function removeDeprecatedSkills(projectRoot) {
|
||||
export function removeDeprecatedSkills(projectRoot, lock) {
|
||||
if (lock === undefined) lock = loadLock(projectRoot);
|
||||
const targets = buildTargetNames();
|
||||
const skillsDirs = findSkillsDirs(projectRoot);
|
||||
const deleted = [];
|
||||
@@ -149,7 +172,9 @@ export function removeDeprecatedSkills(projectRoot) {
|
||||
// Symlink: check the target if it's alive, otherwise treat
|
||||
// dangling symlinks to deprecated names as safe to remove.
|
||||
const targetAlive = existsSync(skillPath);
|
||||
const isMatch = targetAlive ? isImpeccableSkill(skillPath) : true;
|
||||
const isMatch = targetAlive
|
||||
? isImpeccableSkill(skillPath, { skillName: name, lock })
|
||||
: true;
|
||||
if (isMatch) {
|
||||
unlinkSync(skillPath);
|
||||
deleted.push(skillPath);
|
||||
@@ -158,7 +183,7 @@ export function removeDeprecatedSkills(projectRoot) {
|
||||
}
|
||||
|
||||
// Regular directory -- verify it belongs to impeccable
|
||||
if (isImpeccableSkill(skillPath)) {
|
||||
if (isImpeccableSkill(skillPath, { skillName: name, lock })) {
|
||||
rmSync(skillPath, { recursive: true, force: true });
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
@@ -208,10 +233,15 @@ export function cleanSkillsLock(projectRoot) {
|
||||
|
||||
/**
|
||||
* Run the full cleanup. Returns a summary object.
|
||||
*
|
||||
* Order matters: read the lock and delete directories first, then
|
||||
* strip lock entries. Otherwise the authoritative signal is gone by
|
||||
* the time directory deletion runs.
|
||||
*/
|
||||
export function cleanup(projectRoot) {
|
||||
const root = projectRoot || findProjectRoot();
|
||||
const deletedPaths = removeDeprecatedSkills(root);
|
||||
const lock = loadLock(root);
|
||||
const deletedPaths = removeDeprecatedSkills(root, lock);
|
||||
const removedLockEntries = cleanSkillsLock(root);
|
||||
return { deletedPaths, removedLockEntries, projectRoot: root };
|
||||
}
|
||||
|
||||
@@ -6,18 +6,22 @@
|
||||
* 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.
|
||||
* Shapes are named by patch mechanism, not framework origin:
|
||||
* - "append-arrays": CSP defined as structured directive arrays. Patch
|
||||
* appends a dev-only localhost entry. Covers:
|
||||
* - Monorepo helpers with additional*Src options
|
||||
* (e.g. createBaseNextConfig for Next)
|
||||
* - SvelteKit kit.csp.directives
|
||||
* - nuxt-security module's contentSecurityPolicy
|
||||
* - "append-string": CSP built as a literal value string. Patch splices
|
||||
* a dev-only token into script-src and connect-src.
|
||||
* Covers:
|
||||
* - Inline Next.js headers() with CSP string
|
||||
* - Nuxt routeRules / nitro.routeRules CSP headers
|
||||
* - "middleware": CSP set dynamically in middleware.{ts,js}.
|
||||
* Detected but not auto-patched in v1.
|
||||
* - "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.
|
||||
*/
|
||||
|
||||
@@ -43,19 +47,35 @@ 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 = [
|
||||
// append-arrays signals: CSP expressed as structured directive arrays
|
||||
const MONOREPO_HELPER_SIGNALS = [
|
||||
/\bbuildCSPConfig\b/,
|
||||
/\bbuildSecurityHeaders\b/,
|
||||
/\badditionalScriptSrc\b/,
|
||||
/\badditionalConnectSrc\b/,
|
||||
/\bcreateBaseNextConfig\b/,
|
||||
];
|
||||
const SVELTEKIT_CSP_SIGNALS = [
|
||||
/\bkit\s*:/,
|
||||
/\bcsp\s*:/,
|
||||
/\bdirectives\s*:/,
|
||||
];
|
||||
const NUXT_SECURITY_SIGNALS = [
|
||||
/['"]nuxt-security['"]/,
|
||||
/\bcontentSecurityPolicy\b/,
|
||||
];
|
||||
|
||||
// append-string signals: CSP written as a literal value string
|
||||
const INLINE_HEADER_SIGNALS = [
|
||||
/["']Content-Security-Policy["']/i,
|
||||
/\bscript-src\b/,
|
||||
/\bconnect-src\b/,
|
||||
];
|
||||
const NUXT_ROUTE_RULES_SIGNALS = [
|
||||
/\brouteRules\b/,
|
||||
/Content-Security-Policy/i,
|
||||
/\bscript-src\b/,
|
||||
];
|
||||
|
||||
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
|
||||
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
@@ -65,67 +85,78 @@ const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
* @returns {{ shape: string|null, signals: string[] }}
|
||||
*/
|
||||
export function detectCsp(cwd = process.cwd()) {
|
||||
const hits = { sharedHelper: [], inlineHeader: [], middleware: [], metaTag: [] };
|
||||
const hits = { appendArrays: [], appendString: [], middleware: [], metaTag: [] };
|
||||
|
||||
walk(cwd, cwd, 0, (absPath, relPath, body) => {
|
||||
const ext = path.extname(absPath);
|
||||
const base = path.basename(absPath).toLowerCase();
|
||||
const isConfig = (name) =>
|
||||
new RegExp('(^|/)' + name + '\\.config\\.').test(relPath);
|
||||
|
||||
// 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);
|
||||
}
|
||||
// === append-arrays candidates ===
|
||||
|
||||
// Monorepo CSP helper: packages/*/src/.../(config|security)/*
|
||||
if (SCAN_EXTS.has(ext) &&
|
||||
/packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath) &&
|
||||
MONOREPO_HELPER_SIGNALS.some((re) => re.test(body))) {
|
||||
hits.appendArrays.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
// SvelteKit kit.csp.directives
|
||||
if (SCAN_EXTS.has(ext) && isConfig('svelte') &&
|
||||
SVELTEKIT_CSP_SIGNALS.every((re) => re.test(body))) {
|
||||
hits.appendArrays.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Middleware CSP: middleware.{ts,js} at project root or app/
|
||||
// Nuxt nuxt-security module
|
||||
if (SCAN_EXTS.has(ext) && isConfig('nuxt') &&
|
||||
NUXT_SECURITY_SIGNALS.every((re) => re.test(body))) {
|
||||
hits.appendArrays.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// === append-string candidates ===
|
||||
|
||||
// Inline headers in Next/Nuxt/SvelteKit/Astro/Vite config
|
||||
if (SCAN_EXTS.has(ext) &&
|
||||
/(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath) &&
|
||||
INLINE_HEADER_SIGNALS.every((re) => re.test(body))) {
|
||||
// Nuxt routeRules is a sub-shape of append-string; we already covered
|
||||
// nuxt-security above via return, so any remaining Nuxt CSP match here
|
||||
// is a route-rules / inline-headers case. Either way, same patch
|
||||
// mechanism.
|
||||
hits.appendString.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// === detect-only shapes ===
|
||||
|
||||
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,
|
||||
};
|
||||
// Priority: append-arrays > append-string > middleware > meta-tag.
|
||||
// Structured patches are safer than string splices; runtime and HTML
|
||||
// injection patches are less reliable and v1 doesn't auto-apply them.
|
||||
if (hits.appendArrays.length > 0) {
|
||||
return { shape: 'append-arrays', signals: hits.appendArrays };
|
||||
}
|
||||
if (hits.inlineHeader.length > 0) {
|
||||
return {
|
||||
shape: 'inline-headers',
|
||||
signals: hits.inlineHeader,
|
||||
};
|
||||
if (hits.appendString.length > 0) {
|
||||
return { shape: 'append-string', signals: hits.appendString };
|
||||
}
|
||||
if (hits.middleware.length > 0) {
|
||||
return {
|
||||
shape: 'middleware',
|
||||
signals: hits.middleware,
|
||||
};
|
||||
return { shape: 'middleware', signals: hits.middleware };
|
||||
}
|
||||
if (hits.metaTag.length > 0) {
|
||||
return {
|
||||
shape: 'meta-tag',
|
||||
signals: hits.metaTag,
|
||||
};
|
||||
return { shape: 'meta-tag', signals: hits.metaTag };
|
||||
}
|
||||
return { shape: null, signals: [] };
|
||||
}
|
||||
|
||||
@@ -324,11 +324,16 @@ Otherwise, run the detection helper:
|
||||
node {{scripts_path}}/detect-csp.mjs
|
||||
```
|
||||
|
||||
Output: `{ shape, signals }` where `shape` is one of `shared-helper`, `inline-headers`, `middleware`, `meta-tag`, or `null`.
|
||||
Output: `{ shape, signals }` where `shape` is one of `append-arrays`, `append-string`, `middleware`, `meta-tag`, or `null`. The shape is named by *patch mechanism*, so one template covers many frameworks.
|
||||
|
||||
- **`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.
|
||||
- **`append-arrays`** — CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers:
|
||||
- Monorepo helpers with `additionalScriptSrc` / `additionalConnectSrc` options (Next.js + shared config package)
|
||||
- SvelteKit `kit.csp.directives`
|
||||
- Nuxt `nuxt-security` module's `contentSecurityPolicy`
|
||||
- **`append-string`** — CSP written as a literal value string. Auto-patchable. See *append-string* below. Covers:
|
||||
- Inline `next.config.*` `headers()` with a CSP literal
|
||||
- Nuxt `routeRules` / `nitro.routeRules` headers
|
||||
- **`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
|
||||
@@ -348,9 +353,11 @@ On "no": skip the patch, mention live won't work until the user adds the allowan
|
||||
|
||||
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
|
||||
|
||||
#### Shape 1 — shared helper with `additional*Src` arrays
|
||||
#### append-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`:
|
||||
CSP expressed as structured directive arrays. Patch mechanism: declare a dev-only array, spread it into the script-src and connect-src arrays.
|
||||
|
||||
**Declare near the top of the file that holds the CSP arrays:**
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
|
||||
@@ -358,30 +365,41 @@ const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
|
||||
```
|
||||
|
||||
Append `...__impeccableLiveDev` to both `additionalScriptSrc` and `additionalConnectSrc` array options.
|
||||
**Append `...__impeccableLiveDev` to the script-src and connect-src directive arrays.** Per-framework specifics:
|
||||
|
||||
- **Next.js + monorepo helper** — edit the *app's* `next.config.*` (not the shared helper), appending to `additionalScriptSrc` and `additionalConnectSrc` passed into `createBaseNextConfig` (or equivalent). Keeps the shared package clean.
|
||||
- **SvelteKit** — edit `svelte.config.js`, appending to `kit.csp.directives['script-src']` and `kit.csp.directives['connect-src']`.
|
||||
- **Nuxt + nuxt-security** — edit `nuxt.config.*`, appending to `security.headers.contentSecurityPolicy['script-src']` and `['connect-src']`.
|
||||
|
||||
Reference outputs:
|
||||
- `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` (Next.js)
|
||||
- `tests/framework-fixtures/sveltekit-csp/expected-after-patch.js` (SvelteKit)
|
||||
|
||||
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.
|
||||
#### append-string
|
||||
|
||||
#### 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.
|
||||
CSP built as a literal value string. Two-point patch: declare a dev-only string near the top, interpolate it into the CSP at the `script-src` and `connect-src` directives.
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load.
|
||||
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}`
|
||||
Then in 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.)
|
||||
(Leading space on the dev string so it concatenates cleanly into the existing value. Convert the literal CSP directives into template strings as part of the edit if they aren't already.)
|
||||
|
||||
Read the current file, locate the exact CSP string, quote the two directive edits in the consent prompt, write after confirmation.
|
||||
Per-framework specifics:
|
||||
- **Next.js inline `headers()`** — edit `next.config.*`, splicing the variable into the CSP value.
|
||||
- **Nuxt `routeRules`** — edit `nuxt.config.*`, splicing into the CSP in `routeRules['/**'].headers['Content-Security-Policy']`.
|
||||
|
||||
See `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` for the full desired output.
|
||||
Reference outputs:
|
||||
- `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` (Next.js)
|
||||
- `tests/framework-fixtures/nuxt-csp/expected-after-patch.ts` (Nuxt)
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
|
||||
@@ -80,11 +80,31 @@ export function findProjectRoot(startDir = process.cwd()) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable by reading its
|
||||
* SKILL.md and looking for the word "impeccable" (case-insensitive).
|
||||
* Returns false for non-existent paths or skills that don't match.
|
||||
* Load skills-lock.json from the project root, or null if missing/unreadable.
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir) {
|
||||
export function loadLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable. Prefers the
|
||||
* authoritative lock signal (source === "pbakaus/impeccable") when a
|
||||
* skillName and lock are supplied, and falls back to a SKILL.md
|
||||
* content check for older skills that predate the self-identification
|
||||
* convention.
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir, { skillName, lock } = {}) {
|
||||
// Authoritative: the lock file claims this skill is ours.
|
||||
if (skillName && lock?.skills?.[skillName]?.source === 'pbakaus/impeccable') {
|
||||
return true;
|
||||
}
|
||||
// Fallback: content heuristic for skills without a lock entry.
|
||||
const skillMd = join(skillDir, 'SKILL.md');
|
||||
if (!existsSync(skillMd)) return false;
|
||||
try {
|
||||
@@ -125,9 +145,12 @@ export function findSkillsDirs(projectRoot) {
|
||||
|
||||
/**
|
||||
* Remove deprecated skill directories/symlinks from all harness dirs.
|
||||
* Reads skills-lock.json so the authoritative "source" field can
|
||||
* drive deletion even when SKILL.md never mentions impeccable.
|
||||
* Returns an array of paths that were deleted.
|
||||
*/
|
||||
export function removeDeprecatedSkills(projectRoot) {
|
||||
export function removeDeprecatedSkills(projectRoot, lock) {
|
||||
if (lock === undefined) lock = loadLock(projectRoot);
|
||||
const targets = buildTargetNames();
|
||||
const skillsDirs = findSkillsDirs(projectRoot);
|
||||
const deleted = [];
|
||||
@@ -149,7 +172,9 @@ export function removeDeprecatedSkills(projectRoot) {
|
||||
// Symlink: check the target if it's alive, otherwise treat
|
||||
// dangling symlinks to deprecated names as safe to remove.
|
||||
const targetAlive = existsSync(skillPath);
|
||||
const isMatch = targetAlive ? isImpeccableSkill(skillPath) : true;
|
||||
const isMatch = targetAlive
|
||||
? isImpeccableSkill(skillPath, { skillName: name, lock })
|
||||
: true;
|
||||
if (isMatch) {
|
||||
unlinkSync(skillPath);
|
||||
deleted.push(skillPath);
|
||||
@@ -158,7 +183,7 @@ export function removeDeprecatedSkills(projectRoot) {
|
||||
}
|
||||
|
||||
// Regular directory -- verify it belongs to impeccable
|
||||
if (isImpeccableSkill(skillPath)) {
|
||||
if (isImpeccableSkill(skillPath, { skillName: name, lock })) {
|
||||
rmSync(skillPath, { recursive: true, force: true });
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
@@ -208,10 +233,15 @@ export function cleanSkillsLock(projectRoot) {
|
||||
|
||||
/**
|
||||
* Run the full cleanup. Returns a summary object.
|
||||
*
|
||||
* Order matters: read the lock and delete directories first, then
|
||||
* strip lock entries. Otherwise the authoritative signal is gone by
|
||||
* the time directory deletion runs.
|
||||
*/
|
||||
export function cleanup(projectRoot) {
|
||||
const root = projectRoot || findProjectRoot();
|
||||
const deletedPaths = removeDeprecatedSkills(root);
|
||||
const lock = loadLock(root);
|
||||
const deletedPaths = removeDeprecatedSkills(root, lock);
|
||||
const removedLockEntries = cleanSkillsLock(root);
|
||||
return { deletedPaths, removedLockEntries, projectRoot: root };
|
||||
}
|
||||
|
||||
@@ -6,18 +6,22 @@
|
||||
* 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.
|
||||
* Shapes are named by patch mechanism, not framework origin:
|
||||
* - "append-arrays": CSP defined as structured directive arrays. Patch
|
||||
* appends a dev-only localhost entry. Covers:
|
||||
* - Monorepo helpers with additional*Src options
|
||||
* (e.g. createBaseNextConfig for Next)
|
||||
* - SvelteKit kit.csp.directives
|
||||
* - nuxt-security module's contentSecurityPolicy
|
||||
* - "append-string": CSP built as a literal value string. Patch splices
|
||||
* a dev-only token into script-src and connect-src.
|
||||
* Covers:
|
||||
* - Inline Next.js headers() with CSP string
|
||||
* - Nuxt routeRules / nitro.routeRules CSP headers
|
||||
* - "middleware": CSP set dynamically in middleware.{ts,js}.
|
||||
* Detected but not auto-patched in v1.
|
||||
* - "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.
|
||||
*/
|
||||
|
||||
@@ -43,19 +47,35 @@ 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 = [
|
||||
// append-arrays signals: CSP expressed as structured directive arrays
|
||||
const MONOREPO_HELPER_SIGNALS = [
|
||||
/\bbuildCSPConfig\b/,
|
||||
/\bbuildSecurityHeaders\b/,
|
||||
/\badditionalScriptSrc\b/,
|
||||
/\badditionalConnectSrc\b/,
|
||||
/\bcreateBaseNextConfig\b/,
|
||||
];
|
||||
const SVELTEKIT_CSP_SIGNALS = [
|
||||
/\bkit\s*:/,
|
||||
/\bcsp\s*:/,
|
||||
/\bdirectives\s*:/,
|
||||
];
|
||||
const NUXT_SECURITY_SIGNALS = [
|
||||
/['"]nuxt-security['"]/,
|
||||
/\bcontentSecurityPolicy\b/,
|
||||
];
|
||||
|
||||
// append-string signals: CSP written as a literal value string
|
||||
const INLINE_HEADER_SIGNALS = [
|
||||
/["']Content-Security-Policy["']/i,
|
||||
/\bscript-src\b/,
|
||||
/\bconnect-src\b/,
|
||||
];
|
||||
const NUXT_ROUTE_RULES_SIGNALS = [
|
||||
/\brouteRules\b/,
|
||||
/Content-Security-Policy/i,
|
||||
/\bscript-src\b/,
|
||||
];
|
||||
|
||||
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
|
||||
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
@@ -65,67 +85,78 @@ const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
* @returns {{ shape: string|null, signals: string[] }}
|
||||
*/
|
||||
export function detectCsp(cwd = process.cwd()) {
|
||||
const hits = { sharedHelper: [], inlineHeader: [], middleware: [], metaTag: [] };
|
||||
const hits = { appendArrays: [], appendString: [], middleware: [], metaTag: [] };
|
||||
|
||||
walk(cwd, cwd, 0, (absPath, relPath, body) => {
|
||||
const ext = path.extname(absPath);
|
||||
const base = path.basename(absPath).toLowerCase();
|
||||
const isConfig = (name) =>
|
||||
new RegExp('(^|/)' + name + '\\.config\\.').test(relPath);
|
||||
|
||||
// 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);
|
||||
}
|
||||
// === append-arrays candidates ===
|
||||
|
||||
// Monorepo CSP helper: packages/*/src/.../(config|security)/*
|
||||
if (SCAN_EXTS.has(ext) &&
|
||||
/packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath) &&
|
||||
MONOREPO_HELPER_SIGNALS.some((re) => re.test(body))) {
|
||||
hits.appendArrays.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
// SvelteKit kit.csp.directives
|
||||
if (SCAN_EXTS.has(ext) && isConfig('svelte') &&
|
||||
SVELTEKIT_CSP_SIGNALS.every((re) => re.test(body))) {
|
||||
hits.appendArrays.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Middleware CSP: middleware.{ts,js} at project root or app/
|
||||
// Nuxt nuxt-security module
|
||||
if (SCAN_EXTS.has(ext) && isConfig('nuxt') &&
|
||||
NUXT_SECURITY_SIGNALS.every((re) => re.test(body))) {
|
||||
hits.appendArrays.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// === append-string candidates ===
|
||||
|
||||
// Inline headers in Next/Nuxt/SvelteKit/Astro/Vite config
|
||||
if (SCAN_EXTS.has(ext) &&
|
||||
/(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath) &&
|
||||
INLINE_HEADER_SIGNALS.every((re) => re.test(body))) {
|
||||
// Nuxt routeRules is a sub-shape of append-string; we already covered
|
||||
// nuxt-security above via return, so any remaining Nuxt CSP match here
|
||||
// is a route-rules / inline-headers case. Either way, same patch
|
||||
// mechanism.
|
||||
hits.appendString.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// === detect-only shapes ===
|
||||
|
||||
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,
|
||||
};
|
||||
// Priority: append-arrays > append-string > middleware > meta-tag.
|
||||
// Structured patches are safer than string splices; runtime and HTML
|
||||
// injection patches are less reliable and v1 doesn't auto-apply them.
|
||||
if (hits.appendArrays.length > 0) {
|
||||
return { shape: 'append-arrays', signals: hits.appendArrays };
|
||||
}
|
||||
if (hits.inlineHeader.length > 0) {
|
||||
return {
|
||||
shape: 'inline-headers',
|
||||
signals: hits.inlineHeader,
|
||||
};
|
||||
if (hits.appendString.length > 0) {
|
||||
return { shape: 'append-string', signals: hits.appendString };
|
||||
}
|
||||
if (hits.middleware.length > 0) {
|
||||
return {
|
||||
shape: 'middleware',
|
||||
signals: hits.middleware,
|
||||
};
|
||||
return { shape: 'middleware', signals: hits.middleware };
|
||||
}
|
||||
if (hits.metaTag.length > 0) {
|
||||
return {
|
||||
shape: 'meta-tag',
|
||||
signals: hits.metaTag,
|
||||
};
|
||||
return { shape: 'meta-tag', signals: hits.metaTag };
|
||||
}
|
||||
return { shape: null, signals: [] };
|
||||
}
|
||||
|
||||
@@ -324,11 +324,16 @@ Otherwise, run the detection helper:
|
||||
node {{scripts_path}}/detect-csp.mjs
|
||||
```
|
||||
|
||||
Output: `{ shape, signals }` where `shape` is one of `shared-helper`, `inline-headers`, `middleware`, `meta-tag`, or `null`.
|
||||
Output: `{ shape, signals }` where `shape` is one of `append-arrays`, `append-string`, `middleware`, `meta-tag`, or `null`. The shape is named by *patch mechanism*, so one template covers many frameworks.
|
||||
|
||||
- **`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.
|
||||
- **`append-arrays`** — CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers:
|
||||
- Monorepo helpers with `additionalScriptSrc` / `additionalConnectSrc` options (Next.js + shared config package)
|
||||
- SvelteKit `kit.csp.directives`
|
||||
- Nuxt `nuxt-security` module's `contentSecurityPolicy`
|
||||
- **`append-string`** — CSP written as a literal value string. Auto-patchable. See *append-string* below. Covers:
|
||||
- Inline `next.config.*` `headers()` with a CSP literal
|
||||
- Nuxt `routeRules` / `nitro.routeRules` headers
|
||||
- **`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
|
||||
@@ -348,9 +353,11 @@ On "no": skip the patch, mention live won't work until the user adds the allowan
|
||||
|
||||
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
|
||||
|
||||
#### Shape 1 — shared helper with `additional*Src` arrays
|
||||
#### append-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`:
|
||||
CSP expressed as structured directive arrays. Patch mechanism: declare a dev-only array, spread it into the script-src and connect-src arrays.
|
||||
|
||||
**Declare near the top of the file that holds the CSP arrays:**
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
|
||||
@@ -358,30 +365,41 @@ const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
|
||||
```
|
||||
|
||||
Append `...__impeccableLiveDev` to both `additionalScriptSrc` and `additionalConnectSrc` array options.
|
||||
**Append `...__impeccableLiveDev` to the script-src and connect-src directive arrays.** Per-framework specifics:
|
||||
|
||||
- **Next.js + monorepo helper** — edit the *app's* `next.config.*` (not the shared helper), appending to `additionalScriptSrc` and `additionalConnectSrc` passed into `createBaseNextConfig` (or equivalent). Keeps the shared package clean.
|
||||
- **SvelteKit** — edit `svelte.config.js`, appending to `kit.csp.directives['script-src']` and `kit.csp.directives['connect-src']`.
|
||||
- **Nuxt + nuxt-security** — edit `nuxt.config.*`, appending to `security.headers.contentSecurityPolicy['script-src']` and `['connect-src']`.
|
||||
|
||||
Reference outputs:
|
||||
- `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` (Next.js)
|
||||
- `tests/framework-fixtures/sveltekit-csp/expected-after-patch.js` (SvelteKit)
|
||||
|
||||
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.
|
||||
#### append-string
|
||||
|
||||
#### 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.
|
||||
CSP built as a literal value string. Two-point patch: declare a dev-only string near the top, interpolate it into the CSP at the `script-src` and `connect-src` directives.
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load.
|
||||
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}`
|
||||
Then in 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.)
|
||||
(Leading space on the dev string so it concatenates cleanly into the existing value. Convert the literal CSP directives into template strings as part of the edit if they aren't already.)
|
||||
|
||||
Read the current file, locate the exact CSP string, quote the two directive edits in the consent prompt, write after confirmation.
|
||||
Per-framework specifics:
|
||||
- **Next.js inline `headers()`** — edit `next.config.*`, splicing the variable into the CSP value.
|
||||
- **Nuxt `routeRules`** — edit `nuxt.config.*`, splicing into the CSP in `routeRules['/**'].headers['Content-Security-Policy']`.
|
||||
|
||||
See `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` for the full desired output.
|
||||
Reference outputs:
|
||||
- `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` (Next.js)
|
||||
- `tests/framework-fixtures/nuxt-csp/expected-after-patch.ts` (Nuxt)
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
|
||||
@@ -80,11 +80,31 @@ export function findProjectRoot(startDir = process.cwd()) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable by reading its
|
||||
* SKILL.md and looking for the word "impeccable" (case-insensitive).
|
||||
* Returns false for non-existent paths or skills that don't match.
|
||||
* Load skills-lock.json from the project root, or null if missing/unreadable.
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir) {
|
||||
export function loadLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable. Prefers the
|
||||
* authoritative lock signal (source === "pbakaus/impeccable") when a
|
||||
* skillName and lock are supplied, and falls back to a SKILL.md
|
||||
* content check for older skills that predate the self-identification
|
||||
* convention.
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir, { skillName, lock } = {}) {
|
||||
// Authoritative: the lock file claims this skill is ours.
|
||||
if (skillName && lock?.skills?.[skillName]?.source === 'pbakaus/impeccable') {
|
||||
return true;
|
||||
}
|
||||
// Fallback: content heuristic for skills without a lock entry.
|
||||
const skillMd = join(skillDir, 'SKILL.md');
|
||||
if (!existsSync(skillMd)) return false;
|
||||
try {
|
||||
@@ -125,9 +145,12 @@ export function findSkillsDirs(projectRoot) {
|
||||
|
||||
/**
|
||||
* Remove deprecated skill directories/symlinks from all harness dirs.
|
||||
* Reads skills-lock.json so the authoritative "source" field can
|
||||
* drive deletion even when SKILL.md never mentions impeccable.
|
||||
* Returns an array of paths that were deleted.
|
||||
*/
|
||||
export function removeDeprecatedSkills(projectRoot) {
|
||||
export function removeDeprecatedSkills(projectRoot, lock) {
|
||||
if (lock === undefined) lock = loadLock(projectRoot);
|
||||
const targets = buildTargetNames();
|
||||
const skillsDirs = findSkillsDirs(projectRoot);
|
||||
const deleted = [];
|
||||
@@ -149,7 +172,9 @@ export function removeDeprecatedSkills(projectRoot) {
|
||||
// Symlink: check the target if it's alive, otherwise treat
|
||||
// dangling symlinks to deprecated names as safe to remove.
|
||||
const targetAlive = existsSync(skillPath);
|
||||
const isMatch = targetAlive ? isImpeccableSkill(skillPath) : true;
|
||||
const isMatch = targetAlive
|
||||
? isImpeccableSkill(skillPath, { skillName: name, lock })
|
||||
: true;
|
||||
if (isMatch) {
|
||||
unlinkSync(skillPath);
|
||||
deleted.push(skillPath);
|
||||
@@ -158,7 +183,7 @@ export function removeDeprecatedSkills(projectRoot) {
|
||||
}
|
||||
|
||||
// Regular directory -- verify it belongs to impeccable
|
||||
if (isImpeccableSkill(skillPath)) {
|
||||
if (isImpeccableSkill(skillPath, { skillName: name, lock })) {
|
||||
rmSync(skillPath, { recursive: true, force: true });
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
@@ -208,10 +233,15 @@ export function cleanSkillsLock(projectRoot) {
|
||||
|
||||
/**
|
||||
* Run the full cleanup. Returns a summary object.
|
||||
*
|
||||
* Order matters: read the lock and delete directories first, then
|
||||
* strip lock entries. Otherwise the authoritative signal is gone by
|
||||
* the time directory deletion runs.
|
||||
*/
|
||||
export function cleanup(projectRoot) {
|
||||
const root = projectRoot || findProjectRoot();
|
||||
const deletedPaths = removeDeprecatedSkills(root);
|
||||
const lock = loadLock(root);
|
||||
const deletedPaths = removeDeprecatedSkills(root, lock);
|
||||
const removedLockEntries = cleanSkillsLock(root);
|
||||
return { deletedPaths, removedLockEntries, projectRoot: root };
|
||||
}
|
||||
|
||||
@@ -6,18 +6,22 @@
|
||||
* 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.
|
||||
* Shapes are named by patch mechanism, not framework origin:
|
||||
* - "append-arrays": CSP defined as structured directive arrays. Patch
|
||||
* appends a dev-only localhost entry. Covers:
|
||||
* - Monorepo helpers with additional*Src options
|
||||
* (e.g. createBaseNextConfig for Next)
|
||||
* - SvelteKit kit.csp.directives
|
||||
* - nuxt-security module's contentSecurityPolicy
|
||||
* - "append-string": CSP built as a literal value string. Patch splices
|
||||
* a dev-only token into script-src and connect-src.
|
||||
* Covers:
|
||||
* - Inline Next.js headers() with CSP string
|
||||
* - Nuxt routeRules / nitro.routeRules CSP headers
|
||||
* - "middleware": CSP set dynamically in middleware.{ts,js}.
|
||||
* Detected but not auto-patched in v1.
|
||||
* - "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.
|
||||
*/
|
||||
|
||||
@@ -43,19 +47,35 @@ 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 = [
|
||||
// append-arrays signals: CSP expressed as structured directive arrays
|
||||
const MONOREPO_HELPER_SIGNALS = [
|
||||
/\bbuildCSPConfig\b/,
|
||||
/\bbuildSecurityHeaders\b/,
|
||||
/\badditionalScriptSrc\b/,
|
||||
/\badditionalConnectSrc\b/,
|
||||
/\bcreateBaseNextConfig\b/,
|
||||
];
|
||||
const SVELTEKIT_CSP_SIGNALS = [
|
||||
/\bkit\s*:/,
|
||||
/\bcsp\s*:/,
|
||||
/\bdirectives\s*:/,
|
||||
];
|
||||
const NUXT_SECURITY_SIGNALS = [
|
||||
/['"]nuxt-security['"]/,
|
||||
/\bcontentSecurityPolicy\b/,
|
||||
];
|
||||
|
||||
// append-string signals: CSP written as a literal value string
|
||||
const INLINE_HEADER_SIGNALS = [
|
||||
/["']Content-Security-Policy["']/i,
|
||||
/\bscript-src\b/,
|
||||
/\bconnect-src\b/,
|
||||
];
|
||||
const NUXT_ROUTE_RULES_SIGNALS = [
|
||||
/\brouteRules\b/,
|
||||
/Content-Security-Policy/i,
|
||||
/\bscript-src\b/,
|
||||
];
|
||||
|
||||
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
|
||||
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
@@ -65,67 +85,78 @@ const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
* @returns {{ shape: string|null, signals: string[] }}
|
||||
*/
|
||||
export function detectCsp(cwd = process.cwd()) {
|
||||
const hits = { sharedHelper: [], inlineHeader: [], middleware: [], metaTag: [] };
|
||||
const hits = { appendArrays: [], appendString: [], middleware: [], metaTag: [] };
|
||||
|
||||
walk(cwd, cwd, 0, (absPath, relPath, body) => {
|
||||
const ext = path.extname(absPath);
|
||||
const base = path.basename(absPath).toLowerCase();
|
||||
const isConfig = (name) =>
|
||||
new RegExp('(^|/)' + name + '\\.config\\.').test(relPath);
|
||||
|
||||
// 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);
|
||||
}
|
||||
// === append-arrays candidates ===
|
||||
|
||||
// Monorepo CSP helper: packages/*/src/.../(config|security)/*
|
||||
if (SCAN_EXTS.has(ext) &&
|
||||
/packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath) &&
|
||||
MONOREPO_HELPER_SIGNALS.some((re) => re.test(body))) {
|
||||
hits.appendArrays.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
// SvelteKit kit.csp.directives
|
||||
if (SCAN_EXTS.has(ext) && isConfig('svelte') &&
|
||||
SVELTEKIT_CSP_SIGNALS.every((re) => re.test(body))) {
|
||||
hits.appendArrays.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Middleware CSP: middleware.{ts,js} at project root or app/
|
||||
// Nuxt nuxt-security module
|
||||
if (SCAN_EXTS.has(ext) && isConfig('nuxt') &&
|
||||
NUXT_SECURITY_SIGNALS.every((re) => re.test(body))) {
|
||||
hits.appendArrays.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// === append-string candidates ===
|
||||
|
||||
// Inline headers in Next/Nuxt/SvelteKit/Astro/Vite config
|
||||
if (SCAN_EXTS.has(ext) &&
|
||||
/(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath) &&
|
||||
INLINE_HEADER_SIGNALS.every((re) => re.test(body))) {
|
||||
// Nuxt routeRules is a sub-shape of append-string; we already covered
|
||||
// nuxt-security above via return, so any remaining Nuxt CSP match here
|
||||
// is a route-rules / inline-headers case. Either way, same patch
|
||||
// mechanism.
|
||||
hits.appendString.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// === detect-only shapes ===
|
||||
|
||||
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,
|
||||
};
|
||||
// Priority: append-arrays > append-string > middleware > meta-tag.
|
||||
// Structured patches are safer than string splices; runtime and HTML
|
||||
// injection patches are less reliable and v1 doesn't auto-apply them.
|
||||
if (hits.appendArrays.length > 0) {
|
||||
return { shape: 'append-arrays', signals: hits.appendArrays };
|
||||
}
|
||||
if (hits.inlineHeader.length > 0) {
|
||||
return {
|
||||
shape: 'inline-headers',
|
||||
signals: hits.inlineHeader,
|
||||
};
|
||||
if (hits.appendString.length > 0) {
|
||||
return { shape: 'append-string', signals: hits.appendString };
|
||||
}
|
||||
if (hits.middleware.length > 0) {
|
||||
return {
|
||||
shape: 'middleware',
|
||||
signals: hits.middleware,
|
||||
};
|
||||
return { shape: 'middleware', signals: hits.middleware };
|
||||
}
|
||||
if (hits.metaTag.length > 0) {
|
||||
return {
|
||||
shape: 'meta-tag',
|
||||
signals: hits.metaTag,
|
||||
};
|
||||
return { shape: 'meta-tag', signals: hits.metaTag };
|
||||
}
|
||||
return { shape: null, signals: [] };
|
||||
}
|
||||
|
||||
@@ -324,11 +324,16 @@ Otherwise, run the detection helper:
|
||||
node {{scripts_path}}/detect-csp.mjs
|
||||
```
|
||||
|
||||
Output: `{ shape, signals }` where `shape` is one of `shared-helper`, `inline-headers`, `middleware`, `meta-tag`, or `null`.
|
||||
Output: `{ shape, signals }` where `shape` is one of `append-arrays`, `append-string`, `middleware`, `meta-tag`, or `null`. The shape is named by *patch mechanism*, so one template covers many frameworks.
|
||||
|
||||
- **`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.
|
||||
- **`append-arrays`** — CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers:
|
||||
- Monorepo helpers with `additionalScriptSrc` / `additionalConnectSrc` options (Next.js + shared config package)
|
||||
- SvelteKit `kit.csp.directives`
|
||||
- Nuxt `nuxt-security` module's `contentSecurityPolicy`
|
||||
- **`append-string`** — CSP written as a literal value string. Auto-patchable. See *append-string* below. Covers:
|
||||
- Inline `next.config.*` `headers()` with a CSP literal
|
||||
- Nuxt `routeRules` / `nitro.routeRules` headers
|
||||
- **`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
|
||||
@@ -348,9 +353,11 @@ On "no": skip the patch, mention live won't work until the user adds the allowan
|
||||
|
||||
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
|
||||
|
||||
#### Shape 1 — shared helper with `additional*Src` arrays
|
||||
#### append-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`:
|
||||
CSP expressed as structured directive arrays. Patch mechanism: declare a dev-only array, spread it into the script-src and connect-src arrays.
|
||||
|
||||
**Declare near the top of the file that holds the CSP arrays:**
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
|
||||
@@ -358,30 +365,41 @@ const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
|
||||
```
|
||||
|
||||
Append `...__impeccableLiveDev` to both `additionalScriptSrc` and `additionalConnectSrc` array options.
|
||||
**Append `...__impeccableLiveDev` to the script-src and connect-src directive arrays.** Per-framework specifics:
|
||||
|
||||
- **Next.js + monorepo helper** — edit the *app's* `next.config.*` (not the shared helper), appending to `additionalScriptSrc` and `additionalConnectSrc` passed into `createBaseNextConfig` (or equivalent). Keeps the shared package clean.
|
||||
- **SvelteKit** — edit `svelte.config.js`, appending to `kit.csp.directives['script-src']` and `kit.csp.directives['connect-src']`.
|
||||
- **Nuxt + nuxt-security** — edit `nuxt.config.*`, appending to `security.headers.contentSecurityPolicy['script-src']` and `['connect-src']`.
|
||||
|
||||
Reference outputs:
|
||||
- `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` (Next.js)
|
||||
- `tests/framework-fixtures/sveltekit-csp/expected-after-patch.js` (SvelteKit)
|
||||
|
||||
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.
|
||||
#### append-string
|
||||
|
||||
#### 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.
|
||||
CSP built as a literal value string. Two-point patch: declare a dev-only string near the top, interpolate it into the CSP at the `script-src` and `connect-src` directives.
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load.
|
||||
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}`
|
||||
Then in 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.)
|
||||
(Leading space on the dev string so it concatenates cleanly into the existing value. Convert the literal CSP directives into template strings as part of the edit if they aren't already.)
|
||||
|
||||
Read the current file, locate the exact CSP string, quote the two directive edits in the consent prompt, write after confirmation.
|
||||
Per-framework specifics:
|
||||
- **Next.js inline `headers()`** — edit `next.config.*`, splicing the variable into the CSP value.
|
||||
- **Nuxt `routeRules`** — edit `nuxt.config.*`, splicing into the CSP in `routeRules['/**'].headers['Content-Security-Policy']`.
|
||||
|
||||
See `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` for the full desired output.
|
||||
Reference outputs:
|
||||
- `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` (Next.js)
|
||||
- `tests/framework-fixtures/nuxt-csp/expected-after-patch.ts` (Nuxt)
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
|
||||
@@ -80,11 +80,31 @@ export function findProjectRoot(startDir = process.cwd()) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable by reading its
|
||||
* SKILL.md and looking for the word "impeccable" (case-insensitive).
|
||||
* Returns false for non-existent paths or skills that don't match.
|
||||
* Load skills-lock.json from the project root, or null if missing/unreadable.
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir) {
|
||||
export function loadLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable. Prefers the
|
||||
* authoritative lock signal (source === "pbakaus/impeccable") when a
|
||||
* skillName and lock are supplied, and falls back to a SKILL.md
|
||||
* content check for older skills that predate the self-identification
|
||||
* convention.
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir, { skillName, lock } = {}) {
|
||||
// Authoritative: the lock file claims this skill is ours.
|
||||
if (skillName && lock?.skills?.[skillName]?.source === 'pbakaus/impeccable') {
|
||||
return true;
|
||||
}
|
||||
// Fallback: content heuristic for skills without a lock entry.
|
||||
const skillMd = join(skillDir, 'SKILL.md');
|
||||
if (!existsSync(skillMd)) return false;
|
||||
try {
|
||||
@@ -125,9 +145,12 @@ export function findSkillsDirs(projectRoot) {
|
||||
|
||||
/**
|
||||
* Remove deprecated skill directories/symlinks from all harness dirs.
|
||||
* Reads skills-lock.json so the authoritative "source" field can
|
||||
* drive deletion even when SKILL.md never mentions impeccable.
|
||||
* Returns an array of paths that were deleted.
|
||||
*/
|
||||
export function removeDeprecatedSkills(projectRoot) {
|
||||
export function removeDeprecatedSkills(projectRoot, lock) {
|
||||
if (lock === undefined) lock = loadLock(projectRoot);
|
||||
const targets = buildTargetNames();
|
||||
const skillsDirs = findSkillsDirs(projectRoot);
|
||||
const deleted = [];
|
||||
@@ -149,7 +172,9 @@ export function removeDeprecatedSkills(projectRoot) {
|
||||
// Symlink: check the target if it's alive, otherwise treat
|
||||
// dangling symlinks to deprecated names as safe to remove.
|
||||
const targetAlive = existsSync(skillPath);
|
||||
const isMatch = targetAlive ? isImpeccableSkill(skillPath) : true;
|
||||
const isMatch = targetAlive
|
||||
? isImpeccableSkill(skillPath, { skillName: name, lock })
|
||||
: true;
|
||||
if (isMatch) {
|
||||
unlinkSync(skillPath);
|
||||
deleted.push(skillPath);
|
||||
@@ -158,7 +183,7 @@ export function removeDeprecatedSkills(projectRoot) {
|
||||
}
|
||||
|
||||
// Regular directory -- verify it belongs to impeccable
|
||||
if (isImpeccableSkill(skillPath)) {
|
||||
if (isImpeccableSkill(skillPath, { skillName: name, lock })) {
|
||||
rmSync(skillPath, { recursive: true, force: true });
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
@@ -208,10 +233,15 @@ export function cleanSkillsLock(projectRoot) {
|
||||
|
||||
/**
|
||||
* Run the full cleanup. Returns a summary object.
|
||||
*
|
||||
* Order matters: read the lock and delete directories first, then
|
||||
* strip lock entries. Otherwise the authoritative signal is gone by
|
||||
* the time directory deletion runs.
|
||||
*/
|
||||
export function cleanup(projectRoot) {
|
||||
const root = projectRoot || findProjectRoot();
|
||||
const deletedPaths = removeDeprecatedSkills(root);
|
||||
const lock = loadLock(root);
|
||||
const deletedPaths = removeDeprecatedSkills(root, lock);
|
||||
const removedLockEntries = cleanSkillsLock(root);
|
||||
return { deletedPaths, removedLockEntries, projectRoot: root };
|
||||
}
|
||||
|
||||
@@ -6,18 +6,22 @@
|
||||
* 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.
|
||||
* Shapes are named by patch mechanism, not framework origin:
|
||||
* - "append-arrays": CSP defined as structured directive arrays. Patch
|
||||
* appends a dev-only localhost entry. Covers:
|
||||
* - Monorepo helpers with additional*Src options
|
||||
* (e.g. createBaseNextConfig for Next)
|
||||
* - SvelteKit kit.csp.directives
|
||||
* - nuxt-security module's contentSecurityPolicy
|
||||
* - "append-string": CSP built as a literal value string. Patch splices
|
||||
* a dev-only token into script-src and connect-src.
|
||||
* Covers:
|
||||
* - Inline Next.js headers() with CSP string
|
||||
* - Nuxt routeRules / nitro.routeRules CSP headers
|
||||
* - "middleware": CSP set dynamically in middleware.{ts,js}.
|
||||
* Detected but not auto-patched in v1.
|
||||
* - "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.
|
||||
*/
|
||||
|
||||
@@ -43,19 +47,35 @@ 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 = [
|
||||
// append-arrays signals: CSP expressed as structured directive arrays
|
||||
const MONOREPO_HELPER_SIGNALS = [
|
||||
/\bbuildCSPConfig\b/,
|
||||
/\bbuildSecurityHeaders\b/,
|
||||
/\badditionalScriptSrc\b/,
|
||||
/\badditionalConnectSrc\b/,
|
||||
/\bcreateBaseNextConfig\b/,
|
||||
];
|
||||
const SVELTEKIT_CSP_SIGNALS = [
|
||||
/\bkit\s*:/,
|
||||
/\bcsp\s*:/,
|
||||
/\bdirectives\s*:/,
|
||||
];
|
||||
const NUXT_SECURITY_SIGNALS = [
|
||||
/['"]nuxt-security['"]/,
|
||||
/\bcontentSecurityPolicy\b/,
|
||||
];
|
||||
|
||||
// append-string signals: CSP written as a literal value string
|
||||
const INLINE_HEADER_SIGNALS = [
|
||||
/["']Content-Security-Policy["']/i,
|
||||
/\bscript-src\b/,
|
||||
/\bconnect-src\b/,
|
||||
];
|
||||
const NUXT_ROUTE_RULES_SIGNALS = [
|
||||
/\brouteRules\b/,
|
||||
/Content-Security-Policy/i,
|
||||
/\bscript-src\b/,
|
||||
];
|
||||
|
||||
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
|
||||
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
@@ -65,67 +85,78 @@ const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
* @returns {{ shape: string|null, signals: string[] }}
|
||||
*/
|
||||
export function detectCsp(cwd = process.cwd()) {
|
||||
const hits = { sharedHelper: [], inlineHeader: [], middleware: [], metaTag: [] };
|
||||
const hits = { appendArrays: [], appendString: [], middleware: [], metaTag: [] };
|
||||
|
||||
walk(cwd, cwd, 0, (absPath, relPath, body) => {
|
||||
const ext = path.extname(absPath);
|
||||
const base = path.basename(absPath).toLowerCase();
|
||||
const isConfig = (name) =>
|
||||
new RegExp('(^|/)' + name + '\\.config\\.').test(relPath);
|
||||
|
||||
// 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);
|
||||
}
|
||||
// === append-arrays candidates ===
|
||||
|
||||
// Monorepo CSP helper: packages/*/src/.../(config|security)/*
|
||||
if (SCAN_EXTS.has(ext) &&
|
||||
/packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath) &&
|
||||
MONOREPO_HELPER_SIGNALS.some((re) => re.test(body))) {
|
||||
hits.appendArrays.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
// SvelteKit kit.csp.directives
|
||||
if (SCAN_EXTS.has(ext) && isConfig('svelte') &&
|
||||
SVELTEKIT_CSP_SIGNALS.every((re) => re.test(body))) {
|
||||
hits.appendArrays.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Middleware CSP: middleware.{ts,js} at project root or app/
|
||||
// Nuxt nuxt-security module
|
||||
if (SCAN_EXTS.has(ext) && isConfig('nuxt') &&
|
||||
NUXT_SECURITY_SIGNALS.every((re) => re.test(body))) {
|
||||
hits.appendArrays.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// === append-string candidates ===
|
||||
|
||||
// Inline headers in Next/Nuxt/SvelteKit/Astro/Vite config
|
||||
if (SCAN_EXTS.has(ext) &&
|
||||
/(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath) &&
|
||||
INLINE_HEADER_SIGNALS.every((re) => re.test(body))) {
|
||||
// Nuxt routeRules is a sub-shape of append-string; we already covered
|
||||
// nuxt-security above via return, so any remaining Nuxt CSP match here
|
||||
// is a route-rules / inline-headers case. Either way, same patch
|
||||
// mechanism.
|
||||
hits.appendString.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// === detect-only shapes ===
|
||||
|
||||
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,
|
||||
};
|
||||
// Priority: append-arrays > append-string > middleware > meta-tag.
|
||||
// Structured patches are safer than string splices; runtime and HTML
|
||||
// injection patches are less reliable and v1 doesn't auto-apply them.
|
||||
if (hits.appendArrays.length > 0) {
|
||||
return { shape: 'append-arrays', signals: hits.appendArrays };
|
||||
}
|
||||
if (hits.inlineHeader.length > 0) {
|
||||
return {
|
||||
shape: 'inline-headers',
|
||||
signals: hits.inlineHeader,
|
||||
};
|
||||
if (hits.appendString.length > 0) {
|
||||
return { shape: 'append-string', signals: hits.appendString };
|
||||
}
|
||||
if (hits.middleware.length > 0) {
|
||||
return {
|
||||
shape: 'middleware',
|
||||
signals: hits.middleware,
|
||||
};
|
||||
return { shape: 'middleware', signals: hits.middleware };
|
||||
}
|
||||
if (hits.metaTag.length > 0) {
|
||||
return {
|
||||
shape: 'meta-tag',
|
||||
signals: hits.metaTag,
|
||||
};
|
||||
return { shape: 'meta-tag', signals: hits.metaTag };
|
||||
}
|
||||
return { shape: null, signals: [] };
|
||||
}
|
||||
|
||||
@@ -324,11 +324,16 @@ Otherwise, run the detection helper:
|
||||
node {{scripts_path}}/detect-csp.mjs
|
||||
```
|
||||
|
||||
Output: `{ shape, signals }` where `shape` is one of `shared-helper`, `inline-headers`, `middleware`, `meta-tag`, or `null`.
|
||||
Output: `{ shape, signals }` where `shape` is one of `append-arrays`, `append-string`, `middleware`, `meta-tag`, or `null`. The shape is named by *patch mechanism*, so one template covers many frameworks.
|
||||
|
||||
- **`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.
|
||||
- **`append-arrays`** — CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers:
|
||||
- Monorepo helpers with `additionalScriptSrc` / `additionalConnectSrc` options (Next.js + shared config package)
|
||||
- SvelteKit `kit.csp.directives`
|
||||
- Nuxt `nuxt-security` module's `contentSecurityPolicy`
|
||||
- **`append-string`** — CSP written as a literal value string. Auto-patchable. See *append-string* below. Covers:
|
||||
- Inline `next.config.*` `headers()` with a CSP literal
|
||||
- Nuxt `routeRules` / `nitro.routeRules` headers
|
||||
- **`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
|
||||
@@ -348,9 +353,11 @@ On "no": skip the patch, mention live won't work until the user adds the allowan
|
||||
|
||||
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
|
||||
|
||||
#### Shape 1 — shared helper with `additional*Src` arrays
|
||||
#### append-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`:
|
||||
CSP expressed as structured directive arrays. Patch mechanism: declare a dev-only array, spread it into the script-src and connect-src arrays.
|
||||
|
||||
**Declare near the top of the file that holds the CSP arrays:**
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
|
||||
@@ -358,30 +365,41 @@ const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
|
||||
```
|
||||
|
||||
Append `...__impeccableLiveDev` to both `additionalScriptSrc` and `additionalConnectSrc` array options.
|
||||
**Append `...__impeccableLiveDev` to the script-src and connect-src directive arrays.** Per-framework specifics:
|
||||
|
||||
- **Next.js + monorepo helper** — edit the *app's* `next.config.*` (not the shared helper), appending to `additionalScriptSrc` and `additionalConnectSrc` passed into `createBaseNextConfig` (or equivalent). Keeps the shared package clean.
|
||||
- **SvelteKit** — edit `svelte.config.js`, appending to `kit.csp.directives['script-src']` and `kit.csp.directives['connect-src']`.
|
||||
- **Nuxt + nuxt-security** — edit `nuxt.config.*`, appending to `security.headers.contentSecurityPolicy['script-src']` and `['connect-src']`.
|
||||
|
||||
Reference outputs:
|
||||
- `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` (Next.js)
|
||||
- `tests/framework-fixtures/sveltekit-csp/expected-after-patch.js` (SvelteKit)
|
||||
|
||||
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.
|
||||
#### append-string
|
||||
|
||||
#### 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.
|
||||
CSP built as a literal value string. Two-point patch: declare a dev-only string near the top, interpolate it into the CSP at the `script-src` and `connect-src` directives.
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load.
|
||||
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}`
|
||||
Then in 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.)
|
||||
(Leading space on the dev string so it concatenates cleanly into the existing value. Convert the literal CSP directives into template strings as part of the edit if they aren't already.)
|
||||
|
||||
Read the current file, locate the exact CSP string, quote the two directive edits in the consent prompt, write after confirmation.
|
||||
Per-framework specifics:
|
||||
- **Next.js inline `headers()`** — edit `next.config.*`, splicing the variable into the CSP value.
|
||||
- **Nuxt `routeRules`** — edit `nuxt.config.*`, splicing into the CSP in `routeRules['/**'].headers['Content-Security-Policy']`.
|
||||
|
||||
See `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` for the full desired output.
|
||||
Reference outputs:
|
||||
- `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` (Next.js)
|
||||
- `tests/framework-fixtures/nuxt-csp/expected-after-patch.ts` (Nuxt)
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
|
||||
@@ -80,11 +80,31 @@ export function findProjectRoot(startDir = process.cwd()) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable by reading its
|
||||
* SKILL.md and looking for the word "impeccable" (case-insensitive).
|
||||
* Returns false for non-existent paths or skills that don't match.
|
||||
* Load skills-lock.json from the project root, or null if missing/unreadable.
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir) {
|
||||
export function loadLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable. Prefers the
|
||||
* authoritative lock signal (source === "pbakaus/impeccable") when a
|
||||
* skillName and lock are supplied, and falls back to a SKILL.md
|
||||
* content check for older skills that predate the self-identification
|
||||
* convention.
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir, { skillName, lock } = {}) {
|
||||
// Authoritative: the lock file claims this skill is ours.
|
||||
if (skillName && lock?.skills?.[skillName]?.source === 'pbakaus/impeccable') {
|
||||
return true;
|
||||
}
|
||||
// Fallback: content heuristic for skills without a lock entry.
|
||||
const skillMd = join(skillDir, 'SKILL.md');
|
||||
if (!existsSync(skillMd)) return false;
|
||||
try {
|
||||
@@ -125,9 +145,12 @@ export function findSkillsDirs(projectRoot) {
|
||||
|
||||
/**
|
||||
* Remove deprecated skill directories/symlinks from all harness dirs.
|
||||
* Reads skills-lock.json so the authoritative "source" field can
|
||||
* drive deletion even when SKILL.md never mentions impeccable.
|
||||
* Returns an array of paths that were deleted.
|
||||
*/
|
||||
export function removeDeprecatedSkills(projectRoot) {
|
||||
export function removeDeprecatedSkills(projectRoot, lock) {
|
||||
if (lock === undefined) lock = loadLock(projectRoot);
|
||||
const targets = buildTargetNames();
|
||||
const skillsDirs = findSkillsDirs(projectRoot);
|
||||
const deleted = [];
|
||||
@@ -149,7 +172,9 @@ export function removeDeprecatedSkills(projectRoot) {
|
||||
// Symlink: check the target if it's alive, otherwise treat
|
||||
// dangling symlinks to deprecated names as safe to remove.
|
||||
const targetAlive = existsSync(skillPath);
|
||||
const isMatch = targetAlive ? isImpeccableSkill(skillPath) : true;
|
||||
const isMatch = targetAlive
|
||||
? isImpeccableSkill(skillPath, { skillName: name, lock })
|
||||
: true;
|
||||
if (isMatch) {
|
||||
unlinkSync(skillPath);
|
||||
deleted.push(skillPath);
|
||||
@@ -158,7 +183,7 @@ export function removeDeprecatedSkills(projectRoot) {
|
||||
}
|
||||
|
||||
// Regular directory -- verify it belongs to impeccable
|
||||
if (isImpeccableSkill(skillPath)) {
|
||||
if (isImpeccableSkill(skillPath, { skillName: name, lock })) {
|
||||
rmSync(skillPath, { recursive: true, force: true });
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
@@ -208,10 +233,15 @@ export function cleanSkillsLock(projectRoot) {
|
||||
|
||||
/**
|
||||
* Run the full cleanup. Returns a summary object.
|
||||
*
|
||||
* Order matters: read the lock and delete directories first, then
|
||||
* strip lock entries. Otherwise the authoritative signal is gone by
|
||||
* the time directory deletion runs.
|
||||
*/
|
||||
export function cleanup(projectRoot) {
|
||||
const root = projectRoot || findProjectRoot();
|
||||
const deletedPaths = removeDeprecatedSkills(root);
|
||||
const lock = loadLock(root);
|
||||
const deletedPaths = removeDeprecatedSkills(root, lock);
|
||||
const removedLockEntries = cleanSkillsLock(root);
|
||||
return { deletedPaths, removedLockEntries, projectRoot: root };
|
||||
}
|
||||
|
||||
@@ -6,18 +6,22 @@
|
||||
* 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.
|
||||
* Shapes are named by patch mechanism, not framework origin:
|
||||
* - "append-arrays": CSP defined as structured directive arrays. Patch
|
||||
* appends a dev-only localhost entry. Covers:
|
||||
* - Monorepo helpers with additional*Src options
|
||||
* (e.g. createBaseNextConfig for Next)
|
||||
* - SvelteKit kit.csp.directives
|
||||
* - nuxt-security module's contentSecurityPolicy
|
||||
* - "append-string": CSP built as a literal value string. Patch splices
|
||||
* a dev-only token into script-src and connect-src.
|
||||
* Covers:
|
||||
* - Inline Next.js headers() with CSP string
|
||||
* - Nuxt routeRules / nitro.routeRules CSP headers
|
||||
* - "middleware": CSP set dynamically in middleware.{ts,js}.
|
||||
* Detected but not auto-patched in v1.
|
||||
* - "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.
|
||||
*/
|
||||
|
||||
@@ -43,19 +47,35 @@ 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 = [
|
||||
// append-arrays signals: CSP expressed as structured directive arrays
|
||||
const MONOREPO_HELPER_SIGNALS = [
|
||||
/\bbuildCSPConfig\b/,
|
||||
/\bbuildSecurityHeaders\b/,
|
||||
/\badditionalScriptSrc\b/,
|
||||
/\badditionalConnectSrc\b/,
|
||||
/\bcreateBaseNextConfig\b/,
|
||||
];
|
||||
const SVELTEKIT_CSP_SIGNALS = [
|
||||
/\bkit\s*:/,
|
||||
/\bcsp\s*:/,
|
||||
/\bdirectives\s*:/,
|
||||
];
|
||||
const NUXT_SECURITY_SIGNALS = [
|
||||
/['"]nuxt-security['"]/,
|
||||
/\bcontentSecurityPolicy\b/,
|
||||
];
|
||||
|
||||
// append-string signals: CSP written as a literal value string
|
||||
const INLINE_HEADER_SIGNALS = [
|
||||
/["']Content-Security-Policy["']/i,
|
||||
/\bscript-src\b/,
|
||||
/\bconnect-src\b/,
|
||||
];
|
||||
const NUXT_ROUTE_RULES_SIGNALS = [
|
||||
/\brouteRules\b/,
|
||||
/Content-Security-Policy/i,
|
||||
/\bscript-src\b/,
|
||||
];
|
||||
|
||||
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
|
||||
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
@@ -65,67 +85,78 @@ const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
* @returns {{ shape: string|null, signals: string[] }}
|
||||
*/
|
||||
export function detectCsp(cwd = process.cwd()) {
|
||||
const hits = { sharedHelper: [], inlineHeader: [], middleware: [], metaTag: [] };
|
||||
const hits = { appendArrays: [], appendString: [], middleware: [], metaTag: [] };
|
||||
|
||||
walk(cwd, cwd, 0, (absPath, relPath, body) => {
|
||||
const ext = path.extname(absPath);
|
||||
const base = path.basename(absPath).toLowerCase();
|
||||
const isConfig = (name) =>
|
||||
new RegExp('(^|/)' + name + '\\.config\\.').test(relPath);
|
||||
|
||||
// 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);
|
||||
}
|
||||
// === append-arrays candidates ===
|
||||
|
||||
// Monorepo CSP helper: packages/*/src/.../(config|security)/*
|
||||
if (SCAN_EXTS.has(ext) &&
|
||||
/packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath) &&
|
||||
MONOREPO_HELPER_SIGNALS.some((re) => re.test(body))) {
|
||||
hits.appendArrays.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
// SvelteKit kit.csp.directives
|
||||
if (SCAN_EXTS.has(ext) && isConfig('svelte') &&
|
||||
SVELTEKIT_CSP_SIGNALS.every((re) => re.test(body))) {
|
||||
hits.appendArrays.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Middleware CSP: middleware.{ts,js} at project root or app/
|
||||
// Nuxt nuxt-security module
|
||||
if (SCAN_EXTS.has(ext) && isConfig('nuxt') &&
|
||||
NUXT_SECURITY_SIGNALS.every((re) => re.test(body))) {
|
||||
hits.appendArrays.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// === append-string candidates ===
|
||||
|
||||
// Inline headers in Next/Nuxt/SvelteKit/Astro/Vite config
|
||||
if (SCAN_EXTS.has(ext) &&
|
||||
/(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath) &&
|
||||
INLINE_HEADER_SIGNALS.every((re) => re.test(body))) {
|
||||
// Nuxt routeRules is a sub-shape of append-string; we already covered
|
||||
// nuxt-security above via return, so any remaining Nuxt CSP match here
|
||||
// is a route-rules / inline-headers case. Either way, same patch
|
||||
// mechanism.
|
||||
hits.appendString.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// === detect-only shapes ===
|
||||
|
||||
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,
|
||||
};
|
||||
// Priority: append-arrays > append-string > middleware > meta-tag.
|
||||
// Structured patches are safer than string splices; runtime and HTML
|
||||
// injection patches are less reliable and v1 doesn't auto-apply them.
|
||||
if (hits.appendArrays.length > 0) {
|
||||
return { shape: 'append-arrays', signals: hits.appendArrays };
|
||||
}
|
||||
if (hits.inlineHeader.length > 0) {
|
||||
return {
|
||||
shape: 'inline-headers',
|
||||
signals: hits.inlineHeader,
|
||||
};
|
||||
if (hits.appendString.length > 0) {
|
||||
return { shape: 'append-string', signals: hits.appendString };
|
||||
}
|
||||
if (hits.middleware.length > 0) {
|
||||
return {
|
||||
shape: 'middleware',
|
||||
signals: hits.middleware,
|
||||
};
|
||||
return { shape: 'middleware', signals: hits.middleware };
|
||||
}
|
||||
if (hits.metaTag.length > 0) {
|
||||
return {
|
||||
shape: 'meta-tag',
|
||||
signals: hits.metaTag,
|
||||
};
|
||||
return { shape: 'meta-tag', signals: hits.metaTag };
|
||||
}
|
||||
return { shape: null, signals: [] };
|
||||
}
|
||||
|
||||
@@ -324,11 +324,16 @@ Otherwise, run the detection helper:
|
||||
node {{scripts_path}}/detect-csp.mjs
|
||||
```
|
||||
|
||||
Output: `{ shape, signals }` where `shape` is one of `shared-helper`, `inline-headers`, `middleware`, `meta-tag`, or `null`.
|
||||
Output: `{ shape, signals }` where `shape` is one of `append-arrays`, `append-string`, `middleware`, `meta-tag`, or `null`. The shape is named by *patch mechanism*, so one template covers many frameworks.
|
||||
|
||||
- **`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.
|
||||
- **`append-arrays`** — CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers:
|
||||
- Monorepo helpers with `additionalScriptSrc` / `additionalConnectSrc` options (Next.js + shared config package)
|
||||
- SvelteKit `kit.csp.directives`
|
||||
- Nuxt `nuxt-security` module's `contentSecurityPolicy`
|
||||
- **`append-string`** — CSP written as a literal value string. Auto-patchable. See *append-string* below. Covers:
|
||||
- Inline `next.config.*` `headers()` with a CSP literal
|
||||
- Nuxt `routeRules` / `nitro.routeRules` headers
|
||||
- **`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
|
||||
@@ -348,9 +353,11 @@ On "no": skip the patch, mention live won't work until the user adds the allowan
|
||||
|
||||
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
|
||||
|
||||
#### Shape 1 — shared helper with `additional*Src` arrays
|
||||
#### append-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`:
|
||||
CSP expressed as structured directive arrays. Patch mechanism: declare a dev-only array, spread it into the script-src and connect-src arrays.
|
||||
|
||||
**Declare near the top of the file that holds the CSP arrays:**
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
|
||||
@@ -358,30 +365,41 @@ const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
|
||||
```
|
||||
|
||||
Append `...__impeccableLiveDev` to both `additionalScriptSrc` and `additionalConnectSrc` array options.
|
||||
**Append `...__impeccableLiveDev` to the script-src and connect-src directive arrays.** Per-framework specifics:
|
||||
|
||||
- **Next.js + monorepo helper** — edit the *app's* `next.config.*` (not the shared helper), appending to `additionalScriptSrc` and `additionalConnectSrc` passed into `createBaseNextConfig` (or equivalent). Keeps the shared package clean.
|
||||
- **SvelteKit** — edit `svelte.config.js`, appending to `kit.csp.directives['script-src']` and `kit.csp.directives['connect-src']`.
|
||||
- **Nuxt + nuxt-security** — edit `nuxt.config.*`, appending to `security.headers.contentSecurityPolicy['script-src']` and `['connect-src']`.
|
||||
|
||||
Reference outputs:
|
||||
- `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` (Next.js)
|
||||
- `tests/framework-fixtures/sveltekit-csp/expected-after-patch.js` (SvelteKit)
|
||||
|
||||
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.
|
||||
#### append-string
|
||||
|
||||
#### 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.
|
||||
CSP built as a literal value string. Two-point patch: declare a dev-only string near the top, interpolate it into the CSP at the `script-src` and `connect-src` directives.
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load.
|
||||
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}`
|
||||
Then in 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.)
|
||||
(Leading space on the dev string so it concatenates cleanly into the existing value. Convert the literal CSP directives into template strings as part of the edit if they aren't already.)
|
||||
|
||||
Read the current file, locate the exact CSP string, quote the two directive edits in the consent prompt, write after confirmation.
|
||||
Per-framework specifics:
|
||||
- **Next.js inline `headers()`** — edit `next.config.*`, splicing the variable into the CSP value.
|
||||
- **Nuxt `routeRules`** — edit `nuxt.config.*`, splicing into the CSP in `routeRules['/**'].headers['Content-Security-Policy']`.
|
||||
|
||||
See `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` for the full desired output.
|
||||
Reference outputs:
|
||||
- `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` (Next.js)
|
||||
- `tests/framework-fixtures/nuxt-csp/expected-after-patch.ts` (Nuxt)
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
|
||||
@@ -80,11 +80,31 @@ export function findProjectRoot(startDir = process.cwd()) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable by reading its
|
||||
* SKILL.md and looking for the word "impeccable" (case-insensitive).
|
||||
* Returns false for non-existent paths or skills that don't match.
|
||||
* Load skills-lock.json from the project root, or null if missing/unreadable.
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir) {
|
||||
export function loadLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable. Prefers the
|
||||
* authoritative lock signal (source === "pbakaus/impeccable") when a
|
||||
* skillName and lock are supplied, and falls back to a SKILL.md
|
||||
* content check for older skills that predate the self-identification
|
||||
* convention.
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir, { skillName, lock } = {}) {
|
||||
// Authoritative: the lock file claims this skill is ours.
|
||||
if (skillName && lock?.skills?.[skillName]?.source === 'pbakaus/impeccable') {
|
||||
return true;
|
||||
}
|
||||
// Fallback: content heuristic for skills without a lock entry.
|
||||
const skillMd = join(skillDir, 'SKILL.md');
|
||||
if (!existsSync(skillMd)) return false;
|
||||
try {
|
||||
@@ -125,9 +145,12 @@ export function findSkillsDirs(projectRoot) {
|
||||
|
||||
/**
|
||||
* Remove deprecated skill directories/symlinks from all harness dirs.
|
||||
* Reads skills-lock.json so the authoritative "source" field can
|
||||
* drive deletion even when SKILL.md never mentions impeccable.
|
||||
* Returns an array of paths that were deleted.
|
||||
*/
|
||||
export function removeDeprecatedSkills(projectRoot) {
|
||||
export function removeDeprecatedSkills(projectRoot, lock) {
|
||||
if (lock === undefined) lock = loadLock(projectRoot);
|
||||
const targets = buildTargetNames();
|
||||
const skillsDirs = findSkillsDirs(projectRoot);
|
||||
const deleted = [];
|
||||
@@ -149,7 +172,9 @@ export function removeDeprecatedSkills(projectRoot) {
|
||||
// Symlink: check the target if it's alive, otherwise treat
|
||||
// dangling symlinks to deprecated names as safe to remove.
|
||||
const targetAlive = existsSync(skillPath);
|
||||
const isMatch = targetAlive ? isImpeccableSkill(skillPath) : true;
|
||||
const isMatch = targetAlive
|
||||
? isImpeccableSkill(skillPath, { skillName: name, lock })
|
||||
: true;
|
||||
if (isMatch) {
|
||||
unlinkSync(skillPath);
|
||||
deleted.push(skillPath);
|
||||
@@ -158,7 +183,7 @@ export function removeDeprecatedSkills(projectRoot) {
|
||||
}
|
||||
|
||||
// Regular directory -- verify it belongs to impeccable
|
||||
if (isImpeccableSkill(skillPath)) {
|
||||
if (isImpeccableSkill(skillPath, { skillName: name, lock })) {
|
||||
rmSync(skillPath, { recursive: true, force: true });
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
@@ -208,10 +233,15 @@ export function cleanSkillsLock(projectRoot) {
|
||||
|
||||
/**
|
||||
* Run the full cleanup. Returns a summary object.
|
||||
*
|
||||
* Order matters: read the lock and delete directories first, then
|
||||
* strip lock entries. Otherwise the authoritative signal is gone by
|
||||
* the time directory deletion runs.
|
||||
*/
|
||||
export function cleanup(projectRoot) {
|
||||
const root = projectRoot || findProjectRoot();
|
||||
const deletedPaths = removeDeprecatedSkills(root);
|
||||
const lock = loadLock(root);
|
||||
const deletedPaths = removeDeprecatedSkills(root, lock);
|
||||
const removedLockEntries = cleanSkillsLock(root);
|
||||
return { deletedPaths, removedLockEntries, projectRoot: root };
|
||||
}
|
||||
|
||||
@@ -6,18 +6,22 @@
|
||||
* 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.
|
||||
* Shapes are named by patch mechanism, not framework origin:
|
||||
* - "append-arrays": CSP defined as structured directive arrays. Patch
|
||||
* appends a dev-only localhost entry. Covers:
|
||||
* - Monorepo helpers with additional*Src options
|
||||
* (e.g. createBaseNextConfig for Next)
|
||||
* - SvelteKit kit.csp.directives
|
||||
* - nuxt-security module's contentSecurityPolicy
|
||||
* - "append-string": CSP built as a literal value string. Patch splices
|
||||
* a dev-only token into script-src and connect-src.
|
||||
* Covers:
|
||||
* - Inline Next.js headers() with CSP string
|
||||
* - Nuxt routeRules / nitro.routeRules CSP headers
|
||||
* - "middleware": CSP set dynamically in middleware.{ts,js}.
|
||||
* Detected but not auto-patched in v1.
|
||||
* - "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.
|
||||
*/
|
||||
|
||||
@@ -43,19 +47,35 @@ 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 = [
|
||||
// append-arrays signals: CSP expressed as structured directive arrays
|
||||
const MONOREPO_HELPER_SIGNALS = [
|
||||
/\bbuildCSPConfig\b/,
|
||||
/\bbuildSecurityHeaders\b/,
|
||||
/\badditionalScriptSrc\b/,
|
||||
/\badditionalConnectSrc\b/,
|
||||
/\bcreateBaseNextConfig\b/,
|
||||
];
|
||||
const SVELTEKIT_CSP_SIGNALS = [
|
||||
/\bkit\s*:/,
|
||||
/\bcsp\s*:/,
|
||||
/\bdirectives\s*:/,
|
||||
];
|
||||
const NUXT_SECURITY_SIGNALS = [
|
||||
/['"]nuxt-security['"]/,
|
||||
/\bcontentSecurityPolicy\b/,
|
||||
];
|
||||
|
||||
// append-string signals: CSP written as a literal value string
|
||||
const INLINE_HEADER_SIGNALS = [
|
||||
/["']Content-Security-Policy["']/i,
|
||||
/\bscript-src\b/,
|
||||
/\bconnect-src\b/,
|
||||
];
|
||||
const NUXT_ROUTE_RULES_SIGNALS = [
|
||||
/\brouteRules\b/,
|
||||
/Content-Security-Policy/i,
|
||||
/\bscript-src\b/,
|
||||
];
|
||||
|
||||
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
|
||||
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
@@ -65,67 +85,78 @@ const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
* @returns {{ shape: string|null, signals: string[] }}
|
||||
*/
|
||||
export function detectCsp(cwd = process.cwd()) {
|
||||
const hits = { sharedHelper: [], inlineHeader: [], middleware: [], metaTag: [] };
|
||||
const hits = { appendArrays: [], appendString: [], middleware: [], metaTag: [] };
|
||||
|
||||
walk(cwd, cwd, 0, (absPath, relPath, body) => {
|
||||
const ext = path.extname(absPath);
|
||||
const base = path.basename(absPath).toLowerCase();
|
||||
const isConfig = (name) =>
|
||||
new RegExp('(^|/)' + name + '\\.config\\.').test(relPath);
|
||||
|
||||
// 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);
|
||||
}
|
||||
// === append-arrays candidates ===
|
||||
|
||||
// Monorepo CSP helper: packages/*/src/.../(config|security)/*
|
||||
if (SCAN_EXTS.has(ext) &&
|
||||
/packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath) &&
|
||||
MONOREPO_HELPER_SIGNALS.some((re) => re.test(body))) {
|
||||
hits.appendArrays.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
// SvelteKit kit.csp.directives
|
||||
if (SCAN_EXTS.has(ext) && isConfig('svelte') &&
|
||||
SVELTEKIT_CSP_SIGNALS.every((re) => re.test(body))) {
|
||||
hits.appendArrays.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Middleware CSP: middleware.{ts,js} at project root or app/
|
||||
// Nuxt nuxt-security module
|
||||
if (SCAN_EXTS.has(ext) && isConfig('nuxt') &&
|
||||
NUXT_SECURITY_SIGNALS.every((re) => re.test(body))) {
|
||||
hits.appendArrays.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// === append-string candidates ===
|
||||
|
||||
// Inline headers in Next/Nuxt/SvelteKit/Astro/Vite config
|
||||
if (SCAN_EXTS.has(ext) &&
|
||||
/(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath) &&
|
||||
INLINE_HEADER_SIGNALS.every((re) => re.test(body))) {
|
||||
// Nuxt routeRules is a sub-shape of append-string; we already covered
|
||||
// nuxt-security above via return, so any remaining Nuxt CSP match here
|
||||
// is a route-rules / inline-headers case. Either way, same patch
|
||||
// mechanism.
|
||||
hits.appendString.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// === detect-only shapes ===
|
||||
|
||||
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,
|
||||
};
|
||||
// Priority: append-arrays > append-string > middleware > meta-tag.
|
||||
// Structured patches are safer than string splices; runtime and HTML
|
||||
// injection patches are less reliable and v1 doesn't auto-apply them.
|
||||
if (hits.appendArrays.length > 0) {
|
||||
return { shape: 'append-arrays', signals: hits.appendArrays };
|
||||
}
|
||||
if (hits.inlineHeader.length > 0) {
|
||||
return {
|
||||
shape: 'inline-headers',
|
||||
signals: hits.inlineHeader,
|
||||
};
|
||||
if (hits.appendString.length > 0) {
|
||||
return { shape: 'append-string', signals: hits.appendString };
|
||||
}
|
||||
if (hits.middleware.length > 0) {
|
||||
return {
|
||||
shape: 'middleware',
|
||||
signals: hits.middleware,
|
||||
};
|
||||
return { shape: 'middleware', signals: hits.middleware };
|
||||
}
|
||||
if (hits.metaTag.length > 0) {
|
||||
return {
|
||||
shape: 'meta-tag',
|
||||
signals: hits.metaTag,
|
||||
};
|
||||
return { shape: 'meta-tag', signals: hits.metaTag };
|
||||
}
|
||||
return { shape: null, signals: [] };
|
||||
}
|
||||
|
||||
@@ -324,11 +324,16 @@ Otherwise, run the detection helper:
|
||||
node {{scripts_path}}/detect-csp.mjs
|
||||
```
|
||||
|
||||
Output: `{ shape, signals }` where `shape` is one of `shared-helper`, `inline-headers`, `middleware`, `meta-tag`, or `null`.
|
||||
Output: `{ shape, signals }` where `shape` is one of `append-arrays`, `append-string`, `middleware`, `meta-tag`, or `null`. The shape is named by *patch mechanism*, so one template covers many frameworks.
|
||||
|
||||
- **`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.
|
||||
- **`append-arrays`** — CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers:
|
||||
- Monorepo helpers with `additionalScriptSrc` / `additionalConnectSrc` options (Next.js + shared config package)
|
||||
- SvelteKit `kit.csp.directives`
|
||||
- Nuxt `nuxt-security` module's `contentSecurityPolicy`
|
||||
- **`append-string`** — CSP written as a literal value string. Auto-patchable. See *append-string* below. Covers:
|
||||
- Inline `next.config.*` `headers()` with a CSP literal
|
||||
- Nuxt `routeRules` / `nitro.routeRules` headers
|
||||
- **`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
|
||||
@@ -348,9 +353,11 @@ On "no": skip the patch, mention live won't work until the user adds the allowan
|
||||
|
||||
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
|
||||
|
||||
#### Shape 1 — shared helper with `additional*Src` arrays
|
||||
#### append-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`:
|
||||
CSP expressed as structured directive arrays. Patch mechanism: declare a dev-only array, spread it into the script-src and connect-src arrays.
|
||||
|
||||
**Declare near the top of the file that holds the CSP arrays:**
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
|
||||
@@ -358,30 +365,41 @@ const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
|
||||
```
|
||||
|
||||
Append `...__impeccableLiveDev` to both `additionalScriptSrc` and `additionalConnectSrc` array options.
|
||||
**Append `...__impeccableLiveDev` to the script-src and connect-src directive arrays.** Per-framework specifics:
|
||||
|
||||
- **Next.js + monorepo helper** — edit the *app's* `next.config.*` (not the shared helper), appending to `additionalScriptSrc` and `additionalConnectSrc` passed into `createBaseNextConfig` (or equivalent). Keeps the shared package clean.
|
||||
- **SvelteKit** — edit `svelte.config.js`, appending to `kit.csp.directives['script-src']` and `kit.csp.directives['connect-src']`.
|
||||
- **Nuxt + nuxt-security** — edit `nuxt.config.*`, appending to `security.headers.contentSecurityPolicy['script-src']` and `['connect-src']`.
|
||||
|
||||
Reference outputs:
|
||||
- `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` (Next.js)
|
||||
- `tests/framework-fixtures/sveltekit-csp/expected-after-patch.js` (SvelteKit)
|
||||
|
||||
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.
|
||||
#### append-string
|
||||
|
||||
#### 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.
|
||||
CSP built as a literal value string. Two-point patch: declare a dev-only string near the top, interpolate it into the CSP at the `script-src` and `connect-src` directives.
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load.
|
||||
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}`
|
||||
Then in 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.)
|
||||
(Leading space on the dev string so it concatenates cleanly into the existing value. Convert the literal CSP directives into template strings as part of the edit if they aren't already.)
|
||||
|
||||
Read the current file, locate the exact CSP string, quote the two directive edits in the consent prompt, write after confirmation.
|
||||
Per-framework specifics:
|
||||
- **Next.js inline `headers()`** — edit `next.config.*`, splicing the variable into the CSP value.
|
||||
- **Nuxt `routeRules`** — edit `nuxt.config.*`, splicing into the CSP in `routeRules['/**'].headers['Content-Security-Policy']`.
|
||||
|
||||
See `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` for the full desired output.
|
||||
Reference outputs:
|
||||
- `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` (Next.js)
|
||||
- `tests/framework-fixtures/nuxt-csp/expected-after-patch.ts` (Nuxt)
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
|
||||
@@ -80,11 +80,31 @@ export function findProjectRoot(startDir = process.cwd()) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable by reading its
|
||||
* SKILL.md and looking for the word "impeccable" (case-insensitive).
|
||||
* Returns false for non-existent paths or skills that don't match.
|
||||
* Load skills-lock.json from the project root, or null if missing/unreadable.
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir) {
|
||||
export function loadLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable. Prefers the
|
||||
* authoritative lock signal (source === "pbakaus/impeccable") when a
|
||||
* skillName and lock are supplied, and falls back to a SKILL.md
|
||||
* content check for older skills that predate the self-identification
|
||||
* convention.
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir, { skillName, lock } = {}) {
|
||||
// Authoritative: the lock file claims this skill is ours.
|
||||
if (skillName && lock?.skills?.[skillName]?.source === 'pbakaus/impeccable') {
|
||||
return true;
|
||||
}
|
||||
// Fallback: content heuristic for skills without a lock entry.
|
||||
const skillMd = join(skillDir, 'SKILL.md');
|
||||
if (!existsSync(skillMd)) return false;
|
||||
try {
|
||||
@@ -125,9 +145,12 @@ export function findSkillsDirs(projectRoot) {
|
||||
|
||||
/**
|
||||
* Remove deprecated skill directories/symlinks from all harness dirs.
|
||||
* Reads skills-lock.json so the authoritative "source" field can
|
||||
* drive deletion even when SKILL.md never mentions impeccable.
|
||||
* Returns an array of paths that were deleted.
|
||||
*/
|
||||
export function removeDeprecatedSkills(projectRoot) {
|
||||
export function removeDeprecatedSkills(projectRoot, lock) {
|
||||
if (lock === undefined) lock = loadLock(projectRoot);
|
||||
const targets = buildTargetNames();
|
||||
const skillsDirs = findSkillsDirs(projectRoot);
|
||||
const deleted = [];
|
||||
@@ -149,7 +172,9 @@ export function removeDeprecatedSkills(projectRoot) {
|
||||
// Symlink: check the target if it's alive, otherwise treat
|
||||
// dangling symlinks to deprecated names as safe to remove.
|
||||
const targetAlive = existsSync(skillPath);
|
||||
const isMatch = targetAlive ? isImpeccableSkill(skillPath) : true;
|
||||
const isMatch = targetAlive
|
||||
? isImpeccableSkill(skillPath, { skillName: name, lock })
|
||||
: true;
|
||||
if (isMatch) {
|
||||
unlinkSync(skillPath);
|
||||
deleted.push(skillPath);
|
||||
@@ -158,7 +183,7 @@ export function removeDeprecatedSkills(projectRoot) {
|
||||
}
|
||||
|
||||
// Regular directory -- verify it belongs to impeccable
|
||||
if (isImpeccableSkill(skillPath)) {
|
||||
if (isImpeccableSkill(skillPath, { skillName: name, lock })) {
|
||||
rmSync(skillPath, { recursive: true, force: true });
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
@@ -208,10 +233,15 @@ export function cleanSkillsLock(projectRoot) {
|
||||
|
||||
/**
|
||||
* Run the full cleanup. Returns a summary object.
|
||||
*
|
||||
* Order matters: read the lock and delete directories first, then
|
||||
* strip lock entries. Otherwise the authoritative signal is gone by
|
||||
* the time directory deletion runs.
|
||||
*/
|
||||
export function cleanup(projectRoot) {
|
||||
const root = projectRoot || findProjectRoot();
|
||||
const deletedPaths = removeDeprecatedSkills(root);
|
||||
const lock = loadLock(root);
|
||||
const deletedPaths = removeDeprecatedSkills(root, lock);
|
||||
const removedLockEntries = cleanSkillsLock(root);
|
||||
return { deletedPaths, removedLockEntries, projectRoot: root };
|
||||
}
|
||||
|
||||
@@ -6,18 +6,22 @@
|
||||
* 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.
|
||||
* Shapes are named by patch mechanism, not framework origin:
|
||||
* - "append-arrays": CSP defined as structured directive arrays. Patch
|
||||
* appends a dev-only localhost entry. Covers:
|
||||
* - Monorepo helpers with additional*Src options
|
||||
* (e.g. createBaseNextConfig for Next)
|
||||
* - SvelteKit kit.csp.directives
|
||||
* - nuxt-security module's contentSecurityPolicy
|
||||
* - "append-string": CSP built as a literal value string. Patch splices
|
||||
* a dev-only token into script-src and connect-src.
|
||||
* Covers:
|
||||
* - Inline Next.js headers() with CSP string
|
||||
* - Nuxt routeRules / nitro.routeRules CSP headers
|
||||
* - "middleware": CSP set dynamically in middleware.{ts,js}.
|
||||
* Detected but not auto-patched in v1.
|
||||
* - "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.
|
||||
*/
|
||||
|
||||
@@ -43,19 +47,35 @@ 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 = [
|
||||
// append-arrays signals: CSP expressed as structured directive arrays
|
||||
const MONOREPO_HELPER_SIGNALS = [
|
||||
/\bbuildCSPConfig\b/,
|
||||
/\bbuildSecurityHeaders\b/,
|
||||
/\badditionalScriptSrc\b/,
|
||||
/\badditionalConnectSrc\b/,
|
||||
/\bcreateBaseNextConfig\b/,
|
||||
];
|
||||
const SVELTEKIT_CSP_SIGNALS = [
|
||||
/\bkit\s*:/,
|
||||
/\bcsp\s*:/,
|
||||
/\bdirectives\s*:/,
|
||||
];
|
||||
const NUXT_SECURITY_SIGNALS = [
|
||||
/['"]nuxt-security['"]/,
|
||||
/\bcontentSecurityPolicy\b/,
|
||||
];
|
||||
|
||||
// append-string signals: CSP written as a literal value string
|
||||
const INLINE_HEADER_SIGNALS = [
|
||||
/["']Content-Security-Policy["']/i,
|
||||
/\bscript-src\b/,
|
||||
/\bconnect-src\b/,
|
||||
];
|
||||
const NUXT_ROUTE_RULES_SIGNALS = [
|
||||
/\brouteRules\b/,
|
||||
/Content-Security-Policy/i,
|
||||
/\bscript-src\b/,
|
||||
];
|
||||
|
||||
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
|
||||
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
@@ -65,67 +85,78 @@ const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
* @returns {{ shape: string|null, signals: string[] }}
|
||||
*/
|
||||
export function detectCsp(cwd = process.cwd()) {
|
||||
const hits = { sharedHelper: [], inlineHeader: [], middleware: [], metaTag: [] };
|
||||
const hits = { appendArrays: [], appendString: [], middleware: [], metaTag: [] };
|
||||
|
||||
walk(cwd, cwd, 0, (absPath, relPath, body) => {
|
||||
const ext = path.extname(absPath);
|
||||
const base = path.basename(absPath).toLowerCase();
|
||||
const isConfig = (name) =>
|
||||
new RegExp('(^|/)' + name + '\\.config\\.').test(relPath);
|
||||
|
||||
// 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);
|
||||
}
|
||||
// === append-arrays candidates ===
|
||||
|
||||
// Monorepo CSP helper: packages/*/src/.../(config|security)/*
|
||||
if (SCAN_EXTS.has(ext) &&
|
||||
/packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath) &&
|
||||
MONOREPO_HELPER_SIGNALS.some((re) => re.test(body))) {
|
||||
hits.appendArrays.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
// SvelteKit kit.csp.directives
|
||||
if (SCAN_EXTS.has(ext) && isConfig('svelte') &&
|
||||
SVELTEKIT_CSP_SIGNALS.every((re) => re.test(body))) {
|
||||
hits.appendArrays.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Middleware CSP: middleware.{ts,js} at project root or app/
|
||||
// Nuxt nuxt-security module
|
||||
if (SCAN_EXTS.has(ext) && isConfig('nuxt') &&
|
||||
NUXT_SECURITY_SIGNALS.every((re) => re.test(body))) {
|
||||
hits.appendArrays.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// === append-string candidates ===
|
||||
|
||||
// Inline headers in Next/Nuxt/SvelteKit/Astro/Vite config
|
||||
if (SCAN_EXTS.has(ext) &&
|
||||
/(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath) &&
|
||||
INLINE_HEADER_SIGNALS.every((re) => re.test(body))) {
|
||||
// Nuxt routeRules is a sub-shape of append-string; we already covered
|
||||
// nuxt-security above via return, so any remaining Nuxt CSP match here
|
||||
// is a route-rules / inline-headers case. Either way, same patch
|
||||
// mechanism.
|
||||
hits.appendString.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// === detect-only shapes ===
|
||||
|
||||
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,
|
||||
};
|
||||
// Priority: append-arrays > append-string > middleware > meta-tag.
|
||||
// Structured patches are safer than string splices; runtime and HTML
|
||||
// injection patches are less reliable and v1 doesn't auto-apply them.
|
||||
if (hits.appendArrays.length > 0) {
|
||||
return { shape: 'append-arrays', signals: hits.appendArrays };
|
||||
}
|
||||
if (hits.inlineHeader.length > 0) {
|
||||
return {
|
||||
shape: 'inline-headers',
|
||||
signals: hits.inlineHeader,
|
||||
};
|
||||
if (hits.appendString.length > 0) {
|
||||
return { shape: 'append-string', signals: hits.appendString };
|
||||
}
|
||||
if (hits.middleware.length > 0) {
|
||||
return {
|
||||
shape: 'middleware',
|
||||
signals: hits.middleware,
|
||||
};
|
||||
return { shape: 'middleware', signals: hits.middleware };
|
||||
}
|
||||
if (hits.metaTag.length > 0) {
|
||||
return {
|
||||
shape: 'meta-tag',
|
||||
signals: hits.metaTag,
|
||||
};
|
||||
return { shape: 'meta-tag', signals: hits.metaTag };
|
||||
}
|
||||
return { shape: null, signals: [] };
|
||||
}
|
||||
|
||||
@@ -324,11 +324,16 @@ Otherwise, run the detection helper:
|
||||
node {{scripts_path}}/detect-csp.mjs
|
||||
```
|
||||
|
||||
Output: `{ shape, signals }` where `shape` is one of `shared-helper`, `inline-headers`, `middleware`, `meta-tag`, or `null`.
|
||||
Output: `{ shape, signals }` where `shape` is one of `append-arrays`, `append-string`, `middleware`, `meta-tag`, or `null`. The shape is named by *patch mechanism*, so one template covers many frameworks.
|
||||
|
||||
- **`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.
|
||||
- **`append-arrays`** — CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers:
|
||||
- Monorepo helpers with `additionalScriptSrc` / `additionalConnectSrc` options (Next.js + shared config package)
|
||||
- SvelteKit `kit.csp.directives`
|
||||
- Nuxt `nuxt-security` module's `contentSecurityPolicy`
|
||||
- **`append-string`** — CSP written as a literal value string. Auto-patchable. See *append-string* below. Covers:
|
||||
- Inline `next.config.*` `headers()` with a CSP literal
|
||||
- Nuxt `routeRules` / `nitro.routeRules` headers
|
||||
- **`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
|
||||
@@ -348,9 +353,11 @@ On "no": skip the patch, mention live won't work until the user adds the allowan
|
||||
|
||||
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
|
||||
|
||||
#### Shape 1 — shared helper with `additional*Src` arrays
|
||||
#### append-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`:
|
||||
CSP expressed as structured directive arrays. Patch mechanism: declare a dev-only array, spread it into the script-src and connect-src arrays.
|
||||
|
||||
**Declare near the top of the file that holds the CSP arrays:**
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
|
||||
@@ -358,30 +365,41 @@ const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
|
||||
```
|
||||
|
||||
Append `...__impeccableLiveDev` to both `additionalScriptSrc` and `additionalConnectSrc` array options.
|
||||
**Append `...__impeccableLiveDev` to the script-src and connect-src directive arrays.** Per-framework specifics:
|
||||
|
||||
- **Next.js + monorepo helper** — edit the *app's* `next.config.*` (not the shared helper), appending to `additionalScriptSrc` and `additionalConnectSrc` passed into `createBaseNextConfig` (or equivalent). Keeps the shared package clean.
|
||||
- **SvelteKit** — edit `svelte.config.js`, appending to `kit.csp.directives['script-src']` and `kit.csp.directives['connect-src']`.
|
||||
- **Nuxt + nuxt-security** — edit `nuxt.config.*`, appending to `security.headers.contentSecurityPolicy['script-src']` and `['connect-src']`.
|
||||
|
||||
Reference outputs:
|
||||
- `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` (Next.js)
|
||||
- `tests/framework-fixtures/sveltekit-csp/expected-after-patch.js` (SvelteKit)
|
||||
|
||||
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.
|
||||
#### append-string
|
||||
|
||||
#### 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.
|
||||
CSP built as a literal value string. Two-point patch: declare a dev-only string near the top, interpolate it into the CSP at the `script-src` and `connect-src` directives.
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load.
|
||||
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}`
|
||||
Then in 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.)
|
||||
(Leading space on the dev string so it concatenates cleanly into the existing value. Convert the literal CSP directives into template strings as part of the edit if they aren't already.)
|
||||
|
||||
Read the current file, locate the exact CSP string, quote the two directive edits in the consent prompt, write after confirmation.
|
||||
Per-framework specifics:
|
||||
- **Next.js inline `headers()`** — edit `next.config.*`, splicing the variable into the CSP value.
|
||||
- **Nuxt `routeRules`** — edit `nuxt.config.*`, splicing into the CSP in `routeRules['/**'].headers['Content-Security-Policy']`.
|
||||
|
||||
See `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` for the full desired output.
|
||||
Reference outputs:
|
||||
- `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` (Next.js)
|
||||
- `tests/framework-fixtures/nuxt-csp/expected-after-patch.ts` (Nuxt)
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
|
||||
@@ -80,11 +80,31 @@ export function findProjectRoot(startDir = process.cwd()) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable by reading its
|
||||
* SKILL.md and looking for the word "impeccable" (case-insensitive).
|
||||
* Returns false for non-existent paths or skills that don't match.
|
||||
* Load skills-lock.json from the project root, or null if missing/unreadable.
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir) {
|
||||
export function loadLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable. Prefers the
|
||||
* authoritative lock signal (source === "pbakaus/impeccable") when a
|
||||
* skillName and lock are supplied, and falls back to a SKILL.md
|
||||
* content check for older skills that predate the self-identification
|
||||
* convention.
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir, { skillName, lock } = {}) {
|
||||
// Authoritative: the lock file claims this skill is ours.
|
||||
if (skillName && lock?.skills?.[skillName]?.source === 'pbakaus/impeccable') {
|
||||
return true;
|
||||
}
|
||||
// Fallback: content heuristic for skills without a lock entry.
|
||||
const skillMd = join(skillDir, 'SKILL.md');
|
||||
if (!existsSync(skillMd)) return false;
|
||||
try {
|
||||
@@ -125,9 +145,12 @@ export function findSkillsDirs(projectRoot) {
|
||||
|
||||
/**
|
||||
* Remove deprecated skill directories/symlinks from all harness dirs.
|
||||
* Reads skills-lock.json so the authoritative "source" field can
|
||||
* drive deletion even when SKILL.md never mentions impeccable.
|
||||
* Returns an array of paths that were deleted.
|
||||
*/
|
||||
export function removeDeprecatedSkills(projectRoot) {
|
||||
export function removeDeprecatedSkills(projectRoot, lock) {
|
||||
if (lock === undefined) lock = loadLock(projectRoot);
|
||||
const targets = buildTargetNames();
|
||||
const skillsDirs = findSkillsDirs(projectRoot);
|
||||
const deleted = [];
|
||||
@@ -149,7 +172,9 @@ export function removeDeprecatedSkills(projectRoot) {
|
||||
// Symlink: check the target if it's alive, otherwise treat
|
||||
// dangling symlinks to deprecated names as safe to remove.
|
||||
const targetAlive = existsSync(skillPath);
|
||||
const isMatch = targetAlive ? isImpeccableSkill(skillPath) : true;
|
||||
const isMatch = targetAlive
|
||||
? isImpeccableSkill(skillPath, { skillName: name, lock })
|
||||
: true;
|
||||
if (isMatch) {
|
||||
unlinkSync(skillPath);
|
||||
deleted.push(skillPath);
|
||||
@@ -158,7 +183,7 @@ export function removeDeprecatedSkills(projectRoot) {
|
||||
}
|
||||
|
||||
// Regular directory -- verify it belongs to impeccable
|
||||
if (isImpeccableSkill(skillPath)) {
|
||||
if (isImpeccableSkill(skillPath, { skillName: name, lock })) {
|
||||
rmSync(skillPath, { recursive: true, force: true });
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
@@ -208,10 +233,15 @@ export function cleanSkillsLock(projectRoot) {
|
||||
|
||||
/**
|
||||
* Run the full cleanup. Returns a summary object.
|
||||
*
|
||||
* Order matters: read the lock and delete directories first, then
|
||||
* strip lock entries. Otherwise the authoritative signal is gone by
|
||||
* the time directory deletion runs.
|
||||
*/
|
||||
export function cleanup(projectRoot) {
|
||||
const root = projectRoot || findProjectRoot();
|
||||
const deletedPaths = removeDeprecatedSkills(root);
|
||||
const lock = loadLock(root);
|
||||
const deletedPaths = removeDeprecatedSkills(root, lock);
|
||||
const removedLockEntries = cleanSkillsLock(root);
|
||||
return { deletedPaths, removedLockEntries, projectRoot: root };
|
||||
}
|
||||
|
||||
@@ -6,18 +6,22 @@
|
||||
* 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.
|
||||
* Shapes are named by patch mechanism, not framework origin:
|
||||
* - "append-arrays": CSP defined as structured directive arrays. Patch
|
||||
* appends a dev-only localhost entry. Covers:
|
||||
* - Monorepo helpers with additional*Src options
|
||||
* (e.g. createBaseNextConfig for Next)
|
||||
* - SvelteKit kit.csp.directives
|
||||
* - nuxt-security module's contentSecurityPolicy
|
||||
* - "append-string": CSP built as a literal value string. Patch splices
|
||||
* a dev-only token into script-src and connect-src.
|
||||
* Covers:
|
||||
* - Inline Next.js headers() with CSP string
|
||||
* - Nuxt routeRules / nitro.routeRules CSP headers
|
||||
* - "middleware": CSP set dynamically in middleware.{ts,js}.
|
||||
* Detected but not auto-patched in v1.
|
||||
* - "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.
|
||||
*/
|
||||
|
||||
@@ -43,19 +47,35 @@ 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 = [
|
||||
// append-arrays signals: CSP expressed as structured directive arrays
|
||||
const MONOREPO_HELPER_SIGNALS = [
|
||||
/\bbuildCSPConfig\b/,
|
||||
/\bbuildSecurityHeaders\b/,
|
||||
/\badditionalScriptSrc\b/,
|
||||
/\badditionalConnectSrc\b/,
|
||||
/\bcreateBaseNextConfig\b/,
|
||||
];
|
||||
const SVELTEKIT_CSP_SIGNALS = [
|
||||
/\bkit\s*:/,
|
||||
/\bcsp\s*:/,
|
||||
/\bdirectives\s*:/,
|
||||
];
|
||||
const NUXT_SECURITY_SIGNALS = [
|
||||
/['"]nuxt-security['"]/,
|
||||
/\bcontentSecurityPolicy\b/,
|
||||
];
|
||||
|
||||
// append-string signals: CSP written as a literal value string
|
||||
const INLINE_HEADER_SIGNALS = [
|
||||
/["']Content-Security-Policy["']/i,
|
||||
/\bscript-src\b/,
|
||||
/\bconnect-src\b/,
|
||||
];
|
||||
const NUXT_ROUTE_RULES_SIGNALS = [
|
||||
/\brouteRules\b/,
|
||||
/Content-Security-Policy/i,
|
||||
/\bscript-src\b/,
|
||||
];
|
||||
|
||||
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
|
||||
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
@@ -65,67 +85,78 @@ const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
* @returns {{ shape: string|null, signals: string[] }}
|
||||
*/
|
||||
export function detectCsp(cwd = process.cwd()) {
|
||||
const hits = { sharedHelper: [], inlineHeader: [], middleware: [], metaTag: [] };
|
||||
const hits = { appendArrays: [], appendString: [], middleware: [], metaTag: [] };
|
||||
|
||||
walk(cwd, cwd, 0, (absPath, relPath, body) => {
|
||||
const ext = path.extname(absPath);
|
||||
const base = path.basename(absPath).toLowerCase();
|
||||
const isConfig = (name) =>
|
||||
new RegExp('(^|/)' + name + '\\.config\\.').test(relPath);
|
||||
|
||||
// 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);
|
||||
}
|
||||
// === append-arrays candidates ===
|
||||
|
||||
// Monorepo CSP helper: packages/*/src/.../(config|security)/*
|
||||
if (SCAN_EXTS.has(ext) &&
|
||||
/packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath) &&
|
||||
MONOREPO_HELPER_SIGNALS.some((re) => re.test(body))) {
|
||||
hits.appendArrays.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
// SvelteKit kit.csp.directives
|
||||
if (SCAN_EXTS.has(ext) && isConfig('svelte') &&
|
||||
SVELTEKIT_CSP_SIGNALS.every((re) => re.test(body))) {
|
||||
hits.appendArrays.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Middleware CSP: middleware.{ts,js} at project root or app/
|
||||
// Nuxt nuxt-security module
|
||||
if (SCAN_EXTS.has(ext) && isConfig('nuxt') &&
|
||||
NUXT_SECURITY_SIGNALS.every((re) => re.test(body))) {
|
||||
hits.appendArrays.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// === append-string candidates ===
|
||||
|
||||
// Inline headers in Next/Nuxt/SvelteKit/Astro/Vite config
|
||||
if (SCAN_EXTS.has(ext) &&
|
||||
/(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath) &&
|
||||
INLINE_HEADER_SIGNALS.every((re) => re.test(body))) {
|
||||
// Nuxt routeRules is a sub-shape of append-string; we already covered
|
||||
// nuxt-security above via return, so any remaining Nuxt CSP match here
|
||||
// is a route-rules / inline-headers case. Either way, same patch
|
||||
// mechanism.
|
||||
hits.appendString.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// === detect-only shapes ===
|
||||
|
||||
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,
|
||||
};
|
||||
// Priority: append-arrays > append-string > middleware > meta-tag.
|
||||
// Structured patches are safer than string splices; runtime and HTML
|
||||
// injection patches are less reliable and v1 doesn't auto-apply them.
|
||||
if (hits.appendArrays.length > 0) {
|
||||
return { shape: 'append-arrays', signals: hits.appendArrays };
|
||||
}
|
||||
if (hits.inlineHeader.length > 0) {
|
||||
return {
|
||||
shape: 'inline-headers',
|
||||
signals: hits.inlineHeader,
|
||||
};
|
||||
if (hits.appendString.length > 0) {
|
||||
return { shape: 'append-string', signals: hits.appendString };
|
||||
}
|
||||
if (hits.middleware.length > 0) {
|
||||
return {
|
||||
shape: 'middleware',
|
||||
signals: hits.middleware,
|
||||
};
|
||||
return { shape: 'middleware', signals: hits.middleware };
|
||||
}
|
||||
if (hits.metaTag.length > 0) {
|
||||
return {
|
||||
shape: 'meta-tag',
|
||||
signals: hits.metaTag,
|
||||
};
|
||||
return { shape: 'meta-tag', signals: hits.metaTag };
|
||||
}
|
||||
return { shape: null, signals: [] };
|
||||
}
|
||||
|
||||
@@ -324,11 +324,16 @@ Otherwise, run the detection helper:
|
||||
node {{scripts_path}}/detect-csp.mjs
|
||||
```
|
||||
|
||||
Output: `{ shape, signals }` where `shape` is one of `shared-helper`, `inline-headers`, `middleware`, `meta-tag`, or `null`.
|
||||
Output: `{ shape, signals }` where `shape` is one of `append-arrays`, `append-string`, `middleware`, `meta-tag`, or `null`. The shape is named by *patch mechanism*, so one template covers many frameworks.
|
||||
|
||||
- **`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.
|
||||
- **`append-arrays`** — CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers:
|
||||
- Monorepo helpers with `additionalScriptSrc` / `additionalConnectSrc` options (Next.js + shared config package)
|
||||
- SvelteKit `kit.csp.directives`
|
||||
- Nuxt `nuxt-security` module's `contentSecurityPolicy`
|
||||
- **`append-string`** — CSP written as a literal value string. Auto-patchable. See *append-string* below. Covers:
|
||||
- Inline `next.config.*` `headers()` with a CSP literal
|
||||
- Nuxt `routeRules` / `nitro.routeRules` headers
|
||||
- **`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
|
||||
@@ -348,9 +353,11 @@ On "no": skip the patch, mention live won't work until the user adds the allowan
|
||||
|
||||
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
|
||||
|
||||
#### Shape 1 — shared helper with `additional*Src` arrays
|
||||
#### append-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`:
|
||||
CSP expressed as structured directive arrays. Patch mechanism: declare a dev-only array, spread it into the script-src and connect-src arrays.
|
||||
|
||||
**Declare near the top of the file that holds the CSP arrays:**
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
|
||||
@@ -358,30 +365,41 @@ const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
|
||||
```
|
||||
|
||||
Append `...__impeccableLiveDev` to both `additionalScriptSrc` and `additionalConnectSrc` array options.
|
||||
**Append `...__impeccableLiveDev` to the script-src and connect-src directive arrays.** Per-framework specifics:
|
||||
|
||||
- **Next.js + monorepo helper** — edit the *app's* `next.config.*` (not the shared helper), appending to `additionalScriptSrc` and `additionalConnectSrc` passed into `createBaseNextConfig` (or equivalent). Keeps the shared package clean.
|
||||
- **SvelteKit** — edit `svelte.config.js`, appending to `kit.csp.directives['script-src']` and `kit.csp.directives['connect-src']`.
|
||||
- **Nuxt + nuxt-security** — edit `nuxt.config.*`, appending to `security.headers.contentSecurityPolicy['script-src']` and `['connect-src']`.
|
||||
|
||||
Reference outputs:
|
||||
- `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` (Next.js)
|
||||
- `tests/framework-fixtures/sveltekit-csp/expected-after-patch.js` (SvelteKit)
|
||||
|
||||
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.
|
||||
#### append-string
|
||||
|
||||
#### 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.
|
||||
CSP built as a literal value string. Two-point patch: declare a dev-only string near the top, interpolate it into the CSP at the `script-src` and `connect-src` directives.
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load.
|
||||
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}`
|
||||
Then in 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.)
|
||||
(Leading space on the dev string so it concatenates cleanly into the existing value. Convert the literal CSP directives into template strings as part of the edit if they aren't already.)
|
||||
|
||||
Read the current file, locate the exact CSP string, quote the two directive edits in the consent prompt, write after confirmation.
|
||||
Per-framework specifics:
|
||||
- **Next.js inline `headers()`** — edit `next.config.*`, splicing the variable into the CSP value.
|
||||
- **Nuxt `routeRules`** — edit `nuxt.config.*`, splicing into the CSP in `routeRules['/**'].headers['Content-Security-Policy']`.
|
||||
|
||||
See `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` for the full desired output.
|
||||
Reference outputs:
|
||||
- `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` (Next.js)
|
||||
- `tests/framework-fixtures/nuxt-csp/expected-after-patch.ts` (Nuxt)
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
|
||||
@@ -80,11 +80,31 @@ export function findProjectRoot(startDir = process.cwd()) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable by reading its
|
||||
* SKILL.md and looking for the word "impeccable" (case-insensitive).
|
||||
* Returns false for non-existent paths or skills that don't match.
|
||||
* Load skills-lock.json from the project root, or null if missing/unreadable.
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir) {
|
||||
export function loadLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable. Prefers the
|
||||
* authoritative lock signal (source === "pbakaus/impeccable") when a
|
||||
* skillName and lock are supplied, and falls back to a SKILL.md
|
||||
* content check for older skills that predate the self-identification
|
||||
* convention.
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir, { skillName, lock } = {}) {
|
||||
// Authoritative: the lock file claims this skill is ours.
|
||||
if (skillName && lock?.skills?.[skillName]?.source === 'pbakaus/impeccable') {
|
||||
return true;
|
||||
}
|
||||
// Fallback: content heuristic for skills without a lock entry.
|
||||
const skillMd = join(skillDir, 'SKILL.md');
|
||||
if (!existsSync(skillMd)) return false;
|
||||
try {
|
||||
@@ -125,9 +145,12 @@ export function findSkillsDirs(projectRoot) {
|
||||
|
||||
/**
|
||||
* Remove deprecated skill directories/symlinks from all harness dirs.
|
||||
* Reads skills-lock.json so the authoritative "source" field can
|
||||
* drive deletion even when SKILL.md never mentions impeccable.
|
||||
* Returns an array of paths that were deleted.
|
||||
*/
|
||||
export function removeDeprecatedSkills(projectRoot) {
|
||||
export function removeDeprecatedSkills(projectRoot, lock) {
|
||||
if (lock === undefined) lock = loadLock(projectRoot);
|
||||
const targets = buildTargetNames();
|
||||
const skillsDirs = findSkillsDirs(projectRoot);
|
||||
const deleted = [];
|
||||
@@ -149,7 +172,9 @@ export function removeDeprecatedSkills(projectRoot) {
|
||||
// Symlink: check the target if it's alive, otherwise treat
|
||||
// dangling symlinks to deprecated names as safe to remove.
|
||||
const targetAlive = existsSync(skillPath);
|
||||
const isMatch = targetAlive ? isImpeccableSkill(skillPath) : true;
|
||||
const isMatch = targetAlive
|
||||
? isImpeccableSkill(skillPath, { skillName: name, lock })
|
||||
: true;
|
||||
if (isMatch) {
|
||||
unlinkSync(skillPath);
|
||||
deleted.push(skillPath);
|
||||
@@ -158,7 +183,7 @@ export function removeDeprecatedSkills(projectRoot) {
|
||||
}
|
||||
|
||||
// Regular directory -- verify it belongs to impeccable
|
||||
if (isImpeccableSkill(skillPath)) {
|
||||
if (isImpeccableSkill(skillPath, { skillName: name, lock })) {
|
||||
rmSync(skillPath, { recursive: true, force: true });
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
@@ -208,10 +233,15 @@ export function cleanSkillsLock(projectRoot) {
|
||||
|
||||
/**
|
||||
* Run the full cleanup. Returns a summary object.
|
||||
*
|
||||
* Order matters: read the lock and delete directories first, then
|
||||
* strip lock entries. Otherwise the authoritative signal is gone by
|
||||
* the time directory deletion runs.
|
||||
*/
|
||||
export function cleanup(projectRoot) {
|
||||
const root = projectRoot || findProjectRoot();
|
||||
const deletedPaths = removeDeprecatedSkills(root);
|
||||
const lock = loadLock(root);
|
||||
const deletedPaths = removeDeprecatedSkills(root, lock);
|
||||
const removedLockEntries = cleanSkillsLock(root);
|
||||
return { deletedPaths, removedLockEntries, projectRoot: root };
|
||||
}
|
||||
|
||||
@@ -6,18 +6,22 @@
|
||||
* 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.
|
||||
* Shapes are named by patch mechanism, not framework origin:
|
||||
* - "append-arrays": CSP defined as structured directive arrays. Patch
|
||||
* appends a dev-only localhost entry. Covers:
|
||||
* - Monorepo helpers with additional*Src options
|
||||
* (e.g. createBaseNextConfig for Next)
|
||||
* - SvelteKit kit.csp.directives
|
||||
* - nuxt-security module's contentSecurityPolicy
|
||||
* - "append-string": CSP built as a literal value string. Patch splices
|
||||
* a dev-only token into script-src and connect-src.
|
||||
* Covers:
|
||||
* - Inline Next.js headers() with CSP string
|
||||
* - Nuxt routeRules / nitro.routeRules CSP headers
|
||||
* - "middleware": CSP set dynamically in middleware.{ts,js}.
|
||||
* Detected but not auto-patched in v1.
|
||||
* - "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.
|
||||
*/
|
||||
|
||||
@@ -43,19 +47,35 @@ 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 = [
|
||||
// append-arrays signals: CSP expressed as structured directive arrays
|
||||
const MONOREPO_HELPER_SIGNALS = [
|
||||
/\bbuildCSPConfig\b/,
|
||||
/\bbuildSecurityHeaders\b/,
|
||||
/\badditionalScriptSrc\b/,
|
||||
/\badditionalConnectSrc\b/,
|
||||
/\bcreateBaseNextConfig\b/,
|
||||
];
|
||||
const SVELTEKIT_CSP_SIGNALS = [
|
||||
/\bkit\s*:/,
|
||||
/\bcsp\s*:/,
|
||||
/\bdirectives\s*:/,
|
||||
];
|
||||
const NUXT_SECURITY_SIGNALS = [
|
||||
/['"]nuxt-security['"]/,
|
||||
/\bcontentSecurityPolicy\b/,
|
||||
];
|
||||
|
||||
// append-string signals: CSP written as a literal value string
|
||||
const INLINE_HEADER_SIGNALS = [
|
||||
/["']Content-Security-Policy["']/i,
|
||||
/\bscript-src\b/,
|
||||
/\bconnect-src\b/,
|
||||
];
|
||||
const NUXT_ROUTE_RULES_SIGNALS = [
|
||||
/\brouteRules\b/,
|
||||
/Content-Security-Policy/i,
|
||||
/\bscript-src\b/,
|
||||
];
|
||||
|
||||
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
|
||||
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
@@ -65,67 +85,78 @@ const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
* @returns {{ shape: string|null, signals: string[] }}
|
||||
*/
|
||||
export function detectCsp(cwd = process.cwd()) {
|
||||
const hits = { sharedHelper: [], inlineHeader: [], middleware: [], metaTag: [] };
|
||||
const hits = { appendArrays: [], appendString: [], middleware: [], metaTag: [] };
|
||||
|
||||
walk(cwd, cwd, 0, (absPath, relPath, body) => {
|
||||
const ext = path.extname(absPath);
|
||||
const base = path.basename(absPath).toLowerCase();
|
||||
const isConfig = (name) =>
|
||||
new RegExp('(^|/)' + name + '\\.config\\.').test(relPath);
|
||||
|
||||
// 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);
|
||||
}
|
||||
// === append-arrays candidates ===
|
||||
|
||||
// Monorepo CSP helper: packages/*/src/.../(config|security)/*
|
||||
if (SCAN_EXTS.has(ext) &&
|
||||
/packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath) &&
|
||||
MONOREPO_HELPER_SIGNALS.some((re) => re.test(body))) {
|
||||
hits.appendArrays.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
// SvelteKit kit.csp.directives
|
||||
if (SCAN_EXTS.has(ext) && isConfig('svelte') &&
|
||||
SVELTEKIT_CSP_SIGNALS.every((re) => re.test(body))) {
|
||||
hits.appendArrays.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Middleware CSP: middleware.{ts,js} at project root or app/
|
||||
// Nuxt nuxt-security module
|
||||
if (SCAN_EXTS.has(ext) && isConfig('nuxt') &&
|
||||
NUXT_SECURITY_SIGNALS.every((re) => re.test(body))) {
|
||||
hits.appendArrays.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// === append-string candidates ===
|
||||
|
||||
// Inline headers in Next/Nuxt/SvelteKit/Astro/Vite config
|
||||
if (SCAN_EXTS.has(ext) &&
|
||||
/(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath) &&
|
||||
INLINE_HEADER_SIGNALS.every((re) => re.test(body))) {
|
||||
// Nuxt routeRules is a sub-shape of append-string; we already covered
|
||||
// nuxt-security above via return, so any remaining Nuxt CSP match here
|
||||
// is a route-rules / inline-headers case. Either way, same patch
|
||||
// mechanism.
|
||||
hits.appendString.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// === detect-only shapes ===
|
||||
|
||||
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,
|
||||
};
|
||||
// Priority: append-arrays > append-string > middleware > meta-tag.
|
||||
// Structured patches are safer than string splices; runtime and HTML
|
||||
// injection patches are less reliable and v1 doesn't auto-apply them.
|
||||
if (hits.appendArrays.length > 0) {
|
||||
return { shape: 'append-arrays', signals: hits.appendArrays };
|
||||
}
|
||||
if (hits.inlineHeader.length > 0) {
|
||||
return {
|
||||
shape: 'inline-headers',
|
||||
signals: hits.inlineHeader,
|
||||
};
|
||||
if (hits.appendString.length > 0) {
|
||||
return { shape: 'append-string', signals: hits.appendString };
|
||||
}
|
||||
if (hits.middleware.length > 0) {
|
||||
return {
|
||||
shape: 'middleware',
|
||||
signals: hits.middleware,
|
||||
};
|
||||
return { shape: 'middleware', signals: hits.middleware };
|
||||
}
|
||||
if (hits.metaTag.length > 0) {
|
||||
return {
|
||||
shape: 'meta-tag',
|
||||
signals: hits.metaTag,
|
||||
};
|
||||
return { shape: 'meta-tag', signals: hits.metaTag };
|
||||
}
|
||||
return { shape: null, signals: [] };
|
||||
}
|
||||
|
||||
@@ -324,11 +324,16 @@ Otherwise, run the detection helper:
|
||||
node {{scripts_path}}/detect-csp.mjs
|
||||
```
|
||||
|
||||
Output: `{ shape, signals }` where `shape` is one of `shared-helper`, `inline-headers`, `middleware`, `meta-tag`, or `null`.
|
||||
Output: `{ shape, signals }` where `shape` is one of `append-arrays`, `append-string`, `middleware`, `meta-tag`, or `null`. The shape is named by *patch mechanism*, so one template covers many frameworks.
|
||||
|
||||
- **`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.
|
||||
- **`append-arrays`** — CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers:
|
||||
- Monorepo helpers with `additionalScriptSrc` / `additionalConnectSrc` options (Next.js + shared config package)
|
||||
- SvelteKit `kit.csp.directives`
|
||||
- Nuxt `nuxt-security` module's `contentSecurityPolicy`
|
||||
- **`append-string`** — CSP written as a literal value string. Auto-patchable. See *append-string* below. Covers:
|
||||
- Inline `next.config.*` `headers()` with a CSP literal
|
||||
- Nuxt `routeRules` / `nitro.routeRules` headers
|
||||
- **`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
|
||||
@@ -348,9 +353,11 @@ On "no": skip the patch, mention live won't work until the user adds the allowan
|
||||
|
||||
On "yes": apply the Shape-specific patch below, then write `cspChecked: true`.
|
||||
|
||||
#### Shape 1 — shared helper with `additional*Src` arrays
|
||||
#### append-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`:
|
||||
CSP expressed as structured directive arrays. Patch mechanism: declare a dev-only array, spread it into the script-src and connect-src arrays.
|
||||
|
||||
**Declare near the top of the file that holds the CSP arrays:**
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load. Guarded by NODE_ENV.
|
||||
@@ -358,30 +365,41 @@ const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === "development" ? ["http://localhost:8400"] : [];
|
||||
```
|
||||
|
||||
Append `...__impeccableLiveDev` to both `additionalScriptSrc` and `additionalConnectSrc` array options.
|
||||
**Append `...__impeccableLiveDev` to the script-src and connect-src directive arrays.** Per-framework specifics:
|
||||
|
||||
- **Next.js + monorepo helper** — edit the *app's* `next.config.*` (not the shared helper), appending to `additionalScriptSrc` and `additionalConnectSrc` passed into `createBaseNextConfig` (or equivalent). Keeps the shared package clean.
|
||||
- **SvelteKit** — edit `svelte.config.js`, appending to `kit.csp.directives['script-src']` and `kit.csp.directives['connect-src']`.
|
||||
- **Nuxt + nuxt-security** — edit `nuxt.config.*`, appending to `security.headers.contentSecurityPolicy['script-src']` and `['connect-src']`.
|
||||
|
||||
Reference outputs:
|
||||
- `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` (Next.js)
|
||||
- `tests/framework-fixtures/sveltekit-csp/expected-after-patch.js` (SvelteKit)
|
||||
|
||||
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.
|
||||
#### append-string
|
||||
|
||||
#### 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.
|
||||
CSP built as a literal value string. Two-point patch: declare a dev-only string near the top, interpolate it into the CSP at the `script-src` and `connect-src` directives.
|
||||
|
||||
```ts
|
||||
// Dev-only allowance so impeccable live mode can load.
|
||||
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}`
|
||||
Then in 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.)
|
||||
(Leading space on the dev string so it concatenates cleanly into the existing value. Convert the literal CSP directives into template strings as part of the edit if they aren't already.)
|
||||
|
||||
Read the current file, locate the exact CSP string, quote the two directive edits in the consent prompt, write after confirmation.
|
||||
Per-framework specifics:
|
||||
- **Next.js inline `headers()`** — edit `next.config.*`, splicing the variable into the CSP value.
|
||||
- **Nuxt `routeRules`** — edit `nuxt.config.*`, splicing into the CSP in `routeRules['/**'].headers['Content-Security-Policy']`.
|
||||
|
||||
See `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` for the full desired output.
|
||||
Reference outputs:
|
||||
- `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` (Next.js)
|
||||
- `tests/framework-fixtures/nuxt-csp/expected-after-patch.ts` (Nuxt)
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
|
||||
@@ -80,11 +80,31 @@ export function findProjectRoot(startDir = process.cwd()) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable by reading its
|
||||
* SKILL.md and looking for the word "impeccable" (case-insensitive).
|
||||
* Returns false for non-existent paths or skills that don't match.
|
||||
* Load skills-lock.json from the project root, or null if missing/unreadable.
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir) {
|
||||
export function loadLock(projectRoot) {
|
||||
const lockPath = join(projectRoot, 'skills-lock.json');
|
||||
if (!existsSync(lockPath)) return null;
|
||||
try {
|
||||
return JSON.parse(readFileSync(lockPath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a skill directory belongs to Impeccable. Prefers the
|
||||
* authoritative lock signal (source === "pbakaus/impeccable") when a
|
||||
* skillName and lock are supplied, and falls back to a SKILL.md
|
||||
* content check for older skills that predate the self-identification
|
||||
* convention.
|
||||
*/
|
||||
export function isImpeccableSkill(skillDir, { skillName, lock } = {}) {
|
||||
// Authoritative: the lock file claims this skill is ours.
|
||||
if (skillName && lock?.skills?.[skillName]?.source === 'pbakaus/impeccable') {
|
||||
return true;
|
||||
}
|
||||
// Fallback: content heuristic for skills without a lock entry.
|
||||
const skillMd = join(skillDir, 'SKILL.md');
|
||||
if (!existsSync(skillMd)) return false;
|
||||
try {
|
||||
@@ -125,9 +145,12 @@ export function findSkillsDirs(projectRoot) {
|
||||
|
||||
/**
|
||||
* Remove deprecated skill directories/symlinks from all harness dirs.
|
||||
* Reads skills-lock.json so the authoritative "source" field can
|
||||
* drive deletion even when SKILL.md never mentions impeccable.
|
||||
* Returns an array of paths that were deleted.
|
||||
*/
|
||||
export function removeDeprecatedSkills(projectRoot) {
|
||||
export function removeDeprecatedSkills(projectRoot, lock) {
|
||||
if (lock === undefined) lock = loadLock(projectRoot);
|
||||
const targets = buildTargetNames();
|
||||
const skillsDirs = findSkillsDirs(projectRoot);
|
||||
const deleted = [];
|
||||
@@ -149,7 +172,9 @@ export function removeDeprecatedSkills(projectRoot) {
|
||||
// Symlink: check the target if it's alive, otherwise treat
|
||||
// dangling symlinks to deprecated names as safe to remove.
|
||||
const targetAlive = existsSync(skillPath);
|
||||
const isMatch = targetAlive ? isImpeccableSkill(skillPath) : true;
|
||||
const isMatch = targetAlive
|
||||
? isImpeccableSkill(skillPath, { skillName: name, lock })
|
||||
: true;
|
||||
if (isMatch) {
|
||||
unlinkSync(skillPath);
|
||||
deleted.push(skillPath);
|
||||
@@ -158,7 +183,7 @@ export function removeDeprecatedSkills(projectRoot) {
|
||||
}
|
||||
|
||||
// Regular directory -- verify it belongs to impeccable
|
||||
if (isImpeccableSkill(skillPath)) {
|
||||
if (isImpeccableSkill(skillPath, { skillName: name, lock })) {
|
||||
rmSync(skillPath, { recursive: true, force: true });
|
||||
deleted.push(skillPath);
|
||||
}
|
||||
@@ -208,10 +233,15 @@ export function cleanSkillsLock(projectRoot) {
|
||||
|
||||
/**
|
||||
* Run the full cleanup. Returns a summary object.
|
||||
*
|
||||
* Order matters: read the lock and delete directories first, then
|
||||
* strip lock entries. Otherwise the authoritative signal is gone by
|
||||
* the time directory deletion runs.
|
||||
*/
|
||||
export function cleanup(projectRoot) {
|
||||
const root = projectRoot || findProjectRoot();
|
||||
const deletedPaths = removeDeprecatedSkills(root);
|
||||
const lock = loadLock(root);
|
||||
const deletedPaths = removeDeprecatedSkills(root, lock);
|
||||
const removedLockEntries = cleanSkillsLock(root);
|
||||
return { deletedPaths, removedLockEntries, projectRoot: root };
|
||||
}
|
||||
|
||||
@@ -6,18 +6,22 @@
|
||||
* 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.
|
||||
* Shapes are named by patch mechanism, not framework origin:
|
||||
* - "append-arrays": CSP defined as structured directive arrays. Patch
|
||||
* appends a dev-only localhost entry. Covers:
|
||||
* - Monorepo helpers with additional*Src options
|
||||
* (e.g. createBaseNextConfig for Next)
|
||||
* - SvelteKit kit.csp.directives
|
||||
* - nuxt-security module's contentSecurityPolicy
|
||||
* - "append-string": CSP built as a literal value string. Patch splices
|
||||
* a dev-only token into script-src and connect-src.
|
||||
* Covers:
|
||||
* - Inline Next.js headers() with CSP string
|
||||
* - Nuxt routeRules / nitro.routeRules CSP headers
|
||||
* - "middleware": CSP set dynamically in middleware.{ts,js}.
|
||||
* Detected but not auto-patched in v1.
|
||||
* - "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.
|
||||
*/
|
||||
|
||||
@@ -43,19 +47,35 @@ 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 = [
|
||||
// append-arrays signals: CSP expressed as structured directive arrays
|
||||
const MONOREPO_HELPER_SIGNALS = [
|
||||
/\bbuildCSPConfig\b/,
|
||||
/\bbuildSecurityHeaders\b/,
|
||||
/\badditionalScriptSrc\b/,
|
||||
/\badditionalConnectSrc\b/,
|
||||
/\bcreateBaseNextConfig\b/,
|
||||
];
|
||||
const SVELTEKIT_CSP_SIGNALS = [
|
||||
/\bkit\s*:/,
|
||||
/\bcsp\s*:/,
|
||||
/\bdirectives\s*:/,
|
||||
];
|
||||
const NUXT_SECURITY_SIGNALS = [
|
||||
/['"]nuxt-security['"]/,
|
||||
/\bcontentSecurityPolicy\b/,
|
||||
];
|
||||
|
||||
// append-string signals: CSP written as a literal value string
|
||||
const INLINE_HEADER_SIGNALS = [
|
||||
/["']Content-Security-Policy["']/i,
|
||||
/\bscript-src\b/,
|
||||
/\bconnect-src\b/,
|
||||
];
|
||||
const NUXT_ROUTE_RULES_SIGNALS = [
|
||||
/\brouteRules\b/,
|
||||
/Content-Security-Policy/i,
|
||||
/\bscript-src\b/,
|
||||
];
|
||||
|
||||
const MIDDLEWARE_HINT = /headers\.set\(\s*["']Content-Security-Policy["']/i;
|
||||
const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
@@ -65,67 +85,78 @@ const META_TAG_HINT = /http-equiv\s*=\s*["']Content-Security-Policy["']/i;
|
||||
* @returns {{ shape: string|null, signals: string[] }}
|
||||
*/
|
||||
export function detectCsp(cwd = process.cwd()) {
|
||||
const hits = { sharedHelper: [], inlineHeader: [], middleware: [], metaTag: [] };
|
||||
const hits = { appendArrays: [], appendString: [], middleware: [], metaTag: [] };
|
||||
|
||||
walk(cwd, cwd, 0, (absPath, relPath, body) => {
|
||||
const ext = path.extname(absPath);
|
||||
const base = path.basename(absPath).toLowerCase();
|
||||
const isConfig = (name) =>
|
||||
new RegExp('(^|/)' + name + '\\.config\\.').test(relPath);
|
||||
|
||||
// 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);
|
||||
}
|
||||
// === append-arrays candidates ===
|
||||
|
||||
// Monorepo CSP helper: packages/*/src/.../(config|security)/*
|
||||
if (SCAN_EXTS.has(ext) &&
|
||||
/packages\/[^/]+\/src\/.*(config|next-config|security)/.test(relPath) &&
|
||||
MONOREPO_HELPER_SIGNALS.some((re) => re.test(body))) {
|
||||
hits.appendArrays.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
// SvelteKit kit.csp.directives
|
||||
if (SCAN_EXTS.has(ext) && isConfig('svelte') &&
|
||||
SVELTEKIT_CSP_SIGNALS.every((re) => re.test(body))) {
|
||||
hits.appendArrays.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Middleware CSP: middleware.{ts,js} at project root or app/
|
||||
// Nuxt nuxt-security module
|
||||
if (SCAN_EXTS.has(ext) && isConfig('nuxt') &&
|
||||
NUXT_SECURITY_SIGNALS.every((re) => re.test(body))) {
|
||||
hits.appendArrays.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// === append-string candidates ===
|
||||
|
||||
// Inline headers in Next/Nuxt/SvelteKit/Astro/Vite config
|
||||
if (SCAN_EXTS.has(ext) &&
|
||||
/(^|\/)(next|nuxt|vite|astro|svelte)\.config\./.test(relPath) &&
|
||||
INLINE_HEADER_SIGNALS.every((re) => re.test(body))) {
|
||||
// Nuxt routeRules is a sub-shape of append-string; we already covered
|
||||
// nuxt-security above via return, so any remaining Nuxt CSP match here
|
||||
// is a route-rules / inline-headers case. Either way, same patch
|
||||
// mechanism.
|
||||
hits.appendString.push(relPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// === detect-only shapes ===
|
||||
|
||||
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,
|
||||
};
|
||||
// Priority: append-arrays > append-string > middleware > meta-tag.
|
||||
// Structured patches are safer than string splices; runtime and HTML
|
||||
// injection patches are less reliable and v1 doesn't auto-apply them.
|
||||
if (hits.appendArrays.length > 0) {
|
||||
return { shape: 'append-arrays', signals: hits.appendArrays };
|
||||
}
|
||||
if (hits.inlineHeader.length > 0) {
|
||||
return {
|
||||
shape: 'inline-headers',
|
||||
signals: hits.inlineHeader,
|
||||
};
|
||||
if (hits.appendString.length > 0) {
|
||||
return { shape: 'append-string', signals: hits.appendString };
|
||||
}
|
||||
if (hits.middleware.length > 0) {
|
||||
return {
|
||||
shape: 'middleware',
|
||||
signals: hits.middleware,
|
||||
};
|
||||
return { shape: 'middleware', signals: hits.middleware };
|
||||
}
|
||||
if (hits.metaTag.length > 0) {
|
||||
return {
|
||||
shape: 'meta-tag',
|
||||
signals: hits.metaTag,
|
||||
};
|
||||
return { shape: 'meta-tag', signals: hits.metaTag };
|
||||
}
|
||||
return { shape: null, signals: [] };
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
removeDeprecatedSkills,
|
||||
cleanSkillsLock,
|
||||
cleanup,
|
||||
loadLock,
|
||||
} from '../source/skills/impeccable/scripts/cleanup-deprecated.mjs';
|
||||
|
||||
function makeTmpDir() {
|
||||
|
||||
@@ -47,7 +47,9 @@ The `expectedAfter` file lives alongside `fixture.json` (not inside `files/`) an
|
||||
| `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). |
|
||||
| `nextjs-turborepo/` | Monorepo with shared CSP helper (`createBaseNextConfig`). CSP shape `append-arrays`. |
|
||||
| `nextjs-inline-csp/` | App-level `next.config.js` with a literal CSP string. CSP shape `append-string`. |
|
||||
| `sveltekit-csp/` | SvelteKit `kit.csp.directives` in `svelte.config.js`. CSP shape `append-arrays`. |
|
||||
| `nuxt-csp/` | Nuxt `routeRules` with literal CSP header in `nuxt.config.ts`. CSP shape `append-string`. |
|
||||
|
||||
Add new fixtures by cloning a directory, swapping files, and updating `fixture.json`.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"generatedFiles": [],
|
||||
"wrapCases": [],
|
||||
"csp": {
|
||||
"shape": "inline-headers",
|
||||
"shape": "append-string",
|
||||
"signals": [
|
||||
"next.config.js:Content-Security-Policy",
|
||||
"next.config.js:script-src",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
}
|
||||
],
|
||||
"csp": {
|
||||
"shape": "shared-helper",
|
||||
"shape": "append-arrays",
|
||||
"signals": [
|
||||
"packages/shared/src/next-config/index.ts:buildCSPConfig",
|
||||
"packages/shared/src/next-config/index.ts:additionalScriptSrc",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Reference output for agent/human review — not executed by tests.
|
||||
// After the append-string CSP patch is applied, nuxt.config.ts should look
|
||||
// like this.
|
||||
|
||||
// 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' : '';
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-01-01',
|
||||
devtools: { enabled: true },
|
||||
routeRules: {
|
||||
'/**': {
|
||||
headers: {
|
||||
'Content-Security-Policy':
|
||||
"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';",
|
||||
'X-Frame-Options': 'SAMEORIGIN',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Nuxt CSP Fixture</title>
|
||||
</head>
|
||||
<body>
|
||||
<NuxtPage />
|
||||
</body>
|
||||
</html>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
// Nuxt 3 config with CSP applied via routeRules headers.
|
||||
// Representative of the "append-string" shape: CSP is a literal value string.
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-01-01',
|
||||
devtools: { enabled: true },
|
||||
routeRules: {
|
||||
'/**': {
|
||||
headers: {
|
||||
'Content-Security-Policy':
|
||||
"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';",
|
||||
'X-Frame-Options': 'SAMEORIGIN',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<main class="page">
|
||||
<h1 class="hero-title">Nuxt CSP Fixture</h1>
|
||||
<p class="hero-hook">Minimal Nuxt page for live-mode + CSP tests.</p>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "Nuxt 3 (routeRules CSP)",
|
||||
"config": {
|
||||
"files": ["app.vue"],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html"
|
||||
},
|
||||
"sourceFiles": ["nuxt.config.ts", "app.vue", "pages/index.vue"],
|
||||
"generatedFiles": [],
|
||||
"wrapCases": [],
|
||||
"csp": {
|
||||
"shape": "append-string",
|
||||
"signals": ["nuxt.config.ts:Content-Security-Policy"],
|
||||
"patchTarget": "nuxt.config.ts",
|
||||
"expectedAfter": "expected-after-patch.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.nuxt/
|
||||
.output/
|
||||
dist/
|
||||
@@ -0,0 +1,32 @@
|
||||
// Reference output for agent/human review — not executed by tests.
|
||||
// After the append-arrays CSP patch is applied, svelte.config.js should look
|
||||
// like this.
|
||||
|
||||
import adapter from '@sveltejs/adapter-auto';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
// Dev-only allowance so impeccable live mode can load. Empty array in any
|
||||
// non-development environment.
|
||||
const __impeccableLiveDev =
|
||||
process.env.NODE_ENV === 'development' ? ['http://localhost:8400'] : [];
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter(),
|
||||
csp: {
|
||||
mode: 'auto',
|
||||
directives: {
|
||||
'default-src': ['self'],
|
||||
'script-src': ['self', 'unsafe-inline', ...__impeccableLiveDev],
|
||||
'style-src': ['self', 'unsafe-inline'],
|
||||
'img-src': ['self', 'data:', 'blob:'],
|
||||
'connect-src': ['self', ...__impeccableLiveDev],
|
||||
'frame-ancestors': ['self'],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>SvelteKit CSP Fixture</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,4 @@
|
||||
<main class="page">
|
||||
<h1 class="hero-title">SvelteKit CSP Fixture</h1>
|
||||
<p class="hero-hook">Minimal SvelteKit route for live-mode + CSP tests.</p>
|
||||
</main>
|
||||
@@ -0,0 +1,23 @@
|
||||
import adapter from '@sveltejs/adapter-auto';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter(),
|
||||
csp: {
|
||||
mode: 'auto',
|
||||
directives: {
|
||||
'default-src': ['self'],
|
||||
'script-src': ['self', 'unsafe-inline'],
|
||||
'style-src': ['self', 'unsafe-inline'],
|
||||
'img-src': ['self', 'data:', 'blob:'],
|
||||
'connect-src': ['self'],
|
||||
'frame-ancestors': ['self'],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "SvelteKit (kit.csp.directives)",
|
||||
"config": {
|
||||
"files": ["src/app.html"],
|
||||
"insertBefore": "</body>",
|
||||
"commentSyntax": "html"
|
||||
},
|
||||
"sourceFiles": ["svelte.config.js", "src/app.html", "src/routes/+page.svelte"],
|
||||
"generatedFiles": [],
|
||||
"wrapCases": [
|
||||
{
|
||||
"name": "wraps hero title in route source",
|
||||
"args": { "classes": "hero-title", "tag": "h1" },
|
||||
"expectedFile": "src/routes/+page.svelte"
|
||||
}
|
||||
],
|
||||
"csp": {
|
||||
"shape": "append-arrays",
|
||||
"signals": ["svelte.config.js:kit.csp.directives"],
|
||||
"patchTarget": "svelte.config.js",
|
||||
"expectedAfter": "expected-after-patch.js"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.svelte-kit/
|
||||
build/
|
||||
Reference in New Issue
Block a user