fix(browse): sanitize lone Unicode surrogates at commandResult chokepoint + /batch envelope (#1440)

Page captures with mixed-script Unicode round-trip cleanly to the Claude API.
Two new utilities in browse/src/sanitize.ts: stripLoneSurrogates for raw UTF-16
strings, stripLoneSurrogateEscapes for \uXXXX JSON escape text. sanitizeBody
picks the right pass based on cr.json.

buildCommandResponse is extracted from handleCommand (now exported) and
applies sanitization before new Response(). /batch was bypassing this
chokepoint via direct JSON.stringify, so it sanitizes each cr.result before
pushing AND wraps the envelope with stripLoneSurrogateEscapes. Defense in
depth wraps at getCleanText, getCleanTextWithStripping, html, accessibility,
and snapshot.ts return points so downstream consumers (datamarking, envelope
wrapping) see sanitized text before the response is built.

25 new unit tests across sanitize.test.ts and build-command-response.test.ts.
content-security.test.ts updated to accept either pre- or post-sanitize form
of the snapshot scoped branch (source-level regression check).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-14 13:58:35 -07:00
parent 40e34deb7a
commit bdb6023713
21 changed files with 756 additions and 17 deletions
+11 -1
View File
@@ -584,7 +584,17 @@ describe('Envelope sentinel escape', () => {
test('scoped snapshot branch applies escapeEnvelopeSentinels to untrusted lines', () => {
const branchStart = SNAPSHOT_SRC.indexOf('splitForScoped');
expect(branchStart).toBeGreaterThan(-1);
const branchEnd = SNAPSHOT_SRC.indexOf("return output.join('\\n');", branchStart);
// Match either the original return (pre-#1440) or the surrogate-sanitized
// form (post-#1440) — both end the scoped branch.
const candidates = [
"return output.join('\\n');",
"return stripLoneSurrogates(output.join('\\n'));",
];
let branchEnd = -1;
for (const c of candidates) {
const idx = SNAPSHOT_SRC.indexOf(c, branchStart);
if (idx > branchStart) { branchEnd = idx; break; }
}
expect(branchEnd).toBeGreaterThan(branchStart);
const branch = SNAPSHOT_SRC.slice(branchStart, branchEnd);
// The escape helper must be invoked on the untrusted lines, and