security: route splitForScoped through envelope sentinel escape

The scoped-token snapshot path in snapshot.ts built its untrusted
block by pushing the raw accessibility-tree lines between the literal
`═══ BEGIN UNTRUSTED WEB CONTENT ═══` / `═══ END UNTRUSTED WEB CONTENT ═══`
sentinels. The full-page wrap path in content-security.ts already
applied a zero-width-space escape on those exact strings to prevent
sentinel injection, but the scoped path skipped it.

Net effect: a page whose rendered text contains the literal sentinel
can close the envelope early from inside untrusted content and forge
a fake "trusted" block for the LLM. That includes fabricating
interactive `@eN` references the agent will act on.

Fix:
  * Extract the zero-width-space escape into a named, exported helper
    `escapeEnvelopeSentinels(content)` in content-security.ts.
  * Have `wrapUntrustedPageContent` call it (behavior unchanged on
    that path — same bytes out).
  * Import the helper in snapshot.ts and map it over `untrustedLines`
    in the `splitForScoped` branch before pushing the BEGIN sentinel.

Tests: add a describe block in content-security.test.ts that covers
  * `escapeEnvelopeSentinels` defuses BEGIN and END markers;
  * `escapeEnvelopeSentinels` leaves normal text untouched;
  * `wrapUntrustedPageContent` still emits exactly one real envelope
    pair when hostile content contains forged sentinels;
  * snapshot.ts imports the helper;
  * the scoped-snapshot branch calls `escapeEnvelopeSentinels` before
    pushing the BEGIN sentinel (source-level regression — if a future
    refactor reorders this, the test trips).
This commit is contained in:
gus
2026-04-16 19:58:31 -03:00
committed by Garry Tan
parent b7cf46d6e6
commit d9e78dd548
3 changed files with 98 additions and 7 deletions

View File

@@ -200,6 +200,25 @@ export async function cleanupHiddenMarkers(page: Page | Frame): Promise<void> {
const ENVELOPE_BEGIN = '═══ BEGIN UNTRUSTED WEB CONTENT ═══';
const ENVELOPE_END = '═══ END UNTRUSTED WEB CONTENT ═══';
/**
* Defuse envelope sentinels that appear inside attacker-controlled page
* content. Any raw BEGIN/END marker inside `content` gets a zero-width
* space spliced through CONTENT so the marker still renders visibly but
* no longer matches the envelope grep the LLM anchors on.
*
* Both the wrap path (full-page content) and the split path (scoped
* snapshots) must funnel untrusted text through this helper before
* emitting the outer envelope, otherwise a page whose accessibility
* tree contains the literal sentinel can close the envelope early and
* forge a fake "trusted" section in the LLM's view.
*/
export function escapeEnvelopeSentinels(content: string): string {
const zwsp = '\u200B';
return content
.replace(/═══ BEGIN UNTRUSTED WEB CONTENT ═══/g, `═══ BEGIN UNTRUSTED WEB C${zwsp}ONTENT ═══`)
.replace(/═══ END UNTRUSTED WEB CONTENT ═══/g, `═══ END UNTRUSTED WEB C${zwsp}ONTENT ═══`);
}
/**
* Wrap page content in a trust boundary envelope for scoped tokens.
* Escapes envelope markers in content to prevent boundary escape attacks.
@@ -209,11 +228,7 @@ export function wrapUntrustedPageContent(
command: string,
filterWarnings?: string[],
): string {
// Escape envelope markers in content (zero-width space injection)
const zwsp = '\u200B';
const safeContent = content
.replace(/═══ BEGIN UNTRUSTED WEB CONTENT ═══/g, `═══ BEGIN UNTRUSTED WEB C${zwsp}ONTENT ═══`)
.replace(/═══ END UNTRUSTED WEB CONTENT ═══/g, `═══ END UNTRUSTED WEB C${zwsp}ONTENT ═══`);
const safeContent = escapeEnvelopeSentinels(content);
const parts: string[] = [];

View File

@@ -21,6 +21,7 @@ import type { Page, Frame, Locator } from 'playwright';
import type { TabSession, RefEntry } from './tab-session';
import * as Diff from 'diff';
import { TEMP_DIR, isPathWithin } from './platform';
import { escapeEnvelopeSentinels } from './content-security';
// Roles considered "interactive" for the -i flag
const INTERACTIVE_ROLES = new Set([
@@ -613,8 +614,14 @@ export async function handleSnapshot(
parts.push(...trustedRefs);
parts.push('');
}
// Defuse any envelope sentinel that appears inside the page's own
// accessibility text. Without this, a page whose rendered content
// contains the literal `═══ END UNTRUSTED WEB CONTENT ═══` string
// can close the envelope early and forge a fake "trusted" block
// for the LLM. Same escape that wrapUntrustedPageContent applies.
const safeUntrusted = untrustedLines.map(escapeEnvelopeSentinels);
parts.push('═══ BEGIN UNTRUSTED WEB CONTENT ═══');
parts.push(...untrustedLines);
parts.push(...safeUntrusted);
parts.push('═══ END UNTRUSTED WEB CONTENT ═══');
return parts.join('\n');
}