Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions browse/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,22 @@ export const PAGE_CONTENT_COMMANDS = new Set([
'ux-audit',
]);

/**
* 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
Expand Down
47 changes: 36 additions & 11 deletions browse/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { handleWriteCommand } from './write-commands';
import { handleMetaCommand } from './meta-commands';
import { handleCookiePickerRoute } from './cookie-picker-routes';
import { sanitizeExtensionUrl } from './sidebar-utils';
import { COMMAND_DESCRIPTIONS, PAGE_CONTENT_COMMANDS, wrapUntrustedContent } from './commands';
import { COMMAND_DESCRIPTIONS, PAGE_CONTENT_COMMANDS, DOM_CONTENT_COMMANDS, wrapUntrustedContent } from './commands';
import {
wrapUntrustedPageContent, datamarkContent,
runContentFilters, type ContentFilterResult,
Expand Down Expand Up @@ -1015,18 +1015,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);
}
Expand Down Expand Up @@ -1094,10 +1115,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)
Expand Down
69 changes: 69 additions & 0 deletions browse/test/content-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,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', () => {
Expand Down
Loading