fix(live): switch live-poll to execFileSync, validate ids strictly (#124)

* fix(live): switch live-poll to execFileSync, validate ids strictly

live-poll.mjs built the live-accept invocation with execSync and string
interpolation of event.id and event.variantId. Both fields originate in
the browser; validateEvent only checked truthiness, so shell metacharacters
in either field would land in the shell-parsed command.

Real exploitability is gated by the per-session token (loopback only,
unguessable UUID), so risk is low. The construction itself is structurally
unsafe though, and the fix is small.

- live-poll.mjs: execSync(string) → execFileSync('node', argv). Drops the
  hand-rolled single-quote wrap for --param-values; execFileSync passes
  each arg as a discrete argv slot, no shell parsing.
- live-server.mjs validateEvent: tighten id and variantId to match the
  actual generator shapes (8 hex chars and 1-3 digit numeric strings).
  Defense in depth so any value reaching downstream code is inert by
  construction.
- live-server.test.mjs: add three regression tests covering accept/discard
  rejection of shell-metachar ids and non-numeric variantIds. Update the
  three existing fixture ids to match the new pattern.

Reported in #122.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: refresh pnpm-lock.yaml to match package.json

Cloudflare Pages runs pnpm install --frozen-lockfile and was failing on
ERR_PNPM_OUTDATED_LOCKFILE: the lockfile was missing entries for
@ai-sdk/anthropic, @ai-sdk/openai, @anthropic-ai/claude-agent-sdk,
@anthropic-ai/sdk, @google/genai, ai, modern-screenshot, zod, and had
stale specifiers for jsdom, marked, playwright, wrangler, puppeteer.

Drift was introduced when package.json was last edited without a lockfile
regen. Running pnpm install --lockfile-only resolves it; verified with
pnpm install --frozen-lockfile (clean install succeeds).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Paul Bakaus <paulbakaus@pauls-mbp-3.lan>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-04-28 19:00:04 -07:00
committed by GitHub
co-authored by Paul Bakaus Claude Opus 4.7
parent 7e0ce5e6b1
commit 9a5d0e71a9
36 changed files with 1967 additions and 286 deletions
@@ -8,7 +8,7 @@
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import { execSync } from 'node:child_process';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
@@ -146,13 +146,12 @@ Options:
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
// Pass through a JSON blob; the shell-safe wrap uses single quotes because
// values are finite {id, number|string|boolean} pairs from a validated payload.
scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
scriptArgs.push('--param-values', JSON.stringify(event.paramValues));
}
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
const out = execFileSync(
'node',
[acceptScript, ...scriptArgs],
{ encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 }
);
event._acceptResult = JSON.parse(out.trim());
@@ -131,11 +131,21 @@ const VISUAL_ACTIONS = [
'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
];
// Browser generates ids via crypto.randomUUID().slice(0, 8) (8 hex chars)
// and variantIds via String(small integer). Restrict to those shapes so
// any value that reaches a downstream child_process or DOM selector is
// inert by construction.
const ID_PATTERN = /^[0-9a-f]{8}$/;
const VARIANT_ID_PATTERN = /^[0-9]{1,3}$/;
function isValidId(v) { return typeof v === 'string' && ID_PATTERN.test(v); }
function isValidVariantId(v) { return typeof v === 'string' && VARIANT_ID_PATTERN.test(v); }
function validateEvent(msg) {
if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
switch (msg.type) {
case 'generate':
if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id';
if (!isValidId(msg.id)) return 'generate: missing or malformed id';
if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
@@ -145,8 +155,8 @@ function validateEvent(msg) {
if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array';
return null;
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
if (!isValidId(msg.id)) return 'accept: missing or malformed id';
if (!isValidVariantId(msg.variantId)) return 'accept: missing or malformed variantId';
if (msg.paramValues !== undefined) {
if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
return 'accept: paramValues must be an object';
@@ -154,7 +164,7 @@ function validateEvent(msg) {
}
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
return isValidId(msg.id) ? null : 'discard: missing or malformed id';
case 'exit':
return null;
case 'prefetch':
@@ -8,7 +8,7 @@
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import { execSync } from 'node:child_process';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
@@ -146,13 +146,12 @@ Options:
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
// Pass through a JSON blob; the shell-safe wrap uses single quotes because
// values are finite {id, number|string|boolean} pairs from a validated payload.
scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
scriptArgs.push('--param-values', JSON.stringify(event.paramValues));
}
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
const out = execFileSync(
'node',
[acceptScript, ...scriptArgs],
{ encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 }
);
event._acceptResult = JSON.parse(out.trim());
@@ -131,11 +131,21 @@ const VISUAL_ACTIONS = [
'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
];
// Browser generates ids via crypto.randomUUID().slice(0, 8) (8 hex chars)
// and variantIds via String(small integer). Restrict to those shapes so
// any value that reaches a downstream child_process or DOM selector is
// inert by construction.
const ID_PATTERN = /^[0-9a-f]{8}$/;
const VARIANT_ID_PATTERN = /^[0-9]{1,3}$/;
function isValidId(v) { return typeof v === 'string' && ID_PATTERN.test(v); }
function isValidVariantId(v) { return typeof v === 'string' && VARIANT_ID_PATTERN.test(v); }
function validateEvent(msg) {
if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
switch (msg.type) {
case 'generate':
if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id';
if (!isValidId(msg.id)) return 'generate: missing or malformed id';
if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
@@ -145,8 +155,8 @@ function validateEvent(msg) {
if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array';
return null;
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
if (!isValidId(msg.id)) return 'accept: missing or malformed id';
if (!isValidVariantId(msg.variantId)) return 'accept: missing or malformed variantId';
if (msg.paramValues !== undefined) {
if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
return 'accept: paramValues must be an object';
@@ -154,7 +164,7 @@ function validateEvent(msg) {
}
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
return isValidId(msg.id) ? null : 'discard: missing or malformed id';
case 'exit':
return null;
case 'prefetch':
@@ -8,7 +8,7 @@
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import { execSync } from 'node:child_process';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
@@ -146,13 +146,12 @@ Options:
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
// Pass through a JSON blob; the shell-safe wrap uses single quotes because
// values are finite {id, number|string|boolean} pairs from a validated payload.
scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
scriptArgs.push('--param-values', JSON.stringify(event.paramValues));
}
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
const out = execFileSync(
'node',
[acceptScript, ...scriptArgs],
{ encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 }
);
event._acceptResult = JSON.parse(out.trim());
@@ -131,11 +131,21 @@ const VISUAL_ACTIONS = [
'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
];
// Browser generates ids via crypto.randomUUID().slice(0, 8) (8 hex chars)
// and variantIds via String(small integer). Restrict to those shapes so
// any value that reaches a downstream child_process or DOM selector is
// inert by construction.
const ID_PATTERN = /^[0-9a-f]{8}$/;
const VARIANT_ID_PATTERN = /^[0-9]{1,3}$/;
function isValidId(v) { return typeof v === 'string' && ID_PATTERN.test(v); }
function isValidVariantId(v) { return typeof v === 'string' && VARIANT_ID_PATTERN.test(v); }
function validateEvent(msg) {
if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
switch (msg.type) {
case 'generate':
if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id';
if (!isValidId(msg.id)) return 'generate: missing or malformed id';
if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
@@ -145,8 +155,8 @@ function validateEvent(msg) {
if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array';
return null;
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
if (!isValidId(msg.id)) return 'accept: missing or malformed id';
if (!isValidVariantId(msg.variantId)) return 'accept: missing or malformed variantId';
if (msg.paramValues !== undefined) {
if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
return 'accept: paramValues must be an object';
@@ -154,7 +164,7 @@ function validateEvent(msg) {
}
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
return isValidId(msg.id) ? null : 'discard: missing or malformed id';
case 'exit':
return null;
case 'prefetch':
@@ -8,7 +8,7 @@
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import { execSync } from 'node:child_process';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
@@ -146,13 +146,12 @@ Options:
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
// Pass through a JSON blob; the shell-safe wrap uses single quotes because
// values are finite {id, number|string|boolean} pairs from a validated payload.
scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
scriptArgs.push('--param-values', JSON.stringify(event.paramValues));
}
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
const out = execFileSync(
'node',
[acceptScript, ...scriptArgs],
{ encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 }
);
event._acceptResult = JSON.parse(out.trim());
@@ -131,11 +131,21 @@ const VISUAL_ACTIONS = [
'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
];
// Browser generates ids via crypto.randomUUID().slice(0, 8) (8 hex chars)
// and variantIds via String(small integer). Restrict to those shapes so
// any value that reaches a downstream child_process or DOM selector is
// inert by construction.
const ID_PATTERN = /^[0-9a-f]{8}$/;
const VARIANT_ID_PATTERN = /^[0-9]{1,3}$/;
function isValidId(v) { return typeof v === 'string' && ID_PATTERN.test(v); }
function isValidVariantId(v) { return typeof v === 'string' && VARIANT_ID_PATTERN.test(v); }
function validateEvent(msg) {
if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
switch (msg.type) {
case 'generate':
if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id';
if (!isValidId(msg.id)) return 'generate: missing or malformed id';
if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
@@ -145,8 +155,8 @@ function validateEvent(msg) {
if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array';
return null;
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
if (!isValidId(msg.id)) return 'accept: missing or malformed id';
if (!isValidVariantId(msg.variantId)) return 'accept: missing or malformed variantId';
if (msg.paramValues !== undefined) {
if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
return 'accept: paramValues must be an object';
@@ -154,7 +164,7 @@ function validateEvent(msg) {
}
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
return isValidId(msg.id) ? null : 'discard: missing or malformed id';
case 'exit':
return null;
case 'prefetch':
@@ -8,7 +8,7 @@
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import { execSync } from 'node:child_process';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
@@ -146,13 +146,12 @@ Options:
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
// Pass through a JSON blob; the shell-safe wrap uses single quotes because
// values are finite {id, number|string|boolean} pairs from a validated payload.
scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
scriptArgs.push('--param-values', JSON.stringify(event.paramValues));
}
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
const out = execFileSync(
'node',
[acceptScript, ...scriptArgs],
{ encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 }
);
event._acceptResult = JSON.parse(out.trim());
@@ -131,11 +131,21 @@ const VISUAL_ACTIONS = [
'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
];
// Browser generates ids via crypto.randomUUID().slice(0, 8) (8 hex chars)
// and variantIds via String(small integer). Restrict to those shapes so
// any value that reaches a downstream child_process or DOM selector is
// inert by construction.
const ID_PATTERN = /^[0-9a-f]{8}$/;
const VARIANT_ID_PATTERN = /^[0-9]{1,3}$/;
function isValidId(v) { return typeof v === 'string' && ID_PATTERN.test(v); }
function isValidVariantId(v) { return typeof v === 'string' && VARIANT_ID_PATTERN.test(v); }
function validateEvent(msg) {
if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
switch (msg.type) {
case 'generate':
if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id';
if (!isValidId(msg.id)) return 'generate: missing or malformed id';
if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
@@ -145,8 +155,8 @@ function validateEvent(msg) {
if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array';
return null;
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
if (!isValidId(msg.id)) return 'accept: missing or malformed id';
if (!isValidVariantId(msg.variantId)) return 'accept: missing or malformed variantId';
if (msg.paramValues !== undefined) {
if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
return 'accept: paramValues must be an object';
@@ -154,7 +164,7 @@ function validateEvent(msg) {
}
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
return isValidId(msg.id) ? null : 'discard: missing or malformed id';
case 'exit':
return null;
case 'prefetch':
@@ -8,7 +8,7 @@
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import { execSync } from 'node:child_process';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
@@ -146,13 +146,12 @@ Options:
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
// Pass through a JSON blob; the shell-safe wrap uses single quotes because
// values are finite {id, number|string|boolean} pairs from a validated payload.
scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
scriptArgs.push('--param-values', JSON.stringify(event.paramValues));
}
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
const out = execFileSync(
'node',
[acceptScript, ...scriptArgs],
{ encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 }
);
event._acceptResult = JSON.parse(out.trim());
@@ -131,11 +131,21 @@ const VISUAL_ACTIONS = [
'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
];
// Browser generates ids via crypto.randomUUID().slice(0, 8) (8 hex chars)
// and variantIds via String(small integer). Restrict to those shapes so
// any value that reaches a downstream child_process or DOM selector is
// inert by construction.
const ID_PATTERN = /^[0-9a-f]{8}$/;
const VARIANT_ID_PATTERN = /^[0-9]{1,3}$/;
function isValidId(v) { return typeof v === 'string' && ID_PATTERN.test(v); }
function isValidVariantId(v) { return typeof v === 'string' && VARIANT_ID_PATTERN.test(v); }
function validateEvent(msg) {
if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
switch (msg.type) {
case 'generate':
if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id';
if (!isValidId(msg.id)) return 'generate: missing or malformed id';
if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
@@ -145,8 +155,8 @@ function validateEvent(msg) {
if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array';
return null;
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
if (!isValidId(msg.id)) return 'accept: missing or malformed id';
if (!isValidVariantId(msg.variantId)) return 'accept: missing or malformed variantId';
if (msg.paramValues !== undefined) {
if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
return 'accept: paramValues must be an object';
@@ -154,7 +164,7 @@ function validateEvent(msg) {
}
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
return isValidId(msg.id) ? null : 'discard: missing or malformed id';
case 'exit':
return null;
case 'prefetch':
@@ -8,7 +8,7 @@
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import { execSync } from 'node:child_process';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
@@ -146,13 +146,12 @@ Options:
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
// Pass through a JSON blob; the shell-safe wrap uses single quotes because
// values are finite {id, number|string|boolean} pairs from a validated payload.
scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
scriptArgs.push('--param-values', JSON.stringify(event.paramValues));
}
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
const out = execFileSync(
'node',
[acceptScript, ...scriptArgs],
{ encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 }
);
event._acceptResult = JSON.parse(out.trim());
@@ -131,11 +131,21 @@ const VISUAL_ACTIONS = [
'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
];
// Browser generates ids via crypto.randomUUID().slice(0, 8) (8 hex chars)
// and variantIds via String(small integer). Restrict to those shapes so
// any value that reaches a downstream child_process or DOM selector is
// inert by construction.
const ID_PATTERN = /^[0-9a-f]{8}$/;
const VARIANT_ID_PATTERN = /^[0-9]{1,3}$/;
function isValidId(v) { return typeof v === 'string' && ID_PATTERN.test(v); }
function isValidVariantId(v) { return typeof v === 'string' && VARIANT_ID_PATTERN.test(v); }
function validateEvent(msg) {
if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
switch (msg.type) {
case 'generate':
if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id';
if (!isValidId(msg.id)) return 'generate: missing or malformed id';
if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
@@ -145,8 +155,8 @@ function validateEvent(msg) {
if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array';
return null;
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
if (!isValidId(msg.id)) return 'accept: missing or malformed id';
if (!isValidVariantId(msg.variantId)) return 'accept: missing or malformed variantId';
if (msg.paramValues !== undefined) {
if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
return 'accept: paramValues must be an object';
@@ -154,7 +164,7 @@ function validateEvent(msg) {
}
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
return isValidId(msg.id) ? null : 'discard: missing or malformed id';
case 'exit':
return null;
case 'prefetch':
+5 -6
View File
@@ -8,7 +8,7 @@
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import { execSync } from 'node:child_process';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
@@ -146,13 +146,12 @@ Options:
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
// Pass through a JSON blob; the shell-safe wrap uses single quotes because
// values are finite {id, number|string|boolean} pairs from a validated payload.
scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
scriptArgs.push('--param-values', JSON.stringify(event.paramValues));
}
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
const out = execFileSync(
'node',
[acceptScript, ...scriptArgs],
{ encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 }
);
event._acceptResult = JSON.parse(out.trim());
+14 -4
View File
@@ -131,11 +131,21 @@ const VISUAL_ACTIONS = [
'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
];
// Browser generates ids via crypto.randomUUID().slice(0, 8) (8 hex chars)
// and variantIds via String(small integer). Restrict to those shapes so
// any value that reaches a downstream child_process or DOM selector is
// inert by construction.
const ID_PATTERN = /^[0-9a-f]{8}$/;
const VARIANT_ID_PATTERN = /^[0-9]{1,3}$/;
function isValidId(v) { return typeof v === 'string' && ID_PATTERN.test(v); }
function isValidVariantId(v) { return typeof v === 'string' && VARIANT_ID_PATTERN.test(v); }
function validateEvent(msg) {
if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
switch (msg.type) {
case 'generate':
if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id';
if (!isValidId(msg.id)) return 'generate: missing or malformed id';
if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
@@ -145,8 +155,8 @@ function validateEvent(msg) {
if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array';
return null;
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
if (!isValidId(msg.id)) return 'accept: missing or malformed id';
if (!isValidVariantId(msg.variantId)) return 'accept: missing or malformed variantId';
if (msg.paramValues !== undefined) {
if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
return 'accept: paramValues must be an object';
@@ -154,7 +164,7 @@ function validateEvent(msg) {
}
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
return isValidId(msg.id) ? null : 'discard: missing or malformed id';
case 'exit':
return null;
case 'prefetch':
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: impeccable
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
version: 3.0.4
version: 3.0.5
user-invocable: true
argument-hint: "[craft|shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · teach|document|extract|live] [target]"
license: Apache 2.0. Based on Anthropic's frontend-design skill. See NOTICE.md for attribution.
+33 -1
View File
@@ -74,7 +74,7 @@ Reading annotations precisely:
### 2. Wrap the element
```bash
node .qoder/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
node .qoder/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET"
```
Flag mapping — keep them separate, don't collapse into `--query`:
@@ -82,9 +82,12 @@ Flag mapping — keep them separate, don't collapse into `--query`:
- `--element-id``event.element.id`
- `--classes``event.element.classes` joined with commas
- `--tag``event.element.tagName`
- `--text` ← first ~80 chars of `event.element.textContent` (trim, single-line). **Pass this every call.** When the picked element shares classes + tag with sibling components (a list of `<Card>`s, repeating sections), this is what disambiguates which branch in source to wrap. Without it, wrap silently lands on the first match and may rewrite the wrong element.
The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search. `--query` is a fallback for raw text search only — do not use it for normal element lookups.
If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"` — read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow.
Output on success: `{ file, insertLine, commentSyntax }`.
**Fallback errors.** Wrap only writes into files it judges to be source (tracked by git, not marked GENERATED, not listed in config's `generatedFiles`). If it can't land on a source file, it errors without writing — accepting a variant into a generated file is silent data loss. Three shapes:
@@ -181,6 +184,25 @@ The first variant has no `display: none` (visible by default). All others do. If
One edit, all variants — the browser's MutationObserver picks everything up in one pass.
**Author every `:scope` rule with a descendant combinator.** The `@scope` boundary is the **variant wrapper `<div data-impeccable-variant="N">`**, not the element you're designing. A bare `:scope { background: cream; }` styles the wrapper, not the inner replacement, so the cream lands on a `display: contents` shell while the actual element keeps page defaults. Always step in: `:scope > .card`, `:scope > section`, `:scope .hero-title`, etc. The fake test agent's CSS in `tests/live-e2e/agent.mjs` is a faithful template — every rule starts `:scope > ...`.
**JSX / TSX target files.** Wrap `<style>` content in a template literal so the CSS `{` / `}` aren't parsed as JSX expressions, and use `className=` / `style={{…}}` on every variant element. Keep `data-impeccable-*` attributes as-is — they're plain strings:
```tsx
<style data-impeccable-css="SESSION_ID">{`
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
`}</style>
<div data-impeccable-variant="1">
{/* variant 1 */}
</div>
<div data-impeccable-variant="2" style={{ display: 'none' }}>
{/* variant 2 */}
</div>
```
The wrap script already gives you a single-rooted JSX wrapper — a `<div data-impeccable-variants="…">` outer element with the marker comments tucked inside. Drop the variants block above into the "Variants: insert below this line" comment and the source stays valid TSX.
### 7. Parameters (composition-sized, 04 per variant)
Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against.
@@ -246,6 +268,16 @@ node .qoder/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --file
Then run `live-poll.mjs` again immediately.
### Aborting an in-flight session
If wrap or generation fails after the browser has flipped to GENERATING (e.g. wrap landed on the wrong source branch and you've already reverted it, or generation hit an unrecoverable error), tell the **browser** so its bar resets to PICKING:
```bash
node .qoder/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason"
```
Don't run `live-accept --discard` for this — that's a pure file mutator, the browser doesn't see it, and the bar gets stuck on the GENERATING dots forever (the user has to refresh). `--discard` is only correct when the **browser** initiated the discard (user clicked ✕ during CYCLING) and the agent is just running source-side cleanup the browser already triggered.
## Handle fallback
When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here.
+141 -11
View File
@@ -105,15 +105,22 @@ function handleDiscard(id, lines, targetFile) {
if (!block) return { handled: false, error: 'Markers not found' };
const original = extractOriginal(lines, block);
const indent = lines[block.start].match(/^(\s*)/)[1];
const isJsx = detectCommentSyntax(targetFile).open === '{/*';
const replaceRange = expandReplaceRange(block, lines, isJsx);
// De-indent the original content back to the marker's indentation level
// Restore at the line we're actually replacing FROM, not the marker line.
// For JSX wrappers the marker comments live INSIDE the outer `<div>`, so
// `block.start` sits 2 spaces deeper than the original element. Using that
// as the deindent base would push the restored content 2 spaces too far
// right on every JSX/TSX session. `replaceRange.start` is the outer wrapper
// line, which is at the original element's indent for both HTML and JSX.
const indent = lines[replaceRange.start].match(/^(\s*)/)[1];
const restored = deindentContent(original, indent);
const newLines = [
...lines.slice(0, block.start),
...lines.slice(0, replaceRange.start),
...restored,
...lines.slice(block.end + 1),
...lines.slice(replaceRange.end + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
return {};
@@ -127,8 +134,14 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
const indent = lines[block.start].match(/^(\s*)/)[1];
const commentSyntax = detectCommentSyntax(targetFile);
const isJsx = commentSyntax.open === '{/*';
// Anchor indent on the line we're replacing FROM (the outer wrapper),
// not on `block.start` — for JSX that's the marker comment 2 spaces
// deeper than the original element. See handleDiscard for the full
// rationale.
const replaceRange = expandReplaceRange(block, lines, isJsx);
const indent = lines[replaceRange.start].match(/^(\s*)/)[1];
// Extract the chosen variant's inner content
const variantContent = extractVariant(lines, block, variantNum);
@@ -149,7 +162,6 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
const replacement = [];
if (cssContent) {
const isJsx = commentSyntax.open === '{/*';
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
// JSX targets need the CSS body wrapped in a template literal so that the
// `{` and `}` in CSS rules don't get parsed as JSX expressions.
@@ -177,7 +189,6 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
// need the object form, otherwise React 19 throws "Failed to set indexed
// property [0] on CSSStyleDeclaration" while parsing the string char-by-char.
if (cssContent) {
const isJsx = commentSyntax.open === '{/*';
const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"';
replacement.push(indent + '<div data-impeccable-variant="' + variantNum + '" ' + styleAttr + '>');
replacement.push(...restored);
@@ -187,9 +198,9 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
}
const newLines = [
...lines.slice(0, block.start),
...lines.slice(0, replaceRange.start),
...replacement,
...lines.slice(block.end + 1),
...lines.slice(replaceRange.end + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
@@ -218,6 +229,72 @@ function findMarkerBlock(id, lines) {
return (start !== -1 && end !== -1) ? { start, end } : null;
}
/**
* Compute the line range to REPLACE (vs. just the marker range to extract
* from). For JSX/TSX wrappers, live-wrap places the marker comments INSIDE
* the `<div data-impeccable-variants="ID">` outer wrapper so the picked
* element's JSX slot keeps a single child a Fragment `<></>` would have
* solved the multi-sibling case but failed inside `asChild` / cloneElement
* parents with "Invalid prop supplied to React.Fragment".
*
* That means the marker block is enclosed by the wrapper `<div>` opener
* (with `data-impeccable-variants="ID"`) and its matching `</div>`. We
* walk back to the opener and forward to the closer so accept/discard
* remove the entire scaffold, not just the inner markers.
*
* Marker lines themselves stay where they were so extractOriginal /
* extractVariant / extractCss continue to walk the same range.
*/
function expandReplaceRange(block, lines, isJsx) {
if (!isJsx) return { start: block.start, end: block.end };
let { start, end } = block;
// Walk back for the wrapper `<div data-impeccable-variants="..."` opener.
// The attr may sit on a continuation line of a multi-line opening tag, so
// also walk to the line that actually contains `<div`.
for (let i = start - 1; i >= Math.max(0, start - 12); i--) {
if (/data-impeccable-variants=/.test(lines[i])) {
let opener = i;
while (opener > 0 && !/<div\b/.test(lines[opener])) opener--;
start = opener;
break;
}
}
// Walk forward to the matching `</div>` by div-depth tracking from the
// wrapper opener. Operate on JOINED text instead of per-line: a
// multi-line self-closing JSX `<div\n className="spacer"\n/>` would
// fool per-line regex tracking (the `<div` line matches openRe but the
// `/>` line never matches selfCloseRe since it needs `<div` on the same
// line). That left depth permanently over-counted and the wrapper's
// outer `</div>` orphaned after accept/discard. Single regex with
// `[^>]*?` (which spans newlines in JS) handles either form correctly.
const joined = lines.slice(start).join('\n');
// Match either `<div … />` (self-close, group 1 is `/`), `<div … >`
// (open, group 1 is empty), or `</div>`.
const tagRe = /<div\b[^>]*?(\/?)>|<\/div\s*>/g;
let depth = 0;
let m;
while ((m = tagRe.exec(joined)) !== null) {
const isClose = m[0].startsWith('</');
const isSelfClose = !isClose && m[1] === '/';
if (isClose) depth--;
else if (!isSelfClose) depth++;
if (depth <= 0) {
// m.index is offset within `joined`; convert back to a file line.
const linesBefore = joined.slice(0, m.index + m[0].length).split('\n').length - 1;
const candidateEnd = start + linesBefore;
if (candidateEnd >= end) {
end = candidateEnd;
break;
}
}
}
return { start, end };
}
/**
* Join wrapper lines into a single string with `<style>` elements removed so
* marker matching and div-depth tracking aren't confused by:
@@ -345,7 +422,7 @@ function extractCss(lines, block, id) {
// Same-line open + close: extract inner text.
const sameLine = line.match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/);
if (sameLine) {
const inner = sameLine[1];
const inner = stripJsxTemplateWrap(sameLine[1]);
return inner.length > 0 ? inner.split('\n') : null;
}
inStyle = true;
@@ -362,7 +439,60 @@ function extractCss(lines, block, id) {
}
}
return content.length > 0 ? content : null;
if (content.length === 0) return null;
return stripJsxTemplateLines(content);
}
/**
* Strip a JSX template-literal wrap (`{` `}`) from CSS extracted out of a
* `<style>` element in a JSX/TSX file. The agent may write the wrap with
* `{` and `}` directly attached to the `<style>` tags, on their own lines,
* or attached to the first/last CSS lines all three are JSX-legal.
*
* Stripping is required because handleAccept re-wraps the CSS itself when
* carbonizing. Without this, two consecutive accepts (or a previously-
* accepted variants block being carbonized) would produce nested
* `{` `{` `}` `}`, which oxc rejects with "Expected `}` but found `@`".
*/
function stripJsxTemplateLines(content) {
const out = content.slice();
// Drop any leading blank lines so we don't miss a `{` line buried below
// them; same for trailing.
while (out.length > 0 && out[0].trim() === '') out.shift();
while (out.length > 0 && out[out.length - 1].trim() === '') out.pop();
if (out.length === 0) return null;
// Leading `{`: own line, or attached to the first CSS line.
const firstTrim = out[0].trimStart();
if (firstTrim === '{`') {
out.shift();
} else if (firstTrim.startsWith('{`')) {
const idx = out[0].indexOf('{`');
out[0] = out[0].slice(0, idx) + out[0].slice(idx + 2);
if (out[0].trim() === '') out.shift();
}
if (out.length === 0) return null;
// Trailing `` ` `` `}`: own line, or attached to the last CSS line.
const lastIdx = out.length - 1;
const lastTrim = out[lastIdx].trimEnd();
if (lastTrim === '`}') {
out.pop();
} else if (lastTrim.endsWith('`}')) {
const text = out[lastIdx];
const idx = text.lastIndexOf('`}');
out[lastIdx] = text.slice(0, idx) + text.slice(idx + 2);
if (out[lastIdx].trim() === '') out.pop();
}
return out.length > 0 ? out : null;
}
function stripJsxTemplateWrap(text) {
const lines = text.split('\n');
const stripped = stripJsxTemplateLines(lines);
return stripped ? stripped.join('\n') : '';
}
/**
@@ -2622,11 +2622,15 @@
if (!isTransparentColor(cs.backgroundColor)) return cs.backgroundColor;
node = node.parentElement;
}
return (
getComputedStyle(document.body).backgroundColor ||
getComputedStyle(document.documentElement).backgroundColor ||
'#ffffff'
);
// The walk already passed through <body> and <html>; if they had been
// opaque we would have returned. Falling through with the previous
// `getComputedStyle(body).backgroundColor || …` chain is a trap: that
// call returns the literal string `"rgba(0, 0, 0, 0)"` for a page that
// never set its own bg, which is truthy and short-circuits the chain to
// transparent-black — modern-screenshot then renders the capture on a
// black canvas and the shader overlay flashes solid black during load.
// The browser canvas defaults to white, so we do too.
return '#ffffff';
}
// Capture the element (with current annotations baked in) and return a PNG
@@ -388,7 +388,16 @@ export function patchCspMeta(content, port) {
const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
// The tagRe captures any whitespace between the last attribute and the
// closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
// a replace would land it BEFORE that trailing space, leaving a double
// space inside attrs and clobbering the space before `/>`. Split off
// the trailing whitespace, splice the marker into the attribute body,
// and re-append the original trailing whitespace so a self-closing
// `<meta … />` round-trips byte-for-byte.
const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
const newTag = tag.full.replace(attrs, newAttrs);
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
@@ -8,7 +8,7 @@
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import { execSync } from 'node:child_process';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
@@ -146,13 +146,12 @@ Options:
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
// Pass through a JSON blob; the shell-safe wrap uses single quotes because
// values are finite {id, number|string|boolean} pairs from a validated payload.
scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
scriptArgs.push('--param-values', JSON.stringify(event.paramValues));
}
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
const out = execFileSync(
'node',
[acceptScript, ...scriptArgs],
{ encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 }
);
event._acceptResult = JSON.parse(out.trim());
@@ -131,11 +131,21 @@ const VISUAL_ACTIONS = [
'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
];
// Browser generates ids via crypto.randomUUID().slice(0, 8) (8 hex chars)
// and variantIds via String(small integer). Restrict to those shapes so
// any value that reaches a downstream child_process or DOM selector is
// inert by construction.
const ID_PATTERN = /^[0-9a-f]{8}$/;
const VARIANT_ID_PATTERN = /^[0-9]{1,3}$/;
function isValidId(v) { return typeof v === 'string' && ID_PATTERN.test(v); }
function isValidVariantId(v) { return typeof v === 'string' && VARIANT_ID_PATTERN.test(v); }
function validateEvent(msg) {
if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
switch (msg.type) {
case 'generate':
if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id';
if (!isValidId(msg.id)) return 'generate: missing or malformed id';
if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
@@ -145,8 +155,8 @@ function validateEvent(msg) {
if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array';
return null;
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
if (!isValidId(msg.id)) return 'accept: missing or malformed id';
if (!isValidVariantId(msg.variantId)) return 'accept: missing or malformed variantId';
if (msg.paramValues !== undefined) {
if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
return 'accept: paramValues must be an object';
@@ -154,7 +164,7 @@ function validateEvent(msg) {
}
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
return isValidId(msg.id) ? null : 'discard: missing or malformed id';
case 'exit':
return null;
case 'prefetch':
+193 -17
View File
@@ -37,6 +37,10 @@ Element identification (at least one required):
Optional:
--file PATH Source file to search in (skips auto-detection)
--text TEXT Picked element's textContent. Used to disambiguate when
classes/tag match multiple sibling elements (e.g. a list
of <Card>s with the same className). Pass the first ~80
chars of event.element.textContent.
--help Show this help message
Output (JSON):
@@ -53,6 +57,7 @@ The agent should insert variant HTML at insertLine.`);
const tag = argVal(args, '--tag');
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
@@ -115,17 +120,67 @@ The agent should insert variant HTML at insertLine.`);
const content = fs.readFileSync(targetFile, 'utf-8');
const lines = content.split('\n');
// Find the element, trying each query in priority order.
// Pass tag hint so findElement can reject matches inside wrong element types
// and walk backward to the real opener on multi-line JSX tags.
// Find the element, trying each query in priority order. When `--text` is
// supplied, collect every candidate the queries surface and disambiguate
// by the picked element's textContent. Without `--text`, fall back to the
// legacy first-match behavior so unmodified callers keep working.
let match = null;
for (const q of queries) {
match = findElement(lines, q, tag);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
if (text) {
const candidates = [];
for (const q of queries) {
const all = findAllElements(lines, q, tag);
for (const c of all) {
if (!candidates.some((x) => x.startLine === c.startLine)) {
candidates.push(c);
}
}
// Once a more-specific query (ID, full className combo) yielded a unique
// result, stop — falling through to the loose tag+single-class query
// would readmit the siblings we just disambiguated past.
if (candidates.length === 1) break;
}
if (candidates.length === 0) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
if (candidates.length === 1) {
match = candidates[0];
} else {
const filtered = filterByText(candidates, lines, text);
if (filtered.length === 1) {
match = filtered[0];
} else if (filtered.length === 0) {
// Source uses dynamic content (`<h1>{title}</h1>` etc.) so the
// browser-side textContent doesn't appear literally in source. Fall
// back to first-match rather than refusing — this is the same
// behavior unmodified callers see, just preserved.
match = candidates[0];
} else {
// Multiple candidates ALSO match the text. Truly ambiguous — refuse
// rather than pick wrong, and hand the agent the candidate locations
// so it can disambiguate by reading the file.
console.error(JSON.stringify({
error: 'element_ambiguous',
fallback: 'agent-driven',
file: path.relative(process.cwd(), targetFile),
candidates: filtered.map((c) => ({
startLine: c.startLine + 1,
endLine: c.endLine + 1,
})),
hint: 'Multiple source elements match both classes/tag and textContent. Pass --element-id, a more specific --text, or write the wrapper manually. See "Handle fallback" in live.md.',
}));
process.exit(1);
}
}
} else {
for (const q of queries) {
match = findElement(lines, q, tag);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
}
const { startLine, endLine } = match;
@@ -133,17 +188,48 @@ The agent should insert variant HTML at insertLine.`);
const isJsx = commentSyntax.open === '{/*';
const indent = lines[startLine].match(/^(\s*)/)[1];
// Extract the original element
// Extract the original element. Reindent under the wrapper while preserving
// the relative depth between lines — `l.trimStart()` would strip ALL leading
// whitespace and collapse e.g. `<aside>`/` <h1>`/`</aside>` (6/8/6 spaces)
// to a single uniform indent, so on accept/discard the round-trip restores
// the inner element at its parent's depth instead of nested inside it.
// Strip only the COMMON minimum leading whitespace across the picked lines;
// `deindentContent` on the accept side already mirrors this convention.
const originalLines = lines.slice(startLine, endLine + 1);
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
const originalBaseIndent = minLeadingSpaces(originalLines);
const reindentOriginal = (extra) => originalLines
.map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent)))
.join('\n');
const originalIndented = reindentOriginal(' ');
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
// JSX requires object-literal style and parses string attrs as HTML (which
// either type-errors or renders a literal CSS string).
const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';
// Build the wrapper
const wrapperLines = [
// JSX/TSX guard: the picked element occupies a single JSX child slot
// (inside `return (...)`, an array `.map(...)`, an `asChild` branch, or
// any other expression position). Replacing it with `comment + <div> +
// comment` yields three adjacent siblings — invalid JSX. We can't use a
// Fragment `<></>` either: parents that clone children (Radix `asChild`,
// Headless UI, etc.) hit "Invalid prop supplied to React.Fragment" when
// they try to pass an `id` through.
//
// Solution: keep the wrapper `<div>` as the single JSX-slot child and
// tuck both marker comments INSIDE it. accept/discard then expands its
// replacement range to include the wrapper's `<div>` open / close lines
// so the entire scaffold gets removed cleanly.
const wrapperLines = isJsx ? [
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
indent + ' ' + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
indent + ' <div data-impeccable-variant="original">',
reindentOriginal(' '),
indent + ' </div>',
indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
indent + ' ' + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
indent + '</div>',
] : [
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
@@ -163,13 +249,24 @@ The agent should insert variant HTML at insertLine.`);
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment)
const insertLine = startLine + 6; // 0-indexed in the new file
// Calculate insert line (the "insert below this line" comment).
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above
// the insert marker (HTML: start-comment + outer-div + Original-comment +
// original-div + content + close-original-div; JSX: outer-div +
// start-comment + Original-comment + original-div + content +
// close-original-div). Multi-line originals push the marker by their
// extra line count.
const insertLine = startLine + 6 + (originalLines.length - 1);
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
// wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that
// spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
@@ -310,6 +407,22 @@ const OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/;
* line to find the actual tag opener. When `tag` is provided, opener candidates
* must match that tag name.
*/
/**
* Return the smallest leading-whitespace count across a set of lines,
* ignoring blank lines (whose indent isn't load-bearing). Used to compute
* the common base indent of a multi-line picked element so reindenting
* under the wrapper preserves the relative depth between lines.
*/
function minLeadingSpaces(lines) {
let min = Infinity;
for (const l of lines) {
if (l.trim() === '') continue;
const m = l.match(/^(\s*)/);
if (m && m[1].length < min) min = m[1].length;
}
return min === Infinity ? 0 : min;
}
function findElement(lines, query, tag = null) {
// Iterate all matches — the first substring hit isn't always the right one.
for (let i = 0; i < lines.length; i++) {
@@ -330,6 +443,69 @@ function findElement(lines, query, tag = null) {
return null;
}
/**
* Like findElement, but returns every match. Used for ambiguity detection
* when the agent passes --text: when the same className appears on multiple
* sibling elements (a list of cards, repeated section variants, etc.),
* first-match silently lands on the wrong branch. Returning all matches lets
* the caller narrow by textContent or fail with a structured ambiguity error.
*/
function findAllElements(lines, query, tag = null) {
const out = [];
const seen = new Set();
for (let i = 0; i < lines.length; i++) {
if (!lines[i].includes(query)) continue;
const stripped = lines[i].trim();
if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;
if (lines[i].includes('data-impeccable-variant')) continue;
const openerLine = findOpenerLine(lines, i, tag);
if (openerLine === -1) continue;
if (seen.has(openerLine)) continue; // multiple matches inside the same element
seen.add(openerLine);
const endLine = findClosingLine(lines, openerLine);
out.push({ startLine: openerLine, endLine });
}
return out;
}
/**
* Narrow a candidate set to those whose source body matches a meaningful
* prefix of the picked element's textContent. The compare strips tags and
* JSX expressions, then checks two whitespace normalizations side-by-side:
*
* - single-space ("hero two second card body")
* - no-whitespace ("herotwosecondcardbody")
*
* Both are needed because `el.textContent` concatenates sibling text without
* inserting whitespace (e.g. `<h1>Hero Two</h1><p>Second…</p>` reads as
* `"Hero TwoSecond…"`), while the source has whitespace between tags. If
* EITHER normalization matches, the candidate keeps. A snippet shorter than
* 8 chars after stripping is too weak to disambiguate the caller falls
* back to first-match.
*/
function filterByText(candidates, lines, text) {
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
// Too short to disambiguate. Return [] so the caller's `filtered.length
// === 0` branch fires (fall back to first-match) — the previous
// `candidates.slice()` return forced `filtered.length > 1` and surfaced
// a spurious `element_ambiguous` error on every short-text picker event
// with multiple candidates.
if (trimmed.length < 8) return [];
const targetSpaced = trimmed;
const targetCompact = trimmed.replace(/\s+/g, '');
return candidates.filter((c) => {
const body = lines.slice(c.startLine, c.endLine + 1).join(' ');
const inner = body
.replace(/<[^>]*>/g, ' ') // strip HTML/JSX tags
.replace(/\{[^}]*\}/g, ' ') // strip JSX expressions
.toLowerCase();
const sourceSpaced = inner.replace(/\s+/g, ' ').trim();
const sourceCompact = inner.replace(/\s+/g, '');
return sourceSpaced.includes(targetSpaced) || sourceCompact.includes(targetCompact);
});
}
/**
* Resolve a match line to the real tag opener. If the match line itself opens
* a tag, return it. Otherwise walk up to 10 lines backward looking for the
@@ -8,7 +8,7 @@
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import { execSync } from 'node:child_process';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
@@ -146,13 +146,12 @@ Options:
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
// Pass through a JSON blob; the shell-safe wrap uses single quotes because
// values are finite {id, number|string|boolean} pairs from a validated payload.
scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
scriptArgs.push('--param-values', JSON.stringify(event.paramValues));
}
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
const out = execFileSync(
'node',
[acceptScript, ...scriptArgs],
{ encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 }
);
event._acceptResult = JSON.parse(out.trim());
@@ -131,11 +131,21 @@ const VISUAL_ACTIONS = [
'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
];
// Browser generates ids via crypto.randomUUID().slice(0, 8) (8 hex chars)
// and variantIds via String(small integer). Restrict to those shapes so
// any value that reaches a downstream child_process or DOM selector is
// inert by construction.
const ID_PATTERN = /^[0-9a-f]{8}$/;
const VARIANT_ID_PATTERN = /^[0-9]{1,3}$/;
function isValidId(v) { return typeof v === 'string' && ID_PATTERN.test(v); }
function isValidVariantId(v) { return typeof v === 'string' && VARIANT_ID_PATTERN.test(v); }
function validateEvent(msg) {
if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
switch (msg.type) {
case 'generate':
if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id';
if (!isValidId(msg.id)) return 'generate: missing or malformed id';
if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
@@ -145,8 +155,8 @@ function validateEvent(msg) {
if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array';
return null;
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
if (!isValidId(msg.id)) return 'accept: missing or malformed id';
if (!isValidVariantId(msg.variantId)) return 'accept: missing or malformed variantId';
if (msg.paramValues !== undefined) {
if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
return 'accept: paramValues must be an object';
@@ -154,7 +164,7 @@ function validateEvent(msg) {
}
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
return isValidId(msg.id) ? null : 'discard: missing or malformed id';
case 'exit':
return null;
case 'prefetch':
@@ -8,7 +8,7 @@
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import { execSync } from 'node:child_process';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
@@ -146,13 +146,12 @@ Options:
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
// Pass through a JSON blob; the shell-safe wrap uses single quotes because
// values are finite {id, number|string|boolean} pairs from a validated payload.
scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
scriptArgs.push('--param-values', JSON.stringify(event.paramValues));
}
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
const out = execFileSync(
'node',
[acceptScript, ...scriptArgs],
{ encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 }
);
event._acceptResult = JSON.parse(out.trim());
@@ -131,11 +131,21 @@ const VISUAL_ACTIONS = [
'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
];
// Browser generates ids via crypto.randomUUID().slice(0, 8) (8 hex chars)
// and variantIds via String(small integer). Restrict to those shapes so
// any value that reaches a downstream child_process or DOM selector is
// inert by construction.
const ID_PATTERN = /^[0-9a-f]{8}$/;
const VARIANT_ID_PATTERN = /^[0-9]{1,3}$/;
function isValidId(v) { return typeof v === 'string' && ID_PATTERN.test(v); }
function isValidVariantId(v) { return typeof v === 'string' && VARIANT_ID_PATTERN.test(v); }
function validateEvent(msg) {
if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
switch (msg.type) {
case 'generate':
if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id';
if (!isValidId(msg.id)) return 'generate: missing or malformed id';
if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
@@ -145,8 +155,8 @@ function validateEvent(msg) {
if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array';
return null;
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
if (!isValidId(msg.id)) return 'accept: missing or malformed id';
if (!isValidVariantId(msg.variantId)) return 'accept: missing or malformed variantId';
if (msg.paramValues !== undefined) {
if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
return 'accept: paramValues must be an object';
@@ -154,7 +164,7 @@ function validateEvent(msg) {
}
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
return isValidId(msg.id) ? null : 'discard: missing or malformed id';
case 'exit':
return null;
case 'prefetch':
@@ -8,7 +8,7 @@
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import { execSync } from 'node:child_process';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
@@ -146,13 +146,12 @@ Options:
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
// Pass through a JSON blob; the shell-safe wrap uses single quotes because
// values are finite {id, number|string|boolean} pairs from a validated payload.
scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
scriptArgs.push('--param-values', JSON.stringify(event.paramValues));
}
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
const out = execFileSync(
'node',
[acceptScript, ...scriptArgs],
{ encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 }
);
event._acceptResult = JSON.parse(out.trim());
@@ -131,11 +131,21 @@ const VISUAL_ACTIONS = [
'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
];
// Browser generates ids via crypto.randomUUID().slice(0, 8) (8 hex chars)
// and variantIds via String(small integer). Restrict to those shapes so
// any value that reaches a downstream child_process or DOM selector is
// inert by construction.
const ID_PATTERN = /^[0-9a-f]{8}$/;
const VARIANT_ID_PATTERN = /^[0-9]{1,3}$/;
function isValidId(v) { return typeof v === 'string' && ID_PATTERN.test(v); }
function isValidVariantId(v) { return typeof v === 'string' && VARIANT_ID_PATTERN.test(v); }
function validateEvent(msg) {
if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
switch (msg.type) {
case 'generate':
if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id';
if (!isValidId(msg.id)) return 'generate: missing or malformed id';
if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
@@ -145,8 +155,8 @@ function validateEvent(msg) {
if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array';
return null;
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
if (!isValidId(msg.id)) return 'accept: missing or malformed id';
if (!isValidVariantId(msg.variantId)) return 'accept: missing or malformed variantId';
if (msg.paramValues !== undefined) {
if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
return 'accept: paramValues must be an object';
@@ -154,7 +164,7 @@ function validateEvent(msg) {
}
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
return isValidId(msg.id) ? null : 'discard: missing or malformed id';
case 'exit':
return null;
case 'prefetch':
@@ -8,7 +8,7 @@
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import { execSync } from 'node:child_process';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
@@ -146,13 +146,12 @@ Options:
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
// Pass through a JSON blob; the shell-safe wrap uses single quotes because
// values are finite {id, number|string|boolean} pairs from a validated payload.
scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
scriptArgs.push('--param-values', JSON.stringify(event.paramValues));
}
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
const out = execFileSync(
'node',
[acceptScript, ...scriptArgs],
{ encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 }
);
event._acceptResult = JSON.parse(out.trim());
@@ -131,11 +131,21 @@ const VISUAL_ACTIONS = [
'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
];
// Browser generates ids via crypto.randomUUID().slice(0, 8) (8 hex chars)
// and variantIds via String(small integer). Restrict to those shapes so
// any value that reaches a downstream child_process or DOM selector is
// inert by construction.
const ID_PATTERN = /^[0-9a-f]{8}$/;
const VARIANT_ID_PATTERN = /^[0-9]{1,3}$/;
function isValidId(v) { return typeof v === 'string' && ID_PATTERN.test(v); }
function isValidVariantId(v) { return typeof v === 'string' && VARIANT_ID_PATTERN.test(v); }
function validateEvent(msg) {
if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
switch (msg.type) {
case 'generate':
if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id';
if (!isValidId(msg.id)) return 'generate: missing or malformed id';
if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
@@ -145,8 +155,8 @@ function validateEvent(msg) {
if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array';
return null;
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
if (!isValidId(msg.id)) return 'accept: missing or malformed id';
if (!isValidVariantId(msg.variantId)) return 'accept: missing or malformed variantId';
if (msg.paramValues !== undefined) {
if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
return 'accept: paramValues must be an object';
@@ -154,7 +164,7 @@ function validateEvent(msg) {
}
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
return isValidId(msg.id) ? null : 'discard: missing or malformed id';
case 'exit':
return null;
case 'prefetch':
+1263 -105
View File
File diff suppressed because it is too large Load Diff
@@ -8,7 +8,7 @@
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import { execSync } from 'node:child_process';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
@@ -146,13 +146,12 @@ Options:
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
// Pass through a JSON blob; the shell-safe wrap uses single quotes because
// values are finite {id, number|string|boolean} pairs from a validated payload.
scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
scriptArgs.push('--param-values', JSON.stringify(event.paramValues));
}
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
const out = execFileSync(
'node',
[acceptScript, ...scriptArgs],
{ encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 }
);
event._acceptResult = JSON.parse(out.trim());
@@ -131,11 +131,21 @@ const VISUAL_ACTIONS = [
'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
];
// Browser generates ids via crypto.randomUUID().slice(0, 8) (8 hex chars)
// and variantIds via String(small integer). Restrict to those shapes so
// any value that reaches a downstream child_process or DOM selector is
// inert by construction.
const ID_PATTERN = /^[0-9a-f]{8}$/;
const VARIANT_ID_PATTERN = /^[0-9]{1,3}$/;
function isValidId(v) { return typeof v === 'string' && ID_PATTERN.test(v); }
function isValidVariantId(v) { return typeof v === 'string' && VARIANT_ID_PATTERN.test(v); }
function validateEvent(msg) {
if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
switch (msg.type) {
case 'generate':
if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id';
if (!isValidId(msg.id)) return 'generate: missing or malformed id';
if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
@@ -145,8 +155,8 @@ function validateEvent(msg) {
if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array';
return null;
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
if (!isValidId(msg.id)) return 'accept: missing or malformed id';
if (!isValidVariantId(msg.variantId)) return 'accept: missing or malformed variantId';
if (msg.paramValues !== undefined) {
if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
return 'accept: paramValues must be an object';
@@ -154,7 +164,7 @@ function validateEvent(msg) {
}
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
return isValidId(msg.id) ? null : 'discard: missing or malformed id';
case 'exit':
return null;
case 'prefetch':
+51 -5
View File
@@ -130,6 +130,52 @@ describe('live-server integration', () => {
assert.ok(data.error.includes('generate'));
});
// Regression: ids reach `execFileSync` argv and DOM attribute selectors.
// Anything outside the strict generator pattern must be rejected before it
// can leak into a downstream child_process or selector.
it('POST /events rejects accept with shell metacharacters in id', async () => {
const res = await fetch(`http://localhost:${server.port}/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: server.token,
type: 'accept',
id: '"; rm -rf /; #',
variantId: '0',
}),
});
assert.equal(res.status, 400);
const data = await res.json();
assert.ok(data.error.includes('id'));
});
it('POST /events rejects accept with non-numeric variantId', async () => {
const res = await fetch(`http://localhost:${server.port}/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: server.token,
type: 'accept',
id: 'a1b2c3d4',
variantId: '0; touch /tmp/owned',
}),
});
assert.equal(res.status, 400);
const data = await res.json();
assert.ok(data.error.includes('variantId'));
});
it('POST /events rejects discard with malformed id', async () => {
const res = await fetch(`http://localhost:${server.port}/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: server.token, type: 'discard', id: 'not a uuid' }),
});
assert.equal(res.status, 400);
const data = await res.json();
assert.ok(data.error.includes('id'));
});
it('POST /events accepts valid exit event', async () => {
const res = await fetch(`http://localhost:${server.port}/events`, {
method: 'POST',
@@ -163,7 +209,7 @@ describe('live-server integration', () => {
body: JSON.stringify({
token: server.token,
type: 'generate',
id: 'test-e2e-1',
id: 'a1b2c3d4',
action: 'bolder',
count: 2,
element: { outerHTML: '<div>test</div>', tagName: 'div' },
@@ -174,7 +220,7 @@ describe('live-server integration', () => {
// Poll should resolve with the event
const event = await pollPromise;
assert.equal(event.type, 'generate');
assert.equal(event.id, 'test-e2e-1');
assert.equal(event.id, 'a1b2c3d4');
assert.equal(event.action, 'bolder');
assert.equal(event.count, 2);
});
@@ -299,7 +345,7 @@ describe('live-server integration', () => {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: server.token, type: 'generate',
id: 'annot-1', action: 'polish', count: 2,
id: 'aa11bb22', action: 'polish', count: 2,
element: { outerHTML: '<div>x</div>', tagName: 'div' },
screenshotPath: '/tmp/fake.png',
comments: [{ x: 10, y: 20, text: 'tighten this' }],
@@ -310,7 +356,7 @@ describe('live-server integration', () => {
const pollRes = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=2000`);
const event = await pollRes.json();
assert.equal(event.id, 'annot-1');
assert.equal(event.id, 'aa11bb22');
assert.equal(event.screenshotPath, '/tmp/fake.png');
assert.equal(event.comments.length, 1);
assert.equal(event.strokes.length, 1);
@@ -321,7 +367,7 @@ describe('live-server integration', () => {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: server.token, type: 'generate',
id: 'annot-bad', action: 'polish', count: 2,
id: 'cc33dd44', action: 'polish', count: 2,
element: { outerHTML: '<div>x</div>', tagName: 'div' },
comments: 'not-an-array',
}),