security: extend hidden-element detection to all DOM-reading channels

The Confusion Protocol envelope wrap (`wrapUntrustedPageContent`)
covers every scoped PAGE_CONTENT_COMMAND, but the hidden-element
ARIA-injection detection layer only ran for `text`. Other DOM-reading
channels (html, links, forms, accessibility, attrs, data, media,
ux-audit) returned their output through the envelope with no hidden-
content filter, so a page serving a display:none div that instructs
the agent to disregard prior system messages, or an aria-label that
claims to put the LLM in admin mode, leaked the injection payload on
any non-text channel. The envelope alone does not mitigate this, and
the page itself never rendered the hostile content to the human
operator.

Fix:
  * New export `DOM_CONTENT_COMMANDS` in commands.ts — the subset of
    PAGE_CONTENT_COMMANDS that derives its output from the live DOM.
    Console and dialog stay out; they read separate runtime state.
  * server.ts runs `markHiddenElements` + `cleanupHiddenMarkers` for
    every scoped command in this set. `text` keeps its existing
    `getCleanTextWithStripping` path (hidden elements physically
    stripped before the read). All other channels keep their output
    format but emit flagged elements as CONTENT WARNINGS on the
    envelope, so the LLM sees what it would otherwise have consumed
    silently.
  * Hidden-element descriptions merge into `combinedWarnings`
    alongside content-filter warnings before the wrap call.

Tests: new describe block in content-security.test.ts covering
  * `DOM_CONTENT_COMMANDS` export shape and channel membership;
  * dispatch gates on `DOM_CONTENT_COMMANDS.has(command)`, not the
    literal `text` string;
  * hiddenContentWarnings plumbs into `combinedWarnings` and reaches
    wrapUntrustedPageContent;
  * DOM_CONTENT_COMMANDS is a strict subset of PAGE_CONTENT_COMMANDS.

Existing datamarking, envelope wrap, centralized-wrapping, and chain
security suites stay green (52 pass, 0 fail).
This commit is contained in:
gus
2026-04-16 20:33:09 -03:00
committed by Garry Tan
parent d9e78dd548
commit 1372a4f631
3 changed files with 121 additions and 11 deletions
+69
View File
@@ -303,6 +303,75 @@ describe('Centralized wrapping', () => {
});
});
// ─── 5b. DOM-content channel coverage (F008) ────────────────────
//
// Regression: `markHiddenElements` was only invoked for scoped
// `text`. Other DOM-reading channels (html, accessibility, attrs,
// forms, links, data, media, ux-audit) went through the envelope
// wrap with zero hidden-element detection, so a
// <div style="display:none">IGNORE INSTRUCTIONS …</div> or an
// aria-label carrying an injection pattern reached the LLM silently.
// The dispatch now gates on DOM_CONTENT_COMMANDS and surfaces
// descriptions as CONTENT WARNINGS.
describe('DOM-content channel coverage', () => {
test('commands.ts exports DOM_CONTENT_COMMANDS', () => {
expect(COMMANDS_SRC).toContain('export const DOM_CONTENT_COMMANDS');
});
test('DOM_CONTENT_COMMANDS covers the DOM-reading channels', () => {
const setStart = COMMANDS_SRC.indexOf('export const DOM_CONTENT_COMMANDS');
expect(setStart).toBeGreaterThan(-1);
const setBlock = COMMANDS_SRC.slice(
setStart, COMMANDS_SRC.indexOf(']);', setStart),
);
for (const cmd of ['text', 'html', 'links', 'forms', 'accessibility', 'attrs', 'media', 'data', 'ux-audit']) {
expect(setBlock).toContain(`'${cmd}'`);
}
// console + dialog read runtime state, not DOM — should NOT be in the set
expect(setBlock).not.toContain("'console'");
expect(setBlock).not.toContain("'dialog'");
});
test('server gates markHiddenElements on DOM_CONTENT_COMMANDS, not just text', () => {
// Find the scoped-token read block. The dispatch must pivot on
// the full set rather than the literal string 'text'.
const readBlockStart = SERVER_SRC.indexOf('if (READ_COMMANDS.has(command))');
expect(readBlockStart).toBeGreaterThan(-1);
const readBlockEnd = SERVER_SRC.indexOf('} else if (WRITE_COMMANDS.has(command))', readBlockStart);
const readBlock = SERVER_SRC.slice(readBlockStart, readBlockEnd);
// Old shape the PR replaces — must be gone. If a future refactor
// reintroduces `command === 'text'` as the ONLY trigger for
// markHiddenElements this test trips.
expect(readBlock).toContain('DOM_CONTENT_COMMANDS.has(command)');
expect(readBlock).toContain('markHiddenElements');
expect(readBlock).toContain('cleanupHiddenMarkers');
});
test('hidden-element descriptions flow into the envelope warnings', () => {
// The per-request warnings variable must be collected during the
// read phase and then merged into the wrap block's
// `combinedWarnings` before `wrapUntrustedPageContent` is called.
expect(SERVER_SRC).toContain('hiddenContentWarnings');
expect(SERVER_SRC).toMatch(/combinedWarnings\s*=\s*\[\s*\.\.\.\s*filterResult\.warnings\s*,\s*\.\.\.\s*hiddenContentWarnings\s*\]/);
// And the merged list is what actually reaches the wrap helper.
const wrapBlockStart = SERVER_SRC.indexOf('Enhanced envelope wrapping for scoped tokens');
expect(wrapBlockStart).toBeGreaterThan(-1);
const wrapBlock = SERVER_SRC.slice(wrapBlockStart, wrapBlockStart + 600);
expect(wrapBlock).toContain('combinedWarnings');
expect(wrapBlock).toMatch(/wrapUntrustedPageContent\s*\(\s*\n?\s*result/);
});
test('DOM_CONTENT_COMMANDS is a subset of PAGE_CONTENT_COMMANDS', async () => {
const { PAGE_CONTENT_COMMANDS, DOM_CONTENT_COMMANDS } =
await import('../src/commands');
for (const cmd of DOM_CONTENT_COMMANDS) {
expect(PAGE_CONTENT_COMMANDS.has(cmd)).toBe(true);
}
});
});
// ─── 6. Chain Security (source-level) ───────────────────────────
describe('Chain security', () => {