Skip to content

fix: clip untrusted text without splitting a surrogate pair - #208

Open
rahul05ranjan wants to merge 1 commit into
CopilotKit:mainfrom
rahul05ranjan:fix/clip-text-without-splitting-surrogate-pairs
Open

rahul05ranjan wants to merge 1 commit into
CopilotKit:mainfrom
rahul05ranjan:fix/clip-text-without-splitting-surrogate-pairs

Conversation

@rahul05ranjan

Copy link
Copy Markdown

What changed

Untrusted mail and page text is clipped at UTF-16 limits on its way into tool results, recorded evidence and notifications. A cut that lands between the two halves of an astral character (any emoji outside the BMP) leaves a lone high surrogate at the end of the value — not a character: the UI renders U+FFFD, and any consumer that encodes the value to UTF-8 (Python services reading the same records) fails on it. #207 has the one-line reproduction.

Two places already guard exactly this — clipCalendarText in conversation.ts and sanitizeFileName in files.ts ("Slice code points, not UTF-16 units, so a surrogate pair is never split in half"), and #183 fixed the related surrogate-reference case for the mail list. This PR moves the guard into one shared clipText helper (apps/server/src/text.ts) and applies it to the clip sites that carry untrusted text:

  • engine/conversation.ts — search_mail snippet (240) and read_mail_thread body (12000), replacing the local clipCalendarText whose three calendar call sites now use the helper too;
  • engine/model.ts — search evidence excerpt (500), read_web evidence excerpt (500), read_web returned text (30000), the recorded agent update event (12000);
  • engine/service.ts — mailEvidence excerpt (400), monitor lastValue (1000), observation events (1000), result text (500), notice body (240), monitor evidence excerpt (600);
  • jev/service.ts — recorded page text (30000);
  • browser.ts — returned page text (30000).

Behavior is otherwise identical: the same limits, the same values, plus one extra character dropped when — and only when — the cut would split a pair.

Verification

  • New tests/clip-text.test.ts (node:test, in the existing tests/ suite): six cases — short/BMP strings unchanged, a cut landing on a pair drops the high half with the low half (asserted to end on a, regex-checked not to end on a lone high surrogate), a pair that fits is kept, an exact-pair boundary is exact, and a string of exactly the limit is unchanged. All 6 pass locally on this host (node --experimental-strip-types --test tests/clip-text.test.ts).
  • All seven touched files parse clean under node --experimental-strip-types --check.
  • Direct HEAD-vs-fix demonstration of the reported case: ("a".repeat(239) + "\u{1F44D}").slice(0, 240) ends on \uD83D (lone half, charCodeAt(239) === 0xd83d); clipText on the same input ends on a.
  • pnpm test/pnpm lint/pnpm typecheck were not run here (no pnpm install on this machine); CI is the full-suite run. No provider credentials are involved — the change is pure string handling, verified without any backend.

Integration limits

None.

Mail and page text is clipped at UTF-16 limits on its way into tool
results, evidence and notifications, so a cut that lands between the
two halves of an astral character leaves a lone high surrogate: not a
character, rendered as U+FFFD and rejected by consumers that encode
the value to UTF-8. The calendar tool and sanitizeFileName already
guard this; the new clipText helper gives the same guarantee to every
untrusted-text clip site (mail snippet and thread body, search and web
evidence, read_web text, monitor observations and notices).

Fixes CopilotKit#207

Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com>
@ayuxy027

Copy link
Copy Markdown
Contributor

Checked this out locally:

Typecheck and the new tests pass.

@caspianchan31

Copy link
Copy Markdown

The surrogate fix is right — guarding the cut on a trailing high surrogate covers every clip site in one helper.

One scope gap worth deciding on before merge: clipText only repairs lone halves the clip itself creates. The inputs are untrusted, and a page or mail body that already contains a lone surrogate in the middle (malformed upstream, bad copy/paste) passes through untouched: there is no cut to trip the regex, so it still lands in evidence and storage. A consumer that then encodes to UTF-8 fails the same way this PR fixes — Postgres rejects the insert rather than storing it.

If those inputs are in scope, normalizing at the boundary is cheaper than guarding each sink: text.toWellFormed() is built into Node 22 (replaces each lone half with U+FFFD), or a short isWellFormed() check followed by the existing clip otherwise. That keeps this PR's clip guarantee and closes the no-cut path in one move.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants