sitesmith capture: keep a var() border shorthand that a border-top overrides

`border:1px solid var(--x); border-top:4px solid ...` (the AI's accent-top card) lost its left/right/
bottom sides in the self-review capture in Chromium AND Firefox: once a later declaration overrides
one longhand, the CSSOM can no longer serialise the var() shorthand (it and its remaining longhands
read back as ""), so the 4c1c8db recovery had nothing to emit. Recover such shorthands from the
declaration block's source text (inline value; innermost blocks of a sheet, matched to rules by
cssText), re-parse each ALONE through the CSSOM and judge it with the unchanged value/URL gate.
e2e-capture-fidelity gains the card (var/var, var/literal, stylesheet) sampled on all four sides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-11-01 17:19:07 -08:00
1 parent 6e48cb82b7
commit 28119f0666
2 files changed
+130 -13

No files matched your search

+22
View File
@@ -28,6 +28,7 @@ const PAGE = `<!doctype html><html><head><style>
body{margin:0;background:#ffffff}
.sheet-bg{background:var(--wsb-secondary);height:100px}
.sheet-border{border:12px solid var(--wsb-border);height:76px;background-color:#ffffff}
.sheet-card{border:6px solid var(--wsb-border);border-top:14px solid var(--wsb-accent);height:60px;background-color:#ffffff}
</style></head><body>
<div style="background: var(--wsb-secondary); height:100px"></div>
<div style="background-color: var(--wsb-primary); height:100px"></div>
@@ -36,6 +37,9 @@ body{margin:0;background:#ffffff}
<div class="sheet-border"></div>
<div style="background: linear-gradient(var(--wsb-accent), var(--wsb-accent)); height:100px"></div>
<div style="padding: var(--pad) 0 0 0; background-color:#ffffff"><div style="height:60px;background-color:#00aa00"></div></div>
<div style="padding:0;background-color:#ffffff;border:6px solid var(--wsb-border);border-top:14px solid var(--wsb-accent);border-radius:0;height:60px"></div>
<div style="background-color:#ffffff;border:6px solid var(--wsb-border);border-top:14px solid #00aa00;height:60px"></div>
<div class="sheet-card"></div>
</body></html>`;
// [label, x, y, expected rgb]
const POINTS = [
@@ -48,6 +52,17 @@ const POINTS = [
['inline `background: linear-gradient(var(--wsb-accent), ...)`', 640, 550, [0xd9, 0xa2, 0x4a]],
['inline `padding: var(--pad) 0 0 0` keeps the 40px gap (white above the green box)', 640, 620, [0xff, 0xff, 0xff]],
['... and the green box starts below the padding', 640, 670, [0x00, 0xaa, 0x00]],
// A card written `border: ... var(); border-top: <override>` (the AI's "accent top" card, eval 2026-11-01):
// the CSSOM cannot serialise the overridden var() shorthand, so the left/right/bottom sides were lost.
...[['inline `border:var()` + `border-top:var()` card', 700, [0xd9, 0xa2, 0x4a]],
['inline `border:var()` + literal `border-top:#00aa00` card', 780, [0x00, 0xaa, 0x00]],
['stylesheet `.sheet-card{border:var(); border-top:var()}`', 860, [0xd9, 0xa2, 0x4a]]].flatMap(([label, y0, top]) => [
[`${label}: top border`, 640, y0 + 7, top],
[`${label}: left border`, 2, y0 + 50, [0xd4, 0x37, 0x2c]],
[`${label}: right border`, 1277, y0 + 50, [0xd4, 0x37, 0x2c]],
[`${label}: bottom border`, 640, y0 + 77, [0xd4, 0x37, 0x2c]],
[`${label}: interior stays white`, 640, y0 + 45, [0xff, 0xff, 0xff]],
]),
];
const srv = http.createServer((q, r) => { r.setHeader('content-type', 'text/html'); r.end('<!doctype html><title>host</title><body>host</body>'); }).listen(0);
@@ -67,6 +82,13 @@ async function run(name, launcher, exe) {
// The recovered shorthand goes through the same URL gate: a var() fallback url() to a non-allowed target is dropped.
const hostile = await page.evaluate((h) => window.sanitizeCaptureHtml(h), '<div style="background: var(--missing, url(/beacon?x)) center; border: 1px solid var(--b)">x</div><style>.a{background:var(--q, url(https://evil.example/a.png)) no-repeat}</style>');
check(name, 'recovered var() shorthand with a disallowed url() fallback is dropped (inline + sheet)', hostile.ok && !/beacon|evil\.example/.test(hostile.html) && /border:\s*1px solid var\(--b\)/.test(hostile.html), hostile.ok ? hostile.html.slice(0, 300) : hostile.reason);
check(name, 'sanitiser keeps an overridden `border: ... var()` before its `border-top` override (inline + sheet)', clean.ok
&& /border:\s*6px solid var\(--wsb-border\);\s*border-top:\s*14px solid var\(--wsb-accent\)/.test(clean.html)
&& /border:\s*6px solid var\(--wsb-border\);\s*border-top(-width)?:\s*14px/.test(clean.html)
&& /\.sheet-card\s*\{[^}]*border:\s*6px solid var\(--wsb-border\);\s*border-top:/.test(clean.html));
// Recovered-from-source shorthands pass the same URL gate.
const hostile2 = await page.evaluate((h) => window.sanitizeCaptureHtml(h), '<div style="background: var(--m, url(/beacon2?x)) center; background-color: red; border: 1px solid var(--b); border-top: 2px solid red">x</div><style>.b{background:var(--q, url(https://evil.example/b.png)) no-repeat;background-position:0 0}</style>');
check(name, 'overridden var() shorthand with a disallowed url() fallback is still dropped (inline + sheet)', hostile2.ok && !/beacon2|evil\.example/.test(hostile2.html) && /border:\s*1px solid var\(--b\)/.test(hostile2.html), hostile2.ok ? '' : hostile2.reason);
const res = await page.evaluate(([h, pts]) => window.captureAndSample(h, pts), [PAGE, POINTS.map((p) => [p[1], p[2]])]);
check(name, 'capture succeeded', res.ok === true, res.ok ? '' : res.reason);
if (res.ok) POINTS.forEach(([label, , , want], i) => check(name, label, near(res.samples[i], want), `got rgb(${res.samples[i].join(',')}) want rgb(${want.join(',')})`));
+108 -13
View File
@@ -167,38 +167,133 @@ const COVER_EXTRA: Record<string, RegExp> = {
'place-self': /^(align|justify)-self$/,
columns: /^column-(width|count)$/,
};
function covers(sh: string, prop: string): boolean {
return prop.startsWith(sh + '-') || !!COVER_EXTRA[sh]?.test(prop);
}
function shorthandFor(style: CSSStyleDeclaration, prop: string): string | null {
for (const sh of SHORTHANDS) {
if (!(prop.startsWith(sh + '-') || COVER_EXTRA[sh]?.test(prop))) continue;
if (!covers(sh, prop)) continue;
const v = style.getPropertyValue(sh);
if (v && /var\(/i.test(v)) return sh;
}
return null;
}
function declarations(style: CSSStyleDeclaration, pol: Policy, font = false): string {
/** var() shorthands the CSSOM can no longer serialise, recovered from the source text (shorthand -> declaration). */
type Recovered = Map<string, { value: string; important: boolean }>;
/**
* Split a declaration block's SOURCE text into `prop: value` pairs (top-level `;`, strings, parens, comments).
* Only used to find candidates; every candidate is re-parsed by the CSSOM and judged by the normal gate.
*/
function sourceDecls(text: string): Array<[string, string]> {
const out: Array<[string, string]> = [];
let depth = 0; let quote = ''; let start = 0;
const push = (end: number) => {
const d = text.slice(start, end);
const c = d.indexOf(':');
if (c > 0) out.push([d.slice(0, c).trim().toLowerCase(), d.slice(c + 1).trim()]);
};
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (quote) { if (ch === '\\') i++; else if (ch === quote) quote = ''; continue; }
if (ch === '"' || ch === "'") quote = ch;
else if (ch === '/' && text[i + 1] === '*') { const e = text.indexOf('*/', i + 2); i = e < 0 ? text.length : e + 1; }
else if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
else if (ch === ';' && depth === 0) { push(i); start = i + 1; }
}
push(text.length);
return out;
}
/**
* The CSSOM keeps a var() shorthand only while ALL its longhands still come from it. Once a later declaration
* overrides one (`border: 1px solid var(--b); border-top: 4px solid var(--a)` -- the AI's accent-top card),
* the shorthand reads back as "" in Chromium and Firefox and the other sides were lost from the capture.
* Recover each such shorthand from the block's source text: the LAST source declaration of it, re-parsed ALONE
* by the CSSOM (must be one valid declaration producing only its own longhands), canonical value kept.
*/
function recoverShorthands(blockText: string, style: CSSStyleDeclaration): Recovered | null {
let rec: Recovered | null = null;
for (const [prop, raw] of sourceDecls(blockText)) {
if (!(SHORTHANDS as readonly string[]).includes(prop) || !/var\(/i.test(raw) || style.getPropertyValue(prop)) continue;
const rules = parseSheet(`a{${prop}:${raw}}`);
if (!rules || rules.length !== 1 || rules[0].type !== RULE.STYLE) continue;
const one = (rules[0] as CSSStyleRule).style;
if ((rules[0] as CSSStyleRule).selectorText.trim() !== 'a' || !one.length) continue;
let own = true;
for (let i = 0; i < one.length; i++) if (!covers(prop, one[i]) && one[i] !== prop) own = false;
const value = one.getPropertyValue(prop);
if (!own || !value) continue;
(rec ??= new Map()).set(prop, { value, important: !!one.getPropertyPriority(prop) });
}
return rec;
}
function declarations(style: CSSStyleDeclaration, pol: Policy, font = false, recovered: Recovered | null = null): string {
const out: string[] = [];
const seenShorthand = new Set<string>();
for (let i = 0; i < style.length; i++) {
let prop = style[i];
if (!prop || /[^A-Za-z0-9_-]/.test(prop) || BAD_PROP.test(prop)) continue;
let value = style.getPropertyValue(prop);
let pri = style.getPropertyPriority(prop) ? ' !important' : '';
if (!value) {
// A var() shorthand: emit the shorthand once, where its first longhand stood (cascade order kept),
// judged by exactly the same value gate as any other declaration.
const sh = shorthandFor(style, prop);
// A var() shorthand: emit the shorthand once, where its first longhand stood (cascade order kept: the
// CSSOM lists overriding longhands after it), judged by exactly the same value gate as any other declaration.
let sh = shorthandFor(style, prop);
if (sh) { value = style.getPropertyValue(sh); pri = style.getPropertyPriority(sh) ? ' !important' : ''; }
else if (recovered) {
sh = SHORTHANDS.find((s) => recovered.has(s) && covers(s, prop)) ?? null;
if (sh) { const r = recovered.get(sh)!; value = r.value; pri = r.important ? ' !important' : ''; }
}
if (!sh || seenShorthand.has(sh)) continue;
seenShorthand.add(sh);
prop = sh;
value = style.getPropertyValue(sh);
}
if (!value || !valueOk(value, pol, font || prop === 'src')) continue;
const pri = style.getPropertyPriority(prop) ? ' !important' : '';
out.push(`${prop}:${canonicalize(value, pol, font || prop === 'src')}${pri}`);
}
return out.join(';');
}
/** Innermost `{...}` blocks of a stylesheet's source text (declaration blocks), in order. */
function sourceBlocks(css: string): string[] {
const out: string[] = [];
const open: Array<{ at: number; nested: boolean }> = [];
let quote = '';
for (let i = 0; i < css.length; i++) {
const ch = css[i];
if (quote) { if (ch === '\\') i++; else if (ch === quote) quote = ''; continue; }
if (ch === '"' || ch === "'") quote = ch;
else if (ch === '/' && css[i + 1] === '*') { const e = css.indexOf('*/', i + 2); i = e < 0 ? css.length : e + 1; }
else if (ch === '{') { if (open.length) open[open.length - 1].nested = true; open.push({ at: i + 1, nested: false }); }
else if (ch === '}') { const b = open.pop(); if (b && !b.nested) out.push(css.slice(b.at, i)); }
}
return out;
}
/** Style-rule cssText -> recovered shorthands of the source blocks that serialise to it (consumed in order). */
type RecoverIndex = Map<string, Array<Recovered | null>>;
function recoverIndex(css: string): RecoverIndex | null {
if (!/var\(/i.test(css)) return null;
let idx: RecoverIndex | null = null;
for (const block of sourceBlocks(css)) {
if (!/var\(/i.test(block)) continue;
const rules = parseSheet(`a{${block}}`);
if (!rules || rules.length !== 1 || rules[0].type !== RULE.STYLE) continue;
const st = (rules[0] as CSSStyleRule).style;
const rec = recoverShorthands(block, st);
if (!rec) continue;
idx ??= new Map();
const k = st.cssText;
if (!idx.has(k)) idx.set(k, []);
idx.get(k)!.push(rec);
}
return idx;
}
function parseSheet(css: string): CSSRuleList | null {
try {
if (typeof CSSStyleSheet !== 'undefined') {
@@ -216,14 +311,14 @@ function parseSheet(css: string): CSSRuleList | null {
} catch { return null; }
}
function rulesToCss(rules: CSSRuleList, pol: Policy): string {
function rulesToCss(rules: CSSRuleList, pol: Policy, idx: RecoverIndex | null = null): string {
const out: string[] = [];
for (const r of Array.from(rules)) {
switch (r.type) {
case RULE.STYLE: {
const sr = r as CSSStyleRule;
if (/\\/.test(sr.selectorText) || /[<>]/.test(sr.selectorText.replace(/[>+~]/g, ''))) break;
const d = declarations(sr.style, pol);
const d = declarations(sr.style, pol, false, idx?.get(sr.style.cssText)?.shift() ?? null);
if (d) out.push(`${sr.selectorText}{${d}}`);
break;
}
@@ -236,7 +331,7 @@ function rulesToCss(rules: CSSRuleList, pol: Policy): string {
const mr = r as CSSMediaRule;
const cond = mr.media.mediaText;
if (/[\\{}<]/.test(cond)) break;
const inner = rulesToCss(mr.cssRules, pol);
const inner = rulesToCss(mr.cssRules, pol, idx);
if (inner) out.push(`@media ${cond}{${inner}}`);
break;
}
@@ -244,7 +339,7 @@ function rulesToCss(rules: CSSRuleList, pol: Policy): string {
const sr = r as CSSRule & { conditionText?: string; cssRules: CSSRuleList };
const cond = sr.conditionText ?? '';
if (!cond || /[\\{}<]/.test(cond)) break;
const inner = rulesToCss(sr.cssRules, pol);
const inner = rulesToCss(sr.cssRules, pol, idx);
if (inner) out.push(`@supports ${cond}{${inner}}`);
break;
}
@@ -271,7 +366,7 @@ function rulesToCss(rules: CSSRuleList, pol: Policy): string {
/** Sanitise a stylesheet's text through the CSSOM. Empty string when nothing safe is left. */
export function cleanCss(css: string, pol: Policy = { allow: new Set() }): string {
const rules = parseSheet(css);
return rules ? rulesToCss(rules, pol) : '';
return rules ? rulesToCss(rules, pol, recoverIndex(css)) : '';
}
/** Sanitise a style="" attribute value; '' when it is not a plain declaration list. */
@@ -280,7 +375,7 @@ export function cleanInlineStyle(value: string, pol: Policy = { allow: new Set()
if (!rules || rules.length !== 1 || rules[0].type !== RULE.STYLE) return '';
const sr = rules[0] as CSSStyleRule;
if (sr.selectorText.trim() !== 'a') return '';
return declarations(sr.style, pol);
return declarations(sr.style, pol, false, /var\(/i.test(value) ? recoverShorthands(value, sr.style) : null);
}
// ---------------------------------------------------------------- HTML