/** * Copyright (c) 2026, Salesforce, Inc., * All rights reserved. * For full license text, see the LICENSE.txt file */ /** * Canonical Unicode control (Cc: C0/C1/DEL) + format (Cf) character class * (W-23148363, OWASP LLM01). Left raw in host-visible MCP text, these code * points let caller-supplied input forge structure or hide/reorder content: * newlines/CR fabricate "SYSTEM:"-style lines, ESC (U+001B) opens ANSI/SGR * sequences, NEL (U+0085) and the C1 8-bit CSI (U+009B) are alternate * line/escape introducers, and the Cf set (bidi overrides/marks/isolates incl * U+061C, zero-width joiners/spaces, word joiner, BOM, invisible-math * operators, soft hyphen, and the U+E0000–E007F "tag" smuggling block) * reorders or hides text. * * Matched by Unicode property escape so the set tracks future Cc/Cf additions * rather than a hand-enumerated range that drifts (an earlier explicit range * silently missed U+061C / ZWNJ / ZWJ / tag chars — all Cf). This is the SINGLE * SOURCE OF TRUTH for the class: the error-envelope escaper * (`schemas/tool-adapter.ts` `neutralizeControlChars`) and the enum-rejection * stripper (`schemas/fields.ts` `enumStripControlChars`, W-23336443) both * consume it so the two sinks cannot diverge. * * NOTE the two consumers differ in DISPOSITION, deliberately: * - the error envelope ESCAPES (each char → a visible `\xNN`/`\uNNNN` literal) * so a reflected byte stays debuggable but inert; * - the enum-rejection path STRIPS (deletes the char) — a z.enum only ever * reflects the invalid value verbatim inside its own message, so there is * nothing to preserve, and stripping keeps the advertised allowed-value list * (which contains no control chars) an exact round-trip. * U+2028/U+2029 are intentionally OUT of this Cc/Cf class (they are Zl/Zp, not * Cc/Cf); every host-visible sink strips them SEPARATELY via the sibling * {@link LINE_SEPARATOR_RE} / {@link stripLineSeparators} below. */ export const CONTROL_CHAR_RE = /[\p{Cc}\p{Cf}]/gu; /** * Delete every {@link CONTROL_CHAR_RE} code point from `s`. Used to sanitize a * value BEFORE it can be reflected verbatim into a validation-rejection message * that the MCP SDK emits UPSTREAM of the tool adapter (W-23336443), where the * adapter's escaping neutralizer never runs. Ordinary Unicode (accented names, * CJK labels) is untouched. */ export function stripControlChars(s: string): string { return s.replace(CONTROL_CHAR_RE, ""); } /** * Escape every {@link CONTROL_CHAR_RE} code point to a GraphQL-valid `\uXXXX` * escape, for reflection into a LIVE GraphQL DOCUMENT — the value-emission sites * in `lib/query-builder.ts` (`sf_gql_list` scope/filter/orderBy values, and any * nested input-object string, W-23336442). `JSON.stringify` already escapes the * C0 range (U+0000-U+001F) when it builds the quoted literal, but leaves DEL * (U+007F) and the ENTIRE Cf class (bidi overrides, zero-width, BOM, tag block) * raw inside the quotes; this post-pass converts those survivors so the emitted * query still `graphql.parse()`s and carries no smuggled/reordering code points. * * WHY `\uXXXX`-ONLY (and NOT the `\xNN` / `\u{...}` forms that * `neutralizeControlChars` in `schemas/tool-adapter.ts` emits): the output here * is a GraphQL string literal, whose grammar accepts ONLY `\uXXXX` (four fixed * hex digits) as a Unicode escape. `\xNN` is not a GraphQL escape at all — it * would make the document un-parseable — and the variable-width `\u{...}` form * is not accepted by every GraphQL parser. So this escaper deliberately DIVERGES * from the plain-JSON `neutralizeControlChars` (whose `\xNN`/`\u{...}` output is * fine for a JSON envelope but fatal in a query): * - BMP code points -> a single lower-case `\uXXXX` (4 hex). * - Astral code points (e.g. the U+E0000-E007F tag block) -> a UTF-16 surrogate * PAIR `\uXXXX\uXXXX`, which is universally GraphQL-valid, rather than `\u{...}`. * Ordinary Unicode (accented names, CJK labels, emoji) and the Zl/Zp separators * are untouched — they are outside the Cc/Cf class (see {@link CONTROL_CHAR_RE}). */ export function escapeControlCharsGraphQL(s: string): string { return s.replace(CONTROL_CHAR_RE, (c) => { // CONTROL_CHAR_RE carries the `u` flag, so an astral char matches as one // code point; codePointAt(0) recovers its full scalar value. const cp = c.codePointAt(0) ?? 0; if (cp <= 0xffff) return `\\u${cp.toString(16).padStart(4, "0")}`; // Astral: split into a high/low UTF-16 surrogate pair (never `\u{...}`). const v = cp - 0x10000; const hi = 0xd800 + (v >> 10); const lo = 0xdc00 + (v & 0x3ff); return `\\u${hi.toString(16).padStart(4, "0")}\\u${lo.toString(16).padStart(4, "0")}`; }); } /** * Unicode line/paragraph separators (U+2028 Zl / U+2029 Zp). These are legal in * JSON strings and are NOT escaped by `JSON.stringify`, and left raw in * host-visible MCP text they trip a Claude.AI 408 timeout (MCP TS SDK #2155). * They are NOT part of {@link CONTROL_CHAR_RE} (that class is Cc/Cf, and its * escaper consumer emits `\xNN`/`\uNNNN` — a disposition that would be wrong for * these). Every host-visible sink therefore strips them separately: the error/ * success envelopes (`schemas/tool-adapter.ts`) and the enum-rejection path * (`schemas/fields.ts` `enumStripControlChars`, W-23336443), which reaches the * host UPSTREAM of the envelope's own strip. Shared here so those sinks agree. */ export const LINE_SEPARATOR_RE = /[\u2028\u2029]/g; /** Delete every {@link LINE_SEPARATOR_RE} code point from `s`. */ export function stripLineSeparators(s: string): string { return s.replace(LINE_SEPARATOR_RE, ""); }