feat(editor): hidden-content detector + non-blocking authoring warning (TASK-1327) (#477)

* feat(editor): hidden-content detector + non-blocking authoring warning (TASK-1327)

Adds an authoring-honesty feature for HTML blocks: when the user pastes
or types raw HTML containing content that's invisible (or
near-invisible) to humans but readable by LLMs / agents, surface a
non-blocking warning pill above the block. Click the pill to expand an
inspector listing each hidden segment with the rule that flagged it
plus a snippet for context. "Dismiss for this block" button persists
acknowledgement via a sentinel HTML comment marker so the warning
doesn't re-fire after a doc reload.

NOT a security control. Render-time sanitization (sanitizeHtmlBlock,
TASK-1323) protects browsers from XSS. This protects authors from
unconsciously shipping content that looks one way and reads another
— common with copy-pasted HTML blobs that contain steganography
channels (display:none divs, white-on-white text, font-size:0,
hidden HTML comments, off-screen positioning, suspiciously long
aria-label / alt / title values).

## Detector

`web/src/lib/utils/hiddenContentDetector.ts` (NEW). Pure function
`detectHiddenContent(html: string): HiddenSegment[]`. Uses DOMParser
to walk the HTML tree:

- Inline-style heuristics:
  - display:none, visibility:hidden, opacity:0/0%/0.0
  - font-size with px value < 6
  - color matches background-color (exact normalised match)
  - position absolute/fixed with left/right/top/bottom <= -9000px
  - transform translate to off-screen (heuristic regex on -9XXX or
    -1XXXX values inside translateX/Y/translate)
  - width:0 AND height:0
  - clip:rect(0,0,0,0) — intentionally flagged; the user can dismiss
    if it's deliberate sr-only positioning, but it's also a known
    steganography channel
- HTML comments — every <!-- ... --> flags, EXCEPT the Pad-internal
  ack marker (PAD_ACK_HIDDEN_MARKER) which the detector skips
- aria-label / alt / title attributes longer than 200 chars OR
  containing newlines

Class-based hiding (e.g. .sr-only) is NOT flagged: resolving it
requires the page's stylesheet context, which we don't have, and the
false-positive rate is too high.

## NodeView UX

Warning pill ⚠ "N hidden segment(s) — click to inspect" appears above
the preview pane when segments > 0 AND the user hasn't dismissed for
this block. Click toggles the inspector panel below the source pane,
which lists each segment as `<code>tag</code> — rule` with the snippet
in a quoted monospace box. Dismiss button prepends
`<!-- pad:ack-hidden -->` to attrs.html via setNodeMarkup; the marker
survives markdown round-trip (it's a valid HTML comment) and the
detector skips it on subsequent runs.

The wrapper gets `.html-block--has-hidden` while the warning is
showing, in case any caller wants to react.

`updateWarning()` runs on every renderPreview call, so the warning
follows attrs.html changes (e.g. the user edits the block in source
mode and removes the hidden content — warning disappears immediately).

## Out of scope (future work)

- Class-based hiding detection (requires stylesheet resolution)
- Auto-stripping hidden content (this is an authoring honesty
  feature, not a sanitizer; the user decides)
- Detection in non-HTML-block content (markdown comments, raw HTML
  in markdown surface)

Parent: PLAN-1322.

* fix(detector): walk comments at doc root + normalize style values (Codex round 1)

* fix(detector): use CSSStyleDeclaration for browser-correct parsing (Codex round 2)
This commit is contained in:
xarmian
2026-05-10 01:02:38 -04:00
committed by GitHub
parent 781fe86f0e
commit 2bbd9c9e36
3 changed files with 416 additions and 2 deletions
+73 -1
View File
@@ -1385,7 +1385,79 @@
border-color: var(--accent-blue);
}
/* Hidden-content authoring warning (TASK-1327 / PLAN-1322) */
.editor-content :global(.html-block-warning) {
display: block;
width: 100%;
text-align: left;
padding: var(--space-2) var(--space-3);
background: var(--bg-warning, rgba(255, 180, 50, 0.12));
border: none;
border-bottom: 1px solid var(--border);
color: var(--accent-orange, #d68a3a);
font-size: 0.85em;
font-family: var(--font-mono);
cursor: pointer;
transition: background 0.12s;
}
.editor-content :global(.html-block-warning:hover) {
background: var(--bg-warning-hover, rgba(255, 180, 50, 0.22));
}
.editor-content :global(.html-block-inspector) {
padding: var(--space-3);
border-top: 1px solid var(--border);
background: var(--bg-secondary);
font-size: 0.9em;
}
.editor-content :global(.html-block-inspector-heading) {
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--space-2);
}
.editor-content :global(.html-block-inspector-list) {
list-style: none;
padding: 0;
margin: 0 0 var(--space-2) 0;
}
.editor-content :global(.html-block-inspector-item) {
padding: var(--space-2) 0;
border-bottom: 1px solid var(--border);
}
.editor-content :global(.html-block-inspector-item:last-child) {
border-bottom: none;
}
.editor-content :global(.html-block-inspector-rule) {
color: var(--text-secondary);
}
.editor-content :global(.html-block-inspector-rule code) {
color: var(--accent-blue);
font-family: var(--font-mono);
font-size: 0.95em;
}
.editor-content :global(.html-block-inspector-snippet) {
margin-top: 4px;
padding: 4px var(--space-2);
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 0.85em;
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
overflow-wrap: anywhere;
}
.editor-content :global(.html-block-dismiss) {
padding: 4px 12px;
font-size: 0.85em;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-secondary);
cursor: pointer;
transition: color 0.12s, border-color 0.12s;
}
.editor-content :global(.html-block-dismiss:hover) {
color: var(--text-primary);
border-color: var(--accent-blue);
}
/* Mobile keyboard toolbar */
.mobile-toolbar {
@@ -19,6 +19,12 @@ import { InputRule, Node, type Editor } from '@tiptap/core';
import type MarkdownIt from 'markdown-it';
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
import { sanitizeHtmlBlock } from '$lib/utils/markdown';
import {
detectHiddenContent,
isHiddenContentAcknowledged,
setHiddenContentAcknowledged,
type HiddenSegment,
} from '$lib/utils/hiddenContentDetector';
/** A single htmlBlock node's identity for snapshot comparison. */
export interface HtmlBlockSnapshotEntry {
@@ -219,10 +225,91 @@ export const HtmlBlock = Node.create({
actions.append(doneBtn);
source.append(textarea, actions);
wrapper.append(preview, source);
// Hidden-content authoring warning (TASK-1327). The pill is
// always created but only revealed when detectHiddenContent
// finds segments AND the user hasn't dismissed for this block.
const warning = document.createElement('button');
warning.type = 'button';
warning.className = 'html-block-warning';
warning.hidden = true;
warning.title = 'Click to inspect hidden segments in this block';
const inspector = document.createElement('div');
inspector.className = 'html-block-inspector';
inspector.hidden = true;
wrapper.append(warning, preview, source, inspector);
let lastHtml = (node.attrs.html as string | undefined) ?? '';
let mode: 'preview' | 'source' = 'preview';
let lastSegments: HiddenSegment[] = [];
const renderInspector = () => {
if (lastSegments.length === 0) {
inspector.replaceChildren();
return;
}
const heading = document.createElement('div');
heading.className = 'html-block-inspector-heading';
heading.textContent = `${lastSegments.length} hidden segment${lastSegments.length === 1 ? '' : 's'}`;
const list = document.createElement('ul');
list.className = 'html-block-inspector-list';
for (const seg of lastSegments) {
const item = document.createElement('li');
item.className = 'html-block-inspector-item';
const label = document.createElement('div');
label.className = 'html-block-inspector-rule';
const tagSpan = document.createElement('code');
tagSpan.textContent = seg.tag;
label.append(tagSpan, document.createTextNode(`${seg.rule}`));
item.appendChild(label);
if (seg.snippet) {
const snip = document.createElement('div');
snip.className = 'html-block-inspector-snippet';
snip.textContent = `"${seg.snippet}"`;
item.appendChild(snip);
}
list.appendChild(item);
}
const dismiss = document.createElement('button');
dismiss.type = 'button';
dismiss.className = 'html-block-dismiss';
dismiss.textContent = 'Dismiss for this block';
dismiss.title = 'Mark these segments as reviewed; warning will not re-appear';
// mousedown.preventDefault keeps focus where it was so the
// click handler runs in-context.
dismiss.addEventListener('mousedown', (e) => e.preventDefault());
dismiss.addEventListener('click', (e) => {
e.preventDefault();
if (!editor.isEditable) return;
const pos = typeof getPos === 'function' ? getPos() : null;
if (typeof pos !== 'number') return;
const next = setHiddenContentAcknowledged(lastHtml);
if (next === lastHtml) return;
editor.view.dispatch(
editor.view.state.tr.setNodeMarkup(pos, undefined, { html: next }),
);
});
inspector.replaceChildren(heading, list, dismiss);
};
const updateWarning = () => {
lastSegments = detectHiddenContent(lastHtml);
const acked = isHiddenContentAcknowledged(lastHtml);
if (lastSegments.length === 0 || acked) {
warning.hidden = true;
inspector.hidden = true;
wrapper.classList.remove('html-block--has-hidden');
return;
}
warning.textContent = `${lastSegments.length} hidden segment${lastSegments.length === 1 ? '' : 's'} — click to inspect`;
warning.hidden = false;
wrapper.classList.add('html-block--has-hidden');
};
const renderPreview = () => {
if (!lastHtml.trim()) {
@@ -234,9 +321,17 @@ export const HtmlBlock = Node.create({
} else {
preview.innerHTML = sanitizeHtmlBlock(lastHtml);
}
updateWarning();
renderInspector();
};
renderPreview();
warning.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
inspector.hidden = !inspector.hidden;
});
function flipToSource() {
if (mode === 'source') return;
mode = 'source';
+247
View File
@@ -0,0 +1,247 @@
/**
* Hidden-content detector for HTML blocks (TASK-1327 / PLAN-1322).
*
* Walks an HTML string looking for elements + comments that are visible
* to LLMs / agents reading the document but invisible (or near-invisible)
* to a human author reviewing the rendered preview. The detector is an
* **authoring-honesty** feature — NOT a security control. Render-time
* sanitization (sanitizeHtmlBlock) protects browsers; this protects
* authors from accidentally pasting content that looks one way and
* reads another.
*
* Heuristics flag content that is one of:
* - CSS-hidden via inline style (display:none, visibility:hidden,
* opacity:0, font-size:0 or very small, color === background-color)
* - Off-screen positioned (position:absolute with very negative
* left/right/top/bottom, transform translate to off-screen)
* - Zero-dimension (width:0 AND height:0, clip:rect(0,0,0,0))
* - HTML comments
* - Suspiciously long aria-label / alt / title (>200 chars or
* containing newlines — heuristic for hidden text in attribute
* values)
*
* Class-based hiding (e.g. `.sr-only`) is NOT flagged: resolving it
* requires the page's stylesheet context, which we don't have here,
* and the false-positive rate would be too high. The detector stays
* conservative.
*/
/**
* The Pad-internal sentinel used by `setHiddenContentAcknowledged` to
* mark that a human has reviewed and accepted the hidden content.
* `detectHiddenContent` skips this exact comment so dismissal sticks
* after a doc reload.
*/
export const PAD_ACK_HIDDEN_MARKER = '<!-- pad:ack-hidden -->';
const PAD_ACK_HIDDEN_TEXT = ' pad:ack-hidden '; // textContent of the marker (with surrounding spaces)
const PAD_ACK_HIDDEN_TEXT_TRIMMED = 'pad:ack-hidden';
export interface HiddenSegment {
/** Lowercase tag name of the offending element, or `#comment`. */
tag: string;
/** Short human-readable rule label, e.g. `display:none` or `font-size:2px (too small)`. */
rule: string;
/** Up to 80 chars of trimmed text content, for context in the inspector. */
snippet: string;
}
/**
* Returns true when the user has previously dismissed the hidden-content
* warning for this block. The marker lives at the top of the html string
* so it's the first thing read on parse and survives markdown round-trips
* (it's just an HTML comment, valid in any context where raw HTML is
* permitted).
*/
export function isHiddenContentAcknowledged(html: string): boolean {
return html.trimStart().startsWith(PAD_ACK_HIDDEN_MARKER);
}
/**
* Prepend the ack marker if not already present. Idempotent. Used by the
* NodeView's "Dismiss for this block" affordance.
*/
export function setHiddenContentAcknowledged(html: string): string {
if (isHiddenContentAcknowledged(html)) return html;
const sep = html.startsWith('\n') || html === '' ? '' : '\n';
return `${PAD_ACK_HIDDEN_MARKER}${sep}${html}`;
}
/**
* Detect hidden content in an HTML block's raw source. Returns one
* `HiddenSegment` per offending element / attribute / comment. Returns
* an empty array when running outside a browser (no DOMParser) — the
* detector is purely client-side.
*/
export function detectHiddenContent(html: string): HiddenSegment[] {
if (typeof window === 'undefined' || typeof DOMParser === 'undefined') return [];
if (!html) return [];
const segments: HiddenSegment[] = [];
const doc = new DOMParser().parseFromString(html, 'text/html');
// Walk all elements regardless of whether the parser placed them
// inside body (the typical case) or moved them to head (e.g. <style>
// / <link> at the top of the snippet). We only check elements that
// rendered content can hide behind, but element-level checks are
// cheap so just walk the whole document.
doc.querySelectorAll('*').forEach((el) => {
// Skip <html>, <head>, <body> wrappers DOMParser inserts; they
// never carry the inline-style hide patterns we look for.
const tag = el.tagName.toLowerCase();
if (tag === 'html' || tag === 'head' || tag === 'body') return;
segments.push(...checkElement(el as HTMLElement));
});
// Walk comments anywhere in the document. DOMParser can place
// comments at document level (siblings of <html>) when the input
// has a leading `<!-- ... -->`; walking only doc.body would miss
// those. createTreeWalker on `doc` is the inclusive root.
const walker = doc.createTreeWalker(doc, NodeFilter.SHOW_COMMENT);
let node = walker.nextNode();
while (node) {
const text = node.textContent ?? '';
const trimmed = text.trim();
if (text !== PAD_ACK_HIDDEN_TEXT && trimmed !== PAD_ACK_HIDDEN_TEXT_TRIMMED) {
segments.push({
tag: '#comment',
rule: 'HTML comment',
snippet: snippetFor(text),
});
}
node = walker.nextNode();
}
return segments;
}
function checkElement(el: HTMLElement): HiddenSegment[] {
const segments: HiddenSegment[] = [];
const tag = el.tagName.toLowerCase();
// `el.style` is the browser-parsed CSSStyleDeclaration for the
// inline `style` attribute. The browser handles every CSS edge
// case for us:
//
// - `!important` priority (display:none!important;display:block
// leaves `el.style.display === 'none'`)
// - CSS comments (display:/**/none parses correctly)
// - case-insensitive keywords (DISPLAY: NONE → 'none')
// - whitespace and unit normalisation
//
// A hand-rolled `style` attribute parser misses these consistently;
// using the CSSOM here is materially more robust.
const style = el.style;
const text = el.textContent ?? '';
if (style.display === 'none') {
segments.push({ tag, rule: 'display:none', snippet: snippetFor(text) });
}
if (style.visibility === 'hidden') {
segments.push({ tag, rule: 'visibility:hidden', snippet: snippetFor(text) });
}
if (isZeroOpacity(style.opacity)) {
segments.push({ tag, rule: `opacity:${style.opacity}`, snippet: snippetFor(text) });
}
const fontSize = parseLength(style.fontSize);
if (fontSize !== null && fontSize < 6) {
segments.push({
tag,
rule: `font-size:${style.fontSize} (too small)`,
snippet: snippetFor(text),
});
}
if (
style.color &&
style.backgroundColor &&
normalizeColor(style.color) === normalizeColor(style.backgroundColor)
) {
segments.push({
tag,
rule: 'color matches background-color',
snippet: snippetFor(text),
});
}
if (style.position === 'absolute' || style.position === 'fixed') {
for (const prop of ['left', 'right', 'top', 'bottom'] as const) {
const value = style.getPropertyValue(prop);
const offset = parseLength(value);
if (offset !== null && offset <= -9000) {
segments.push({
tag,
rule: `${prop}:${value} (off-screen)`,
snippet: snippetFor(text),
});
break;
}
}
}
if (style.transform && /translate[xy]?\s*\(\s*-?\d+/i.test(style.transform)) {
const off = /-\s*9\d{3,}|-\s*\d{5,}/.test(style.transform);
if (off) {
segments.push({
tag,
rule: 'transform off-screen',
snippet: snippetFor(text),
});
}
}
const w = parseLength(style.width);
const h = parseLength(style.height);
if (w === 0 && h === 0) {
segments.push({ tag, rule: 'width:0;height:0', snippet: snippetFor(text) });
}
if (style.clip && /rect\(\s*0(?:px)?\s*,\s*0(?:px)?\s*,\s*0(?:px)?\s*,\s*0(?:px)?\s*\)/i.test(style.clip)) {
segments.push({ tag, rule: 'clip:rect(0,0,0,0)', snippet: snippetFor(text) });
}
for (const attr of ['aria-label', 'alt', 'title'] as const) {
const val = el.getAttribute(attr);
if (!val) continue;
const tooLong = val.length > 200;
const hasNewline = /\n/.test(val);
if (tooLong || hasNewline) {
const reasons: string[] = [];
if (tooLong) reasons.push(`${val.length} chars`);
if (hasNewline) reasons.push('contains newlines');
segments.push({
tag,
rule: `${attr} suspicious (${reasons.join(', ')})`,
snippet: snippetFor(val),
});
}
}
return segments;
}
/**
* Parse a CSS length declaration like `12px`, `0`, `-9999px`, `1.5em`
* into a number when the unit is bare (`0`) or px. Returns `null` for
* percentage / em / rem / unrecognised forms — those don't have a
* single numeric "is this hidden" answer.
*/
function parseLength(value: string | undefined): number | null {
if (!value) return null;
const trimmed = value.trim();
if (trimmed === '0') return 0;
const match = trimmed.match(/^(-?[\d.]+)px$/i);
if (!match) return null;
const n = parseFloat(match[1]);
return Number.isFinite(n) ? n : null;
}
function isZeroOpacity(value: string | undefined): boolean {
if (!value) return false;
const v = value.trim();
return v === '0' || v === '0.0' || v === '0%' || v === '.0';
}
function normalizeColor(c: string): string {
return c.toLowerCase().replace(/\s+/g, '');
}
function snippetFor(text: string): string {
const collapsed = text.trim().replace(/\s+/g, ' ');
if (collapsed.length <= 80) return collapsed;
return `${collapsed.slice(0, 77)}`;
}