diff --git a/browse/src/commands.ts b/browse/src/commands.ts index 8af1cb85..e9e60153 100644 --- a/browse/src/commands.ts +++ b/browse/src/commands.ts @@ -59,6 +59,22 @@ export const PAGE_CONTENT_COMMANDS = new Set([ 'snapshot', ]); +/** + * Subset of PAGE_CONTENT_COMMANDS whose output is derived from the + * live page DOM. These channels can carry hidden elements or + * ARIA-injection payloads that the centralized envelope wrap alone + * does not neutralize, so the scoped-token pipeline runs + * `markHiddenElements` on the page before the read and surfaces any + * hits as CONTENT WARNINGS to the LLM. + * + * `console`, `dialog` intentionally excluded — they read separate + * runtime state (console capture, dialog events), not the DOM tree. + */ +export const DOM_CONTENT_COMMANDS = new Set([ + 'text', 'html', 'links', 'forms', 'accessibility', 'attrs', + 'media', 'data', 'ux-audit', +]); + /** Wrap output from untrusted-content commands with trust boundary markers */ export function wrapUntrustedContent(result: string, url: string): string { // Sanitize URL: remove newlines to prevent marker injection via history.pushState diff --git a/browse/src/server.ts b/browse/src/server.ts index f3ffc9b5..3941d826 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -19,7 +19,7 @@ import { handleWriteCommand } from './write-commands'; import { handleMetaCommand } from './meta-commands'; import { handleCookiePickerRoute, hasActivePicker } from './cookie-picker-routes'; import { sanitizeExtensionUrl } from './sidebar-utils'; -import { COMMAND_DESCRIPTIONS, PAGE_CONTENT_COMMANDS, wrapUntrustedContent, canonicalizeCommand, buildUnknownCommandError, ALL_COMMANDS } from './commands'; +import { COMMAND_DESCRIPTIONS, PAGE_CONTENT_COMMANDS, DOM_CONTENT_COMMANDS, wrapUntrustedContent, canonicalizeCommand, buildUnknownCommandError, ALL_COMMANDS } from './commands'; import { wrapUntrustedPageContent, datamarkContent, runContentFilters, type ContentFilterResult, @@ -1178,18 +1178,39 @@ async function handleCommandInternal( const session = browserManager.getActiveSession(); + // Per-request warnings collected during hidden-element detection, + // surfaced into the envelope the LLM sees. Carries across the read + // phase into the centralized wrap block below. + let hiddenContentWarnings: string[] = []; + if (READ_COMMANDS.has(command)) { const isScoped = tokenInfo && tokenInfo.clientId !== 'root'; - // Hidden element stripping for scoped tokens on text command - if (isScoped && command === 'text') { + // Hidden-element / ARIA-injection detection for every scoped + // DOM-reading channel (text, html, links, forms, accessibility, + // attrs, data, media, ux-audit). Previously only `text` received + // stripping; other channels let hidden injection payloads reach + // the LLM despite the envelope wrap. Detections become CONTENT + // WARNINGS on the outgoing envelope so the model can see what it + // would have otherwise trusted silently. + if (isScoped && DOM_CONTENT_COMMANDS.has(command)) { const page = session.getPage(); - const strippedDescs = await markHiddenElements(page); - if (strippedDescs.length > 0) { - console.warn(`[browse] Content security: stripped ${strippedDescs.length} hidden elements for ${tokenInfo.clientId}`); - } try { - const target = session.getActiveFrameOrPage(); - result = await getCleanTextWithStripping(target); + const strippedDescs = await markHiddenElements(page); + if (strippedDescs.length > 0) { + console.warn(`[browse] Content security: ${strippedDescs.length} hidden elements flagged on ${command} for ${tokenInfo.clientId}`); + hiddenContentWarnings = strippedDescs.slice(0, 8).map(d => + `hidden content: ${d.slice(0, 120)}`, + ); + if (strippedDescs.length > 8) { + hiddenContentWarnings.push(`hidden content: +${strippedDescs.length - 8} more flagged elements`); + } + } + if (command === 'text') { + const target = session.getActiveFrameOrPage(); + result = await getCleanTextWithStripping(target); + } else { + result = await handleReadCommand(command, args, session, browserManager); + } } finally { await cleanupHiddenMarkers(page); } @@ -1260,10 +1281,14 @@ async function handleCommandInternal( if (command === 'text') { result = datamarkContent(result); } - // Enhanced envelope wrapping for scoped tokens + // Enhanced envelope wrapping for scoped tokens. + // Merge per-request hidden-element warnings with content-filter + // warnings so both reach the LLM through the same CONTENT + // WARNINGS header. + const combinedWarnings = [...filterResult.warnings, ...hiddenContentWarnings]; result = wrapUntrustedPageContent( result, command, - filterResult.warnings.length > 0 ? filterResult.warnings : undefined, + combinedWarnings.length > 0 ? combinedWarnings : undefined, ); } else { // Root token: basic wrapping (backward compat, Decision 2) diff --git a/browse/test/content-security.test.ts b/browse/test/content-security.test.ts index 759aee6d..6c98e3a3 100644 --- a/browse/test/content-security.test.ts +++ b/browse/test/content-security.test.ts @@ -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 +//
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', () => {